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.
- checksums.yaml +4 -4
- data/exe/lutaml-lml-lsp +11 -0
- data/lib/lutaml/lml/cli.rb +166 -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 +63 -21
- data/lib/lutaml/lml/format/adapter/standard_adapter.rb +22 -23
- data/lib/lutaml/lml/formatter/canonical.rb +736 -0
- data/lib/lutaml/lml/formatter.rb +2 -0
- data/lib/lutaml/lml/grammar/lml.artifact.json +2946 -2512
- data/lib/lutaml/lml/grammar/lml.parg +47 -9
- 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/serialization_mappings.rb +36 -10
- data/lib/lutaml/lml/model_compiler.rb +25 -2
- 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 +6 -0
- data/lib/lutaml/lml/models/uml_class.rb +6 -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 +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
|
-
|
|
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
|
-
|
|
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 { }"
|
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
|