lutaml-lml 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/exe/lutaml-lml-lsp +11 -0
  3. data/lib/lutaml/lml/cli.rb +166 -17
  4. data/lib/lutaml/lml/data_processor/attribute_processing.rb +4 -2
  5. data/lib/lutaml/lml/data_processor/value_declaration_processing.rb +1 -0
  6. data/lib/lutaml/lml/data_processor.rb +8 -1
  7. data/lib/lutaml/lml/diagnostic.rb +149 -0
  8. data/lib/lutaml/lml/document_builder.rb +63 -21
  9. data/lib/lutaml/lml/format/adapter/standard_adapter.rb +22 -23
  10. data/lib/lutaml/lml/formatter/canonical.rb +736 -0
  11. data/lib/lutaml/lml/formatter.rb +2 -0
  12. data/lib/lutaml/lml/grammar/lml.artifact.json +2946 -2512
  13. data/lib/lutaml/lml/grammar/lml.parg +47 -9
  14. data/lib/lutaml/lml/grammar.rb +13 -0
  15. data/lib/lutaml/lml/import_resolver.rb +13 -4
  16. data/lib/lutaml/lml/lsp/conversions.rb +78 -0
  17. data/lib/lutaml/lml/lsp/keywords.rb +43 -0
  18. data/lib/lutaml/lml/lsp/server.rb +378 -0
  19. data/lib/lutaml/lml/lsp/store.rb +68 -0
  20. data/lib/lutaml/lml/lsp/transport.rb +53 -0
  21. data/lib/lutaml/lml/lsp.rb +16 -0
  22. data/lib/lutaml/lml/model_compiler/serialization_mappings.rb +36 -10
  23. data/lib/lutaml/lml/model_compiler.rb +25 -2
  24. data/lib/lutaml/lml/models/association.rb +3 -0
  25. data/lib/lutaml/lml/models/constraint.rb +3 -0
  26. data/lib/lutaml/lml/models/data_type.rb +3 -0
  27. data/lib/lutaml/lml/models/diagram.rb +3 -0
  28. data/lib/lutaml/lml/models/document.rb +10 -3
  29. data/lib/lutaml/lml/models/enum.rb +4 -1
  30. data/lib/lutaml/lml/models/instance.rb +3 -0
  31. data/lib/lutaml/lml/models/instance_collection.rb +3 -0
  32. data/lib/lutaml/lml/models/member_field.rb +2 -0
  33. data/lib/lutaml/lml/models/namespace.rb +2 -0
  34. data/lib/lutaml/lml/models/operation.rb +4 -1
  35. data/lib/lutaml/lml/models/package.rb +3 -0
  36. data/lib/lutaml/lml/models/primitive_type.rb +6 -3
  37. data/lib/lutaml/lml/models/serialization_mapping.rb +2 -0
  38. data/lib/lutaml/lml/models/top_element_attribute.rb +6 -0
  39. data/lib/lutaml/lml/models/uml_class.rb +6 -0
  40. data/lib/lutaml/lml/models/value.rb +3 -0
  41. data/lib/lutaml/lml/pipeline.rb +25 -8
  42. data/lib/lutaml/lml/positioned.rb +13 -0
  43. data/lib/lutaml/lml/preprocessor.rb +44 -13
  44. data/lib/lutaml/lml/quoting.rb +60 -0
  45. data/lib/lutaml/lml/source_position.rb +20 -0
  46. data/lib/lutaml/lml/symbol_index.rb +200 -0
  47. data/lib/lutaml/lml/transform.rb +49 -2
  48. data/lib/lutaml/lml/validator.rb +134 -21
  49. data/lib/lutaml/lml/version.rb +1 -1
  50. data/lib/lutaml/lml.rb +18 -1
  51. metadata +18 -4
@@ -9,7 +9,28 @@
9
9
  # capture binds the whole match (Parslet's `.repeat.as(:x)` shape);
10
10
  # `[ x as k ]` yields k: nil when absent (Parslet omits the key —
11
11
  # consumers treat nil and absent as equivalent).
12
- grammar Lml version "1.0.0" {
12
+ grammar Lml version "1.0.0" edition 2 {
13
+ # ---- Glue (parsanol-ruby#364) ----
14
+ # Edition 2 names every uncaptured rule reference; these are the
15
+ # token mechanics the tree must not see: separators that eat `;` and
16
+ # comments, verbatim-scan classes, quote delimiters, identifier
17
+ # runs and keyword tokens. Declaring the whole chain keeps the tree
18
+ # the authored captures only — byte-identical to edition 1 (the
19
+ # corpus differential proves it). Un-glue a rule when its node
20
+ # should surface.
21
+ glue whitespace line_comment block_comment
22
+ glue any_char quotes
23
+ glue word nonspace class_run class_name_chars type_run type_token quoted_name_run
24
+ glue definition_content
25
+ glue kw_abstract_sp kw_artifact_sp kw_association_sp kw_attribute_sp kw_caption_sp
26
+ glue kw_class_sp kw_collection_sp kw_condition_sp kw_data_type_sp kw_definition_sp
27
+ glue kw_derive_sp kw_element_sp kw_enum_sp kw_fontname_sp kw_format_sp kw_hide_sp
28
+ glue kw_import_sp kw_instance_sp kw_isa_sp kw_lml_sp kw_map_attribute_sp kw_map_content_sp
29
+ glue kw_map_element_sp kw_map_key_sp kw_map_sp kw_map_value_sp kw_mapping_sp kw_member_sp
30
+ glue kw_member_type_sp kw_namespace_sp kw_owner_sp kw_owner_type_sp kw_package_sp
31
+ glue kw_pattern_sp kw_prefix_sp kw_primitive_sp kw_require_sp kw_show_sp
32
+ glue kw_string_format_sp kw_title_sp kw_to_instance_sp kw_uri_sp kw_value_sp
33
+ glue kw_values_sp kw_version_sp kw_visibility_sp
13
34
  # ---- Trivia (parsanol-ruby#134 skip rule) ----
14
35
  # Grammar-native trivia replaces the pre-parse comment stripping:
15
36
  # `//` line and `/* */` block comments (RS 3001 par. Comment) plus
@@ -29,6 +50,11 @@ grammar Lml version "1.0.0" {
29
50
  atomic kw_visibility_sp = "visibility" 1*" "
30
51
  atomic kw_version_sp = "version" 1*" "
31
52
  version_declaration = kw_version_sp quoted_string as version
53
+ # ---- Language version ----
54
+ # `lml "1.0"` prologue: the grammar version a document speaks. It is
55
+ # optional and, when present, comes before every other construct.
56
+ atomic kw_lml_sp = "lml" 1*" "
57
+ lml_prologue = [ whitespace ] kw_lml_sp quoted_string as lml_version
32
58
  atomic kw_abstract_sp = "abstract" 1*" "
33
59
  atomic kw_collection_sp = "collection" 1*" "
34
60
  atomic kw_condition_sp = "condition" 1*" "
@@ -96,7 +122,7 @@ grammar Lml version "1.0.0" {
96
122
 
97
123
  boolean = ( "true" / "false" ) as boolean
98
124
  null_literal = "null" as null
99
- number = ( 1*%x30-39 "." 1*%x30-39 ) as float / ( 1*%x30-39 ) as number
125
+ number = ( [ "-" ] 1*%x30-39 "." 1*%x30-39 ) as float / ( [ "-" ] 1*%x30-39 ) as number
100
126
  variable = quoted_string / 1*word
101
127
  reference = ( "reference:(" / "ref:(" ) ( variable *( "." variable ) ) as reference ")"
102
128
  range = ( variable as start ".." variable as end ) as range
@@ -131,14 +157,17 @@ grammar Lml version "1.0.0" {
131
157
  visibility = kw_visibility_modifier as visibility_modifier
132
158
  attribute_stereotype = "<<" ( 1*( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / "/" ) ) as stereotype ">>"
133
159
  atomic type_run = 1*( !( "//" / "/*" ) ( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / ":" / "/" / "+" ) )
134
- atomic type_token = type_run *( %x20 type_run )
160
+ atomic type_token = type_run *( %x20 !( "as" 1*" " ":" ) type_run )
135
161
  atomic namechar = %x41-5A / %x61-7A / %x30-39 / "_" / "-" / "/" / "+"
136
162
  attribute_type = [ ":" ] [ attribute_stereotype ] [ quotes as type_literal ] ( type_token ) as type [ quotes ]
137
163
  attribute_name = ( 1*namechar ) as name
138
164
  atomic quoted_name_run = 1*( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / "/" / "+" )
139
165
  atomic quoted_name_body = quoted_name_run *( %x20 quoted_name_run )
140
166
  attribute_definition_name = ( quotes ( quoted_name_body ) as name quotes ) / attribute_name
141
- attribute_definition = ( [ visibility ] as visibility attribute_definition_name [ attribute_type ] [ cardinality ] [ class_body ] ) as attributes
167
+ # Issue lutaml/lutaml#49: `as :element` / `as :attribute` picks the XML
168
+ # node kind of an attribute (lutaml-model map_element / map_attribute).
169
+ atomic xml_role_suffix = "as" 1*" " ":" ( ( "element" / "attribute" ) as xml_role )
170
+ attribute_definition = ( [ visibility ] as visibility attribute_definition_name [ attribute_type ] [ cardinality ] [ xml_role_suffix ] [ class_body ] ) as attributes
142
171
 
143
172
  keyword_type_argument = "type" ( 1*nonspace ) as type [ whitespace ]
144
173
  keyword_cardinality_argument = "cardinality" cardinality_body_definition as cardinality [ whitespace ]
@@ -149,7 +178,7 @@ grammar Lml version "1.0.0" {
149
178
  value_set_body = "{" [ whitespace ] ( *( ( ( data_value / variable ) as item ) [ "," ] [ whitespace ] ) ) as items "}"
150
179
  keyword_attribute_options = *( keyword_type_argument / keyword_cardinality_argument / keyword_values_argument / keyword_pattern_argument / ( keyword_any_argument ) as properties )
151
180
  keyword_attribute_body = "{" [ whitespace ] keyword_attribute_options [ whitespace ] "}"
152
- keyword_attribute_definition = ( kw_attribute_sp attribute_name keyword_attribute_body ) as attributes
181
+ keyword_attribute_definition = ( kw_attribute_sp attribute_name [ xml_role_suffix ] keyword_attribute_body ) as attributes
153
182
 
154
183
  # ---- Associations ----
155
184
  owner_cardinality = "[" cardinality_body_definition as owner_end_cardinality "]"
@@ -230,7 +259,10 @@ grammar Lml version "1.0.0" {
230
259
  string_format_body = "{" [ whitespace ] ( *( string_format_item ) ) as items [ whitespace ] "}"
231
260
  string_format_definition = ( [ whitespace ] kw_string_format_sp ( variable ) as format string_format_body ) as string_format
232
261
 
233
- class_inner_definitions = visibility_declaration / abstract_declaration / definition_body / definition_line / class_mapping_section / derive_definition / string_format_definition / ( operation_definition ) as operations / attribute_comma_definition / ( !( kw_attribute_sp ) attribute_definition ) / keyword_attribute_definition / comment_definition / comment_multiline_definition
262
+ # Issue lutaml/lutaml#49: a quoted `namespace "uri" [prefix "p"]` line
263
+ # inside a class declares the class's XML namespace.
264
+ class_namespace_definition = ( kw_namespace_sp ( quoted_string ) as uri [ kw_prefix_sp ( quoted_string / ( 1*word ) ) as prefix ] ) as class_namespace [ whitespace ]
265
+ class_inner_definitions = class_namespace_definition / visibility_declaration / abstract_declaration / definition_body / definition_line / class_mapping_section / derive_definition / string_format_definition / ( operation_definition ) as operations / attribute_comma_definition / ( !( kw_attribute_sp ) attribute_definition ) / keyword_attribute_definition / comment_definition / comment_multiline_definition
234
266
  class_inner_definition = class_inner_definitions [ whitespace ]
235
267
  class_body = "{" [ whitespace ] ( *( class_inner_definition ) ) as members "}"
236
268
  parent_class = "<" ( class_name_chars ) as parent_class
@@ -306,11 +338,11 @@ grammar Lml version "1.0.0" {
306
338
  instance_named = ( [ whitespace ] kw_instance_sp quoted_string as name [ ( namespaced_identifier ) as instance_type ] "{" [ whitespace ] ( ( instance ) / attributes ) "}" [ whitespace ] ) as instance
307
339
  isa_definition = [ whitespace ] kw_isa_sp ( namespaced_identifier ) as isa [ whitespace ]
308
340
  keyword_instance = ( [ whitespace ] "instance" [ ( namespaced_identifier ) as instance_type ] "{" [ whitespace ] [ isa_definition ] ( ( instance ) / attributes ) "}" ) as instance [ whitespace ]
309
- class_instance = ( variable as instance_type [ whitespace ] quoted_string as name [ whitespace ] [ [ whitespace ] "extends" [ whitespace ] quoted_string as parent [ whitespace ] ] "{" [ whitespace ] [ lml_instance_body ] "}" ) as instance [ whitespace ]
341
+ class_instance = ( [ whitespace ] variable as instance_type [ whitespace ] quoted_string as name [ whitespace ] [ [ whitespace ] "extends" [ whitespace ] quoted_string as parent [ whitespace ] ] "{" [ whitespace ] [ lml_instance_body ] "}" ) as instance [ whitespace ]
310
342
  lml_instance_body = lml_instance_members [ whitespace ]
311
343
  instance_template = [ whitespace ] "template" [ whitespace ] "{" [ whitespace ] attributes [ whitespace ] "}"
312
344
  lml_instance_members = ( instance_template ) as template / attributes
313
- models = [ whitespace ] "models" [ whitespace ] variable as name [ version_declaration ] [ whitespace ] "{" ( *( ( ( string_format_definition ) as default_string_formats ) / model_body ) ) as members "}" [ whitespace ]
345
+ models = [ whitespace ] "models" [ whitespace ] variable as name [ version_declaration ] [ whitespace ] "{" [ whitespace ] ( *( ( ( string_format_definition ) as default_string_formats ) / model_body ) ) as members "}" [ whitespace ]
314
346
  model_body = diagram_inner_definition
315
347
 
316
348
  # ---- Data structures ----
@@ -330,7 +362,7 @@ grammar Lml version "1.0.0" {
330
362
  export_format = [ whitespace ] kw_format_sp variable as format_type [ whitespace ] "{" [ whitespace ] attributes "}" [ whitespace ]
331
363
 
332
364
  # ---- Root (RS 3001: a document interleaves declarations) ----
333
- diagram = [ require_block ] ( *( top_level_definition ) ) as members
365
+ diagram = [ lml_prologue ] [ require_block ] ( *( top_level_definition ) ) as members
334
366
 
335
367
  top_level_definition = ( models / diagram_definitions / view_definitions / instances / instance / model_definitions ) [ whitespace ]
336
368
  }
@@ -342,6 +374,12 @@ entry diagram: diagram
342
374
  test diagram {
343
375
  accept "// leading\nclass Ceramic { }\n"
344
376
  accept "class /* inline */ Ceramic { }\n"
377
+ accept "lml \"1.0\"\nclass A { }\n"
378
+ accept "lml \"1.0\"\n\nThing \"t0\" { }\n"
379
+ reject "lml 1.0\nclass A { }\n"
380
+ accept "models M {\n}\n"
381
+ accept "instance X {\n a = -42\n}\n"
382
+ accept "models M { }"
345
383
  accept "class Ceramic { } // trailing"
346
384
  accept "instances {\n P \"p\" {\n url = \"http://a//b\"\n }\n}\n"
347
385
  reject "classCeramic Foo { }"
@@ -20,6 +20,19 @@ module Lutaml
20
20
  GRAMMAR_PATH = File.expand_path('grammar/lml.parg', __dir__)
21
21
  ARTIFACT_PATH = File.expand_path('grammar/lml.artifact.json', __dir__)
22
22
 
23
+ # The language version this build implements, read from the
24
+ # compiled artifact (falling back to the grammar text when the
25
+ # artifact is absent). Version negotiation compares document
26
+ # prologues against it.
27
+ def self.version
28
+ @version ||= begin
29
+ envelope = JSON.parse(File.read(ARTIFACT_PATH, encoding: 'UTF-8'))
30
+ envelope['version'].to_s
31
+ rescue StandardError
32
+ File.read(GRAMMAR_PATH, encoding: 'UTF-8')[/grammar \w+ version "([^"]+)"/, 1] || '0.0.0'
33
+ end
34
+ end
35
+
23
36
  def self.artifact
24
37
  @artifact ||= load_artifact || compile_artifact
25
38
  end
@@ -4,6 +4,7 @@ require "set"
4
4
 
5
5
  module Lutaml
6
6
  module Lml
7
+ # Resolves `view import` paths and merges the imported entities.
7
8
  class ImportResolver
8
9
  def initialize(base_dir)
9
10
  @base_dir = base_dir
@@ -24,7 +25,16 @@ module Lutaml
24
25
 
25
26
  collect_local_entities(document, entity_index, associations)
26
27
 
27
- raise ImportError, @failures.join("\n") if @failures.any?
28
+ if @failures.any?
29
+ raise ImportError.new(
30
+ @failures.join("\n"),
31
+ diagnostic: Diagnostic.build(
32
+ 'contract/LML-C05',
33
+ message: @failures.first,
34
+ file: document.source_path,
35
+ ),
36
+ )
37
+ end
28
38
 
29
39
  [entity_index, associations]
30
40
  end
@@ -34,12 +44,11 @@ module Lutaml
34
44
  def resolve_import(path, entity_index, associations, visited, base_dir)
35
45
  abs_pattern = File.expand_path(path, base_dir)
36
46
  files = Dir.glob(abs_pattern)
37
- if files.empty?
38
- @failures << "import matched no files: #{path} (resolved to #{abs_pattern})"
39
- end
47
+ @failures << "import matched no files: #{path} (resolved to #{abs_pattern})" if files.empty?
40
48
 
41
49
  files.each do |file_path|
42
50
  next if visited.include?(file_path)
51
+
43
52
  visited.add(file_path)
44
53
 
45
54
  doc = parse_file(file_path)
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Lutaml
6
+ module Lml
7
+ module Lsp
8
+ # Conversions between the parser's 1-based positions and LSP's
9
+ # 0-based positions, plus diagnostic records.
10
+ module Conversions
11
+ module_function
12
+
13
+ def position(source_position)
14
+ return { line: 0, character: 0 } unless source_position
15
+
16
+ {
17
+ line: source_position.line.to_i - 1,
18
+ character: source_position.column.to_i - 1,
19
+ }
20
+ end
21
+
22
+ def range(source_position)
23
+ start = position(source_position)
24
+ {
25
+ start: start,
26
+ end: {
27
+ line: start[:line],
28
+ character: start[:character] + source_position.length.to_i,
29
+ },
30
+ }
31
+ end
32
+
33
+ def diagnostic(record)
34
+ {
35
+ range: diagnostic_range(record),
36
+ severity: severity(record.severity),
37
+ code: record.code,
38
+ source: "lutaml-lml",
39
+ message: diagnostic_message(record),
40
+ }
41
+ end
42
+
43
+ def diagnostic_range(record)
44
+ line = [record.line.to_i - 1, 0].max
45
+ character = [record.column.to_i - 1, 0].max
46
+ length = record.length.to_i
47
+ length = 1 if length.zero?
48
+ {
49
+ start: { line: line, character: character },
50
+ end: { line: line, character: character + length },
51
+ }
52
+ end
53
+
54
+ def diagnostic_message(record)
55
+ return record.message.to_s if record.remedy.to_s.empty?
56
+
57
+ "#{record.message}\nremedy: #{record.remedy}"
58
+ end
59
+
60
+ def severity(value)
61
+ value.to_s == "warning" ? 2 : 1
62
+ end
63
+
64
+ # file:///path → local path. Windows drive paths lose the leading
65
+ # slash (file:///C:/x → C:/x).
66
+ def uri_to_path(uri)
67
+ path = uri.to_s.sub(%r{\Afile://}, "")
68
+ path = CGI.unescape(path)
69
+ path.sub(%r{\A/([A-Za-z]:/)}, '\1')
70
+ end
71
+
72
+ def path_to_uri(path)
73
+ "file://#{CGI.escape(path.to_s).gsub('+', '%20')}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lutaml
6
+ module Lml
7
+ module Lsp
8
+ # The completion keyword list, read from the compiled grammar
9
+ # artifact so it cannot drift from the language.
10
+ module Keywords
11
+ ARTIFACT = File.expand_path("../grammar/lml.artifact.json", __dir__)
12
+
13
+ module_function
14
+
15
+ def all
16
+ @all ||= begin
17
+ artifact = JSON.parse(File.read(ARTIFACT))
18
+ literals = []
19
+ collect(artifact["entries"], literals)
20
+ literals.grep(/\A[a-z][a-z0-9_]*\z/)
21
+ .uniq
22
+ .sort
23
+ end
24
+ end
25
+
26
+ def collect(node, literals)
27
+ case node
28
+ when Hash
29
+ node.each do |key, value|
30
+ if key.to_s == "Str" && value.is_a?(Hash)
31
+ literals << value["pattern"].to_s
32
+ else
33
+ collect(value, literals)
34
+ end
35
+ end
36
+ when Array
37
+ node.each { |item| collect(item, literals) }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ module Lsp
6
+ # The stdio JSON-RPC server. One message at a time; every document
7
+ # change reparses the full text. Errors are contained per message
8
+ # so a broken document never crashes the server.
9
+ class Server
10
+ WORD = /[A-Za-z0-9_:.-]/
11
+
12
+ CAPABILITIES = {
13
+ textDocumentSync: 1,
14
+ documentSymbolProvider: true,
15
+ definitionProvider: true,
16
+ referencesProvider: true,
17
+ hoverProvider: true,
18
+ completionProvider: { triggerCharacters: [' ', ':'] },
19
+ documentFormattingProvider: true,
20
+ diagnosticProvider: {
21
+ interFileDependencies: false,
22
+ workspaceDiagnostics: false,
23
+ },
24
+ }.freeze
25
+
26
+ def initialize(transport: Transport.new, store: Store.new)
27
+ @transport = transport
28
+ @store = store
29
+ @shutdown = false
30
+ @stopped = false
31
+ end
32
+
33
+ # Runs the message loop; returns the process exit code.
34
+ def run
35
+ until @stopped
36
+ message = @transport.read_message
37
+ break if message.nil?
38
+
39
+ dispatch(message)
40
+ end
41
+ @shutdown ? 0 : 1
42
+ end
43
+
44
+ private
45
+
46
+ def dispatch(message)
47
+ id = message['id']
48
+ method = message['method']
49
+ params = message['params'] || {}
50
+
51
+ case method
52
+ when 'initialize' then respond(id, initialize_result)
53
+ when 'initialized' then nil
54
+ when 'shutdown'
55
+ @shutdown = true
56
+ respond(id, nil)
57
+ when 'exit'
58
+ @stopped = true
59
+ when 'textDocument/didOpen' then did_open(params)
60
+ when 'textDocument/didChange' then did_change(params)
61
+ when 'textDocument/didClose' then did_close(params)
62
+ when 'textDocument/diagnostic' then respond(id, diagnostic_report(params))
63
+ when 'textDocument/documentSymbol' then respond(id, document_symbols(params))
64
+ when 'textDocument/definition' then respond(id, definition(params))
65
+ when 'textDocument/references' then respond(id, references(params))
66
+ when 'textDocument/hover' then respond(id, hover(params))
67
+ when 'textDocument/completion' then respond(id, completion(params))
68
+ when 'textDocument/formatting' then respond(id, formatting(params))
69
+ else
70
+ respond_error(id, -32_601, "Method not found: #{method}") unless id.nil?
71
+ end
72
+ rescue StandardError => e
73
+ respond_error(id, -32_603, e.message) unless id.nil?
74
+ end
75
+
76
+ def initialize_result
77
+ {
78
+ capabilities: CAPABILITIES,
79
+ serverInfo: { name: 'lutaml-lml-lsp', version: Lutaml::Lml::VERSION },
80
+ }
81
+ end
82
+
83
+ # ---- documents ----
84
+
85
+ def did_open(params)
86
+ document = params['textDocument'] || {}
87
+ entry = @store.open(
88
+ uri: document['uri'], text: document['text'].to_s,
89
+ version: document['version']
90
+ )
91
+ publish(entry)
92
+ end
93
+
94
+ def did_change(params)
95
+ document = params['textDocument'] || {}
96
+ changes = params['contentChanges'] || []
97
+ text = changes.last.is_a?(Hash) ? changes.last['text'].to_s : ''
98
+ entry = @store.change(
99
+ uri: document['uri'], text: text, version: document['version'],
100
+ )
101
+ publish(entry) if entry
102
+ end
103
+
104
+ def did_close(params)
105
+ document = params['textDocument'] || {}
106
+ uri = document['uri']
107
+ @store.close(uri)
108
+ @transport.write_message(
109
+ jsonrpc: '2.0', method: 'textDocument/publishDiagnostics',
110
+ params: { uri: uri, diagnostics: [] }
111
+ )
112
+ end
113
+
114
+ def publish(entry)
115
+ @transport.write_message(
116
+ jsonrpc: '2.0', method: 'textDocument/publishDiagnostics',
117
+ params: {
118
+ uri: entry.uri,
119
+ diagnostics: entry.diagnostics.map { |record| Conversions.diagnostic(record) },
120
+ }
121
+ )
122
+ end
123
+
124
+ def diagnostic_report(params)
125
+ entry = entry_for(params)
126
+ return { kind: 'full', items: [] } unless entry
127
+
128
+ {
129
+ kind: 'full',
130
+ items: entry.diagnostics.map { |record| Conversions.diagnostic(record) },
131
+ }
132
+ end
133
+
134
+ # ---- symbols ----
135
+
136
+ def document_symbols(params)
137
+ entry = entry_for(params)
138
+ return [] unless entry&.model
139
+
140
+ SymbolIndex.new(entry.model).entries.map do |symbol|
141
+ {
142
+ name: symbol.name,
143
+ kind: SymbolIndex::KINDS.fetch(symbol.kind),
144
+ location: location(entry.uri, symbol.position),
145
+ containerName: symbol.container,
146
+ }.compact
147
+ end
148
+ end
149
+
150
+ def definition(params)
151
+ entry = entry_for(params)
152
+ return nil unless entry&.model
153
+
154
+ token = token_at(entry, params['position'])
155
+ return nil if token.empty?
156
+
157
+ index = SymbolIndex.new(entry.model)
158
+ locations = resolve(index, token).map { |symbol| location(entry.uri, symbol.position) }
159
+ locations.empty? ? nil : locations
160
+ end
161
+
162
+ def references(params)
163
+ entry = entry_for(params)
164
+ return [] unless entry&.model
165
+
166
+ token = token_at(entry, params['position'])
167
+ return [] if token.empty?
168
+
169
+ index = SymbolIndex.new(entry.model)
170
+ symbols = index.uses(token)
171
+ symbols += index.definitions(token) if params.dig('context', 'includeDeclaration')
172
+
173
+ symbols.map { |symbol| location(entry.uri, symbol.position) }.uniq
174
+ end
175
+
176
+ def hover(params)
177
+ entry = entry_for(params)
178
+ return nil unless entry&.model
179
+
180
+ position = params['position']
181
+ token = token_at(entry, position)
182
+ return nil if token.empty?
183
+
184
+ symbol = resolve(SymbolIndex.new(entry.model), token).first
185
+ return nil unless symbol
186
+
187
+ {
188
+ contents: { kind: 'markdown', value: hover_markdown(symbol) },
189
+ range: token_range(entry, position, token),
190
+ }
191
+ end
192
+
193
+ def hover_markdown(symbol)
194
+ case symbol.kind
195
+ when :class then class_markdown(symbol.record)
196
+ when :enum then enum_markdown(symbol.record)
197
+ when :field then field_markdown(symbol.record)
198
+ when :instance then instance_markdown(symbol.record)
199
+ when :enum_member then enum_member_markdown(symbol.record)
200
+ else "**#{symbol.name}**"
201
+ end
202
+ end
203
+
204
+ def class_markdown(klass)
205
+ text = "```lml\nclass #{klass.name}"
206
+ text << " < #{klass.parent_class}" unless klass.parent_class.to_s.empty?
207
+ text << "\n```"
208
+ append_definition(text, klass.definition)
209
+ end
210
+
211
+ def enum_markdown(enum)
212
+ members = Array(enum.attributes).map { |member| member.name.to_s }
213
+ text = "```lml\nenum #{enum.name}\n```"
214
+ text << "\n\nMembers: #{members.join(', ')}" unless members.empty?
215
+ append_definition(text, enum.definition)
216
+ end
217
+
218
+ def field_markdown(attribute)
219
+ label = attribute.name.to_s
220
+ type = attribute.type.to_s
221
+ text = "```lml\n#{label}"
222
+ text << ": #{type}" unless type.empty?
223
+ cardinality = attribute.cardinality
224
+ if cardinality
225
+ max = cardinality.max.to_s
226
+ text << " [#{cardinality.min}#{"..#{max}" unless max.empty?}]"
227
+ end
228
+ text << "\n```"
229
+ text << "\n\ndefault: `#{attribute.default}`" if attribute.default
230
+ text << "\npattern: `#{attribute.pattern}`" if attribute.pattern
231
+ text << "\nvalues: #{attribute.value_set.join(', ')}" if Array(attribute.value_set).any?
232
+ append_definition(text, attribute.definition)
233
+ text << "\n\n`null`, `\"\"`, and an unassigned attribute are distinct." if type == 'String'
234
+ text
235
+ end
236
+
237
+ def instance_markdown(instance)
238
+ label = instance.type.to_s.empty? ? instance.name.to_s : instance.type.to_s
239
+ text = "```lml\ninstance #{label}\n```"
240
+ text << "\n\n#{Array(instance.attributes).size} assignment(s)"
241
+ text
242
+ end
243
+
244
+ def enum_member_markdown(member)
245
+ text = "**value #{member.name}**"
246
+ description = Array(member.attributes).find { |attribute| attribute.name == 'description' }
247
+ text << "\n\n#{description.type}" if description
248
+ text
249
+ end
250
+
251
+ def append_definition(text, definition)
252
+ value = definition.to_s
253
+ text << "\n\n#{value}" unless value.empty?
254
+ text
255
+ end
256
+
257
+ # ---- completion ----
258
+
259
+ def completion(params)
260
+ entry = entry_for(params)
261
+ prefix = completion_prefix(entry, params['position'])
262
+ items = completion_items(entry)
263
+ items = items.select { |item| item[:label].start_with?(prefix) } unless prefix.empty?
264
+
265
+ { isIncomplete: false, items: items }
266
+ end
267
+
268
+ def completion_items(entry)
269
+ items = Keywords.all.map do |keyword|
270
+ { label: keyword, kind: 14, detail: 'LML keyword' }
271
+ end
272
+ return dedupe(items) unless entry&.model
273
+
274
+ index = SymbolIndex.new(entry.model)
275
+ index.entity_entries.each do |symbol|
276
+ items << {
277
+ label: symbol.name,
278
+ kind: SymbolIndex::KINDS.fetch(symbol.kind),
279
+ detail: symbol.detail || symbol.container,
280
+ }
281
+ end
282
+ index.attribute_entries.each do |symbol|
283
+ items << { label: symbol.name, kind: 8, detail: symbol.detail }
284
+ end
285
+ dedupe(items)
286
+ end
287
+
288
+ def dedupe(items)
289
+ seen = {}
290
+ items.each_with_object([]) do |item, result|
291
+ next if seen[item[:label]]
292
+
293
+ seen[item[:label]] = true
294
+ result << item.compact
295
+ end
296
+ end
297
+
298
+ # ---- formatting ----
299
+
300
+ def formatting(params)
301
+ entry = entry_for(params)
302
+ return nil unless entry&.model
303
+
304
+ canonical = Lutaml::Formatter::Canonical.new(entry.model).render
305
+ return [] if canonical == entry.text
306
+
307
+ [{ range: full_range(entry.text), newText: canonical }]
308
+ end
309
+
310
+ def full_range(text)
311
+ {
312
+ start: { line: 0, character: 0 },
313
+ end: { line: text.lines.length, character: 0 },
314
+ }
315
+ end
316
+
317
+ # ---- helpers ----
318
+
319
+ def entry_for(params)
320
+ document = params['textDocument'] || {}
321
+ @store[document['uri']]
322
+ end
323
+
324
+ def location(uri, position)
325
+ { uri: uri, range: Conversions.range(position) }
326
+ end
327
+
328
+ def resolve(index, token)
329
+ symbols = index.definitions(token)
330
+ return symbols unless symbols.empty?
331
+
332
+ token.split('.').flat_map { |segment| index.definitions(segment) }.uniq
333
+ end
334
+
335
+ def token_at(entry, position)
336
+ line = entry.text.lines[position['line'].to_i].to_s
337
+ index = [position['character'].to_i, line.length].min
338
+ start = index
339
+ start -= 1 while start.positive? && line[start - 1].match?(WORD)
340
+ finish = index
341
+ finish += 1 while finish < line.length && line[finish].match?(WORD)
342
+ line[start...finish].to_s
343
+ end
344
+
345
+ def token_range(entry, position, token)
346
+ line = entry.text.lines[position['line'].to_i].to_s
347
+ finish = [position['character'].to_i, line.length].min
348
+ start = [finish - token.length, 0].max
349
+ {
350
+ start: { line: position['line'].to_i, character: start },
351
+ end: { line: position['line'].to_i, character: start + token.length },
352
+ }
353
+ end
354
+
355
+ def completion_prefix(entry, position)
356
+ return '' unless entry
357
+
358
+ line = entry.text.lines[position['line'].to_i].to_s
359
+ before = line[0...[position['character'].to_i, line.length].min].to_s
360
+ before[/[A-Za-z0-9_]*\z/].to_s
361
+ end
362
+
363
+ def respond(id, result)
364
+ return if id.nil?
365
+
366
+ @transport.write_message(jsonrpc: '2.0', id: id, result: result)
367
+ end
368
+
369
+ def respond_error(id, code, message)
370
+ @transport.write_message(
371
+ jsonrpc: '2.0', id: id,
372
+ error: { code: code, message: message }
373
+ )
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end