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.
- checksums.yaml +4 -4
- data/exe/lutaml-lml-lsp +11 -0
- data/lib/lutaml/lml/cli.rb +165 -17
- data/lib/lutaml/lml/data_processor/attribute_processing.rb +4 -2
- data/lib/lutaml/lml/data_processor/value_declaration_processing.rb +1 -0
- data/lib/lutaml/lml/data_processor.rb +8 -1
- data/lib/lutaml/lml/diagnostic.rb +149 -0
- data/lib/lutaml/lml/document_builder.rb +53 -21
- data/lib/lutaml/lml/format/adapter/standard_adapter.rb +22 -23
- data/lib/lutaml/lml/formatter/canonical.rb +719 -0
- data/lib/lutaml/lml/formatter.rb +2 -0
- data/lib/lutaml/lml/grammar/lml.artifact.json +2626 -2439
- data/lib/lutaml/lml/grammar/lml.parg +15 -4
- data/lib/lutaml/lml/grammar.rb +13 -0
- data/lib/lutaml/lml/import_resolver.rb +13 -4
- data/lib/lutaml/lml/lsp/conversions.rb +78 -0
- data/lib/lutaml/lml/lsp/keywords.rb +43 -0
- data/lib/lutaml/lml/lsp/server.rb +378 -0
- data/lib/lutaml/lml/lsp/store.rb +68 -0
- data/lib/lutaml/lml/lsp/transport.rb +53 -0
- data/lib/lutaml/lml/lsp.rb +16 -0
- data/lib/lutaml/lml/model_compiler.rb +16 -1
- data/lib/lutaml/lml/models/association.rb +3 -0
- data/lib/lutaml/lml/models/constraint.rb +3 -0
- data/lib/lutaml/lml/models/data_type.rb +3 -0
- data/lib/lutaml/lml/models/diagram.rb +3 -0
- data/lib/lutaml/lml/models/document.rb +10 -3
- data/lib/lutaml/lml/models/enum.rb +4 -1
- data/lib/lutaml/lml/models/instance.rb +3 -0
- data/lib/lutaml/lml/models/instance_collection.rb +3 -0
- data/lib/lutaml/lml/models/member_field.rb +2 -0
- data/lib/lutaml/lml/models/namespace.rb +2 -0
- data/lib/lutaml/lml/models/operation.rb +4 -1
- data/lib/lutaml/lml/models/package.rb +3 -0
- data/lib/lutaml/lml/models/primitive_type.rb +6 -3
- data/lib/lutaml/lml/models/serialization_mapping.rb +2 -0
- data/lib/lutaml/lml/models/top_element_attribute.rb +3 -0
- data/lib/lutaml/lml/models/uml_class.rb +3 -0
- data/lib/lutaml/lml/models/value.rb +3 -0
- data/lib/lutaml/lml/pipeline.rb +25 -8
- data/lib/lutaml/lml/positioned.rb +13 -0
- data/lib/lutaml/lml/preprocessor.rb +44 -13
- data/lib/lutaml/lml/quoting.rb +60 -0
- data/lib/lutaml/lml/source_position.rb +20 -0
- data/lib/lutaml/lml/symbol_index.rb +200 -0
- data/lib/lutaml/lml/transform.rb +49 -2
- data/lib/lutaml/lml/validator.rb +134 -21
- data/lib/lutaml/lml/version.rb +1 -1
- data/lib/lutaml/lml.rb +18 -1
- 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 { }"
|
data/lib/lutaml/lml/grammar.rb
CHANGED
|
@@ -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
|
-
|
|
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
|