lutaml-lml 0.3.3 → 0.3.4

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/exe/lutaml-lml-lsp +11 -0
  3. data/lib/lutaml/lml/cli.rb +165 -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 +53 -21
  9. data/lib/lutaml/lml/format/adapter/standard_adapter.rb +22 -23
  10. data/lib/lutaml/lml/formatter/canonical.rb +719 -0
  11. data/lib/lutaml/lml/formatter.rb +2 -0
  12. data/lib/lutaml/lml/grammar/lml.artifact.json +2626 -2439
  13. data/lib/lutaml/lml/grammar/lml.parg +15 -4
  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.rb +16 -1
  23. data/lib/lutaml/lml/models/association.rb +3 -0
  24. data/lib/lutaml/lml/models/constraint.rb +3 -0
  25. data/lib/lutaml/lml/models/data_type.rb +3 -0
  26. data/lib/lutaml/lml/models/diagram.rb +3 -0
  27. data/lib/lutaml/lml/models/document.rb +10 -3
  28. data/lib/lutaml/lml/models/enum.rb +4 -1
  29. data/lib/lutaml/lml/models/instance.rb +3 -0
  30. data/lib/lutaml/lml/models/instance_collection.rb +3 -0
  31. data/lib/lutaml/lml/models/member_field.rb +2 -0
  32. data/lib/lutaml/lml/models/namespace.rb +2 -0
  33. data/lib/lutaml/lml/models/operation.rb +4 -1
  34. data/lib/lutaml/lml/models/package.rb +3 -0
  35. data/lib/lutaml/lml/models/primitive_type.rb +6 -3
  36. data/lib/lutaml/lml/models/serialization_mapping.rb +2 -0
  37. data/lib/lutaml/lml/models/top_element_attribute.rb +3 -0
  38. data/lib/lutaml/lml/models/uml_class.rb +3 -0
  39. data/lib/lutaml/lml/models/value.rb +3 -0
  40. data/lib/lutaml/lml/pipeline.rb +25 -8
  41. data/lib/lutaml/lml/positioned.rb +13 -0
  42. data/lib/lutaml/lml/preprocessor.rb +44 -13
  43. data/lib/lutaml/lml/quoting.rb +60 -0
  44. data/lib/lutaml/lml/source_position.rb +20 -0
  45. data/lib/lutaml/lml/symbol_index.rb +200 -0
  46. data/lib/lutaml/lml/transform.rb +49 -2
  47. data/lib/lutaml/lml/validator.rb +134 -21
  48. data/lib/lutaml/lml/version.rb +1 -1
  49. data/lib/lutaml/lml.rb +18 -1
  50. metadata +16 -2
@@ -29,6 +29,11 @@ grammar Lml version "1.0.0" {
29
29
  atomic kw_visibility_sp = "visibility" 1*" "
30
30
  atomic kw_version_sp = "version" 1*" "
31
31
  version_declaration = kw_version_sp quoted_string as version
32
+ # ---- Language version ----
33
+ # `lml "1.0"` prologue: the grammar version a document speaks. It is
34
+ # optional and, when present, comes before every other construct.
35
+ atomic kw_lml_sp = "lml" 1*" "
36
+ lml_prologue = [ whitespace ] kw_lml_sp quoted_string as lml_version
32
37
  atomic kw_abstract_sp = "abstract" 1*" "
33
38
  atomic kw_collection_sp = "collection" 1*" "
34
39
  atomic kw_condition_sp = "condition" 1*" "
@@ -96,7 +101,7 @@ grammar Lml version "1.0.0" {
96
101
 
97
102
  boolean = ( "true" / "false" ) as boolean
98
103
  null_literal = "null" as null
99
- number = ( 1*%x30-39 "." 1*%x30-39 ) as float / ( 1*%x30-39 ) as number
104
+ number = ( [ "-" ] 1*%x30-39 "." 1*%x30-39 ) as float / ( [ "-" ] 1*%x30-39 ) as number
100
105
  variable = quoted_string / 1*word
101
106
  reference = ( "reference:(" / "ref:(" ) ( variable *( "." variable ) ) as reference ")"
102
107
  range = ( variable as start ".." variable as end ) as range
@@ -306,11 +311,11 @@ grammar Lml version "1.0.0" {
306
311
  instance_named = ( [ whitespace ] kw_instance_sp quoted_string as name [ ( namespaced_identifier ) as instance_type ] "{" [ whitespace ] ( ( instance ) / attributes ) "}" [ whitespace ] ) as instance
307
312
  isa_definition = [ whitespace ] kw_isa_sp ( namespaced_identifier ) as isa [ whitespace ]
308
313
  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 ]
314
+ 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
315
  lml_instance_body = lml_instance_members [ whitespace ]
311
316
  instance_template = [ whitespace ] "template" [ whitespace ] "{" [ whitespace ] attributes [ whitespace ] "}"
312
317
  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 ]
318
+ models = [ whitespace ] "models" [ whitespace ] variable as name [ version_declaration ] [ whitespace ] "{" [ whitespace ] ( *( ( ( string_format_definition ) as default_string_formats ) / model_body ) ) as members "}" [ whitespace ]
314
319
  model_body = diagram_inner_definition
315
320
 
316
321
  # ---- Data structures ----
@@ -330,7 +335,7 @@ grammar Lml version "1.0.0" {
330
335
  export_format = [ whitespace ] kw_format_sp variable as format_type [ whitespace ] "{" [ whitespace ] attributes "}" [ whitespace ]
331
336
 
332
337
  # ---- Root (RS 3001: a document interleaves declarations) ----
333
- diagram = [ require_block ] ( *( top_level_definition ) ) as members
338
+ diagram = [ lml_prologue ] [ require_block ] ( *( top_level_definition ) ) as members
334
339
 
335
340
  top_level_definition = ( models / diagram_definitions / view_definitions / instances / instance / model_definitions ) [ whitespace ]
336
341
  }
@@ -342,6 +347,12 @@ entry diagram: diagram
342
347
  test diagram {
343
348
  accept "// leading\nclass Ceramic { }\n"
344
349
  accept "class /* inline */ Ceramic { }\n"
350
+ accept "lml \"1.0\"\nclass A { }\n"
351
+ accept "lml \"1.0\"\n\nThing \"t0\" { }\n"
352
+ reject "lml 1.0\nclass A { }\n"
353
+ accept "models M {\n}\n"
354
+ accept "instance X {\n a = -42\n}\n"
355
+ accept "models M { }"
345
356
  accept "class Ceramic { } // trailing"
346
357
  accept "instances {\n P \"p\" {\n url = \"http://a//b\"\n }\n}\n"
347
358
  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
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ module Lsp
6
+ # The open-document store. Every change reparses the full text
7
+ # (full-document sync) and recomputes diagnostics.
8
+ class Store
9
+ Entry = Struct.new(:uri, :text, :version, :model, :diagnostics,
10
+ keyword_init: true)
11
+
12
+ def initialize
13
+ @entries = {}
14
+ end
15
+
16
+ def open(uri:, text:, version: nil)
17
+ entry = Entry.new(uri: uri, text: text, version: version)
18
+ parse(entry)
19
+ @entries[uri] = entry
20
+ end
21
+
22
+ def change(uri:, text:, version: nil)
23
+ entry = @entries[uri]
24
+ return nil unless entry
25
+
26
+ entry.text = text
27
+ entry.version = version
28
+ parse(entry)
29
+ entry
30
+ end
31
+
32
+ def close(uri)
33
+ @entries.delete(uri)
34
+ end
35
+
36
+ def [](uri)
37
+ @entries[uri]
38
+ end
39
+
40
+ private
41
+
42
+ def parse(entry)
43
+ path = Conversions.uri_to_path(entry.uri)
44
+ entry.model = Lutaml::Lml.parse_document(entry.text)
45
+ entry.model.source_path = path
46
+ entry.diagnostics = Validator.diagnostics(entry.model)
47
+ rescue Lutaml::Lml::Error => e
48
+ entry.model = nil
49
+ entry.diagnostics = [diagnostic_for(e, path)]
50
+ end
51
+
52
+ def diagnostic_for(error, path)
53
+ record = error.diagnostic
54
+ return record if record&.file
55
+
56
+ Diagnostic.build(
57
+ record&.code || 'parse/LML-001',
58
+ message: record&.message || error.message.lines.first.to_s.strip,
59
+ file: path,
60
+ line: record&.line,
61
+ column: record&.column,
62
+ length: record&.length,
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end