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
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # Expands `include` directives before parsing. Unterminated block
6
+ # comments are rejected here; line comments are grammar trivia.
5
7
  class Preprocessor
6
8
  attr_reader :input_file
7
9
 
@@ -16,7 +18,7 @@ module Lutaml
16
18
  end
17
19
 
18
20
  def call
19
- expand_lines(@input_file.read, @input_file.base_dir, [])
21
+ expand_lines(@input_file.read, @input_file.base_dir, [], @input_file.path)
20
22
  end
21
23
 
22
24
  private
@@ -25,24 +27,38 @@ module Lutaml
25
27
  # skip (parsanol-ruby#134); only include directives are expanded
26
28
  # here. Line structure is preserved so parse-error positions
27
29
  # match the source.
28
- def expand_lines(text, base_dir, chain)
29
- validate_block_comments(text)
30
+ def expand_lines(text, base_dir, chain, file_path)
31
+ validate_block_comments(text, file_path)
30
32
  text.split(/\r?\n/)
31
- .flat_map { |line| expand_line(line, base_dir, chain) }
33
+ .each_with_index
34
+ .flat_map { |line, index| expand_line(line, base_dir, chain, file_path, index + 1) }
32
35
  .join("\n")
33
36
  end
34
37
 
35
- def expand_line(line, base_dir, chain)
38
+ def expand_line(line, base_dir, chain, file_path, line_number)
36
39
  path = include_path(line, base_dir)
37
40
  return [line] unless path
38
41
 
39
- raise Error, "circular include detected: #{path}" if chain.include?(path)
42
+ if chain.include?(path)
43
+ raise Error.new(
44
+ "circular include detected: #{path}",
45
+ diagnostic: Diagnostic.build(
46
+ 'parse/LML-003',
47
+ message: "circular include detected: #{path}",
48
+ file: file_path, line: line_number, column: 1
49
+ ),
50
+ )
51
+ end
40
52
 
41
- expand_lines(read_included(path), File.dirname(path), chain + [path])
53
+ expand_lines(read_included(path, file_path, line_number),
54
+ File.dirname(path), chain + [path], path)
42
55
  end
43
56
 
44
57
  def include_path(line, base_dir)
45
- match = line.match(/^\s*include\s+(.+)$/)
58
+ # Linear-time match: `\S.*` cannot overlap the leading `[ \t]+`,
59
+ # so a line of spaces after `include` fails in O(n) rather than
60
+ # backtracking quadratically (CodeQL ReDoS).
61
+ match = line.match(/\A[ \t]*include[ \t]+(\S.*)\z/)
46
62
  return nil unless match
47
63
 
48
64
  File.expand_path(trim_trailing_comment(match[1].strip), base_dir)
@@ -52,17 +68,24 @@ module Lutaml
52
68
  path.sub(%r{\s+(//|/\*).*}, '')
53
69
  end
54
70
 
55
- def read_included(path)
71
+ def read_included(path, file_path, line_number)
56
72
  File.read(path, encoding: 'UTF-8')
57
- rescue Errno::ENOENT, Errno::EACCES => e
58
- raise Error, "cannot read include #{path}: #{e.message}"
73
+ rescue SystemCallError => e
74
+ raise Error.new(
75
+ "cannot read include #{path}: #{e.message}",
76
+ diagnostic: Diagnostic.build(
77
+ 'parse/LML-003',
78
+ message: "cannot read include: #{path}",
79
+ file: file_path, line: line_number, column: 1
80
+ ),
81
+ )
59
82
  end
60
83
 
61
84
  # Quote-aware scan: a `/*` opened outside a string literal or a
62
85
  # line comment must close before end of input. The grammar's
63
86
  # trivia would otherwise reject the text with an unrelated
64
87
  # expected-token error.
65
- def validate_block_comments(text)
88
+ def validate_block_comments(text, file_path)
66
89
  quote = nil
67
90
  pos = 0
68
91
  open_index = nil
@@ -92,7 +115,15 @@ module Lutaml
92
115
  return unless open_index
93
116
 
94
117
  line = text[0...open_index].count("\n") + 1
95
- raise Error, "unterminated block comment at line #{line}"
118
+ column = open_index - (text.rindex("\n", open_index) || -1)
119
+ raise Error.new(
120
+ "unterminated block comment at line #{line}",
121
+ diagnostic: Diagnostic.build(
122
+ 'parse/LML-002',
123
+ message: 'unterminated block comment',
124
+ file: file_path, line: line, column: column
125
+ ),
126
+ )
96
127
  end
97
128
  end
98
129
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ # The single source of truth for scalar quoting on the emit side:
6
+ # bare only for runs of [A-Za-z0-9_] (the grammar's word rule);
7
+ # double quotes with the two RS 3001 escapes otherwise. Values,
8
+ # attribute keys, and the canonical form all emit through here.
9
+ module Quoting
10
+ BARE_WORD = /\A\w+\z/
11
+
12
+ module_function
13
+
14
+ # @param scalar [String, #to_s]
15
+ # @return [String] the emitted form: parseable back to scalar
16
+ def quote_scalar(scalar)
17
+ str = scalar.to_s
18
+ return str if str.match?(BARE_WORD)
19
+
20
+ quote_always(str)
21
+ end
22
+
23
+ # Double-quoted form even when the scalar is a bare word. Used
24
+ # where the grammar only accepts quoted strings (definitions,
25
+ # requires, instance data values, wire names).
26
+ #
27
+ # @param scalar [String, #to_s]
28
+ # @return [String] the double-quoted, escaped form
29
+ def quote_always(scalar)
30
+ escaped = scalar.to_s.gsub('\\') { |bs| bs * 2 }.gsub('"') { |q| "\\#{q}" }
31
+ "\"#{escaped}\""
32
+ end
33
+
34
+ # Float#to_s may use exponent notation, for which the language has
35
+ # no literal. Expand it to a plain decimal that parses back to the
36
+ # same Float.
37
+ #
38
+ # @param number [Numeric]
39
+ # @return [String] the emitted form
40
+ def number_scalar(number)
41
+ text = number.to_s
42
+ match = text.match(/\A(-?)(\d+)(?:\.(\d+))?[eE]([+-]?\d+)\z/)
43
+ return text unless match
44
+
45
+ integer = match[2]
46
+ digits = integer + match[3].to_s
47
+ point = integer.length + match[4].to_i
48
+ plain =
49
+ if point <= 0
50
+ "0.#{'0' * -point}#{digits}"
51
+ elsif point >= digits.length
52
+ "#{digits}#{'0' * (point - digits.length)}.0"
53
+ else
54
+ "#{digits[0...point]}.#{digits[point..]}"
55
+ end
56
+ "#{match[1]}#{plain}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ # A position in the parsed source: 1-based line and column, with the
6
+ # byte offset and captured length when the parser provides them.
7
+ # Attached to model objects as non-serialized metadata so diagnostics
8
+ # can cite it without changing the document model's wire form.
9
+ class SourcePosition
10
+ attr_reader :line, :column, :offset, :length
11
+
12
+ def initialize(line:, column:, offset: nil, length: nil)
13
+ @line = line
14
+ @column = column
15
+ @offset = offset
16
+ @length = length
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ # A flat, positioned index of the document's symbols and the uses
6
+ # that reference them. Backs navigation, hover, and completion in
7
+ # tooling, and is available as Document#symbols.
8
+ class SymbolIndex
9
+ Entry = Struct.new(:name, :kind, :container, :position, :detail, :record,
10
+ keyword_init: true)
11
+
12
+ # LSP SymbolKind values.
13
+ KINDS = {
14
+ package: 4, class: 5, method: 6, field: 8,
15
+ enum: 10, instance: 19, enum_member: 22, data_type: 23
16
+ }.freeze
17
+
18
+ attr_reader :entries
19
+
20
+ def initialize(document)
21
+ @entries = []
22
+ return unless document
23
+
24
+ walk_document(document)
25
+ end
26
+
27
+ def definitions(name)
28
+ @entries.select { |entry| entry.name.to_s == name.to_s }
29
+ end
30
+
31
+ def uses(name)
32
+ token = name.to_s
33
+ @entries.select do |entry|
34
+ if entry.record.is_a?(TopElementAttribute)
35
+ reference?(entry.record, token)
36
+ elsif entry.record.is_a?(Instance)
37
+ [entry.record.type.to_s, entry.record.isa.to_s].include?(token)
38
+ else
39
+ false
40
+ end
41
+ end
42
+ end
43
+
44
+ def attribute_entries
45
+ @entries.select { |entry| entry.kind == :field }
46
+ end
47
+
48
+ def entity_entries
49
+ @entries.reject { |entry| %i[field method enum_member].include?(entry.kind) }
50
+ end
51
+
52
+ def names
53
+ @entries.map(&:name).compact.uniq
54
+ end
55
+
56
+ private
57
+
58
+ def walk_document(document)
59
+ walk_members(document, nil)
60
+ walk_instance(document.instance, nil) if document.instance
61
+ walk_collection(document.instances) if document.instances
62
+ end
63
+
64
+ def walk_members(owner, container)
65
+ Array(owner.classes).each { |klass| walk_class(klass, container) }
66
+ Array(owner.enums).each { |enum| walk_enum(enum, container) }
67
+ Array(owner.data_types).each { |type| walk_data_type(type, container) }
68
+ Array(owner.packages).each { |package| walk_package(package, container) }
69
+ end
70
+
71
+ def walk_package(package, container)
72
+ name = package.name.to_s
73
+ add(name, :package, container, package.source_position, nil, package)
74
+ walk_members(package, name)
75
+ end
76
+
77
+ def walk_class(klass, container)
78
+ name = klass.name.to_s
79
+ add(name, :class, container, klass.source_position, parent_detail(klass), klass)
80
+ Array(klass.attributes).each do |attribute|
81
+ add(attribute.name, :field, name, attribute.source_position,
82
+ attribute_detail(attribute), attribute)
83
+ end
84
+ Array(klass.operations).each do |operation|
85
+ add(operation.name, :method, name, operation.source_position,
86
+ operation_detail(operation), operation)
87
+ end
88
+ Array(klass.data_types).each { |type| walk_data_type(type, name) }
89
+ end
90
+
91
+ def walk_enum(enum, container)
92
+ name = enum.name.to_s
93
+ add(name, :enum, container, enum.source_position, nil, enum)
94
+ Array(enum.attributes).each do |member|
95
+ add(member.name, :enum_member, name, member.source_position,
96
+ enum_member_detail(member), member)
97
+ end
98
+ Array(enum.member_fields).each do |field|
99
+ add(field.name, :field, name, field.source_position,
100
+ field.type.to_s, field)
101
+ end
102
+ end
103
+
104
+ def walk_data_type(type, container)
105
+ name = type.name.to_s
106
+ add(name, :data_type, container, type.source_position, nil, type)
107
+ Array(type.attributes).each do |attribute|
108
+ add(attribute.name, :field, name, attribute.source_position,
109
+ attribute_detail(attribute), attribute)
110
+ end
111
+ end
112
+
113
+ def walk_collection(collection)
114
+ return unless collection.is_a?(InstanceCollection)
115
+
116
+ name = collection.name.to_s
117
+ add(name, :package, nil, collection.source_position, 'instances', collection)
118
+ Array(collection.instances).each { |instance| walk_instance(instance, name) }
119
+ end
120
+
121
+ def walk_instance(instance, container)
122
+ name = instance_label(instance)
123
+ add(name, :instance, container, instance.source_position, name, instance)
124
+ Array(instance.attributes).each do |attribute|
125
+ next if attribute.name.to_s.empty?
126
+
127
+ add(attribute.name, :field, name, attribute.source_position,
128
+ attribute_value_detail(attribute), attribute)
129
+ Array(attribute.instances).each { |nested| walk_instance(nested, name) }
130
+ end
131
+ walk_instance(instance.instance, name) if instance.instance
132
+ end
133
+
134
+ def add(name, kind, container, position, detail, record)
135
+ return if name.to_s.empty? || position.nil?
136
+
137
+ @entries << Entry.new(
138
+ name: name.to_s, kind: kind, container: container,
139
+ position: position, detail: detail, record: record
140
+ )
141
+ end
142
+
143
+ def parent_detail(klass)
144
+ klass.parent_class.to_s.empty? ? nil : "< #{klass.parent_class}"
145
+ end
146
+
147
+ def attribute_detail(attribute)
148
+ detail = attribute.type.to_s.dup
149
+ cardinality = attribute.cardinality
150
+ if cardinality
151
+ max = cardinality.max.to_s
152
+ detail << " [#{cardinality.min}#{"..#{max}" unless max.empty?}]"
153
+ end
154
+ detail.empty? ? nil : detail
155
+ end
156
+
157
+ def operation_detail(operation)
158
+ params = Array(operation.owned_parameter)
159
+ .map { |param| "#{param.name}: #{param.type}" }.join(', ')
160
+ detail = "(#{params})"
161
+ detail += " : #{operation.return_type}" if operation.return_type.to_s != ''
162
+ detail
163
+ end
164
+
165
+ def enum_member_detail(member)
166
+ description = Array(member.attributes).find { |a| a.name == 'description' }
167
+ description&.type&.to_s
168
+ end
169
+
170
+ def attribute_value_detail(attribute)
171
+ value = attribute.value
172
+ value = value.value if value.is_a?(LiteralValue)
173
+ value.nil? ? attribute.type.to_s : value.to_s
174
+ end
175
+
176
+ def instance_label(instance)
177
+ type = instance.type.to_s
178
+ name = instance.name.to_s
179
+ return name unless name.empty?
180
+ return type unless type.empty?
181
+
182
+ 'instance'
183
+ end
184
+
185
+ def reference?(attribute, token)
186
+ type = attribute.type.to_s
187
+ return true if type == token || type.split('[]').first == token
188
+
189
+ value = attribute.value
190
+ value = value.value if value.is_a?(LiteralValue)
191
+ return false unless value.is_a?(Hash)
192
+
193
+ reference = value[:reference] || value['reference']
194
+ return false unless reference
195
+
196
+ reference.to_s.split('.').include?(token)
197
+ end
198
+ end
199
+ end
200
+ end
@@ -4,11 +4,13 @@ require 'parsanol'
4
4
 
5
5
  module Lutaml
6
6
  module Lml
7
+ # Converts the raw parsanol tree into the processed hash, retaining
8
+ # source positions as non-serialized metadata.
7
9
  class Transform < Parsanol::Transform
8
10
  VISIBILITY_MAP = {
9
11
  '-' => 'private',
10
12
  '#' => 'protected',
11
- '~' => 'package'
13
+ '~' => 'package',
12
14
  }.freeze
13
15
 
14
16
  rule(visibility_modifier: simple(:visibility_value)) do
@@ -50,7 +52,52 @@ module Lutaml
50
52
  BLOCK_KEYS = %i[definition comments].freeze
51
53
 
52
54
  def apply(tree, context = nil)
53
- strip_block_layout(super)
55
+ processed = super
56
+ attach_positions(tree, processed)
57
+ strip_block_layout(processed)
58
+ end
59
+
60
+ # Retains the source position of name/key captures as a :position
61
+ # hash beside the transformed value. The document builder moves it
62
+ # onto the model as non-serialized metadata (see Positioned).
63
+ def attach_positions(raw, processed)
64
+ case raw
65
+ when Hash
66
+ return unless processed.is_a?(Hash)
67
+
68
+ slice = capture_slice(raw[:name]) || capture_slice(raw[:key]) ||
69
+ capture_slice(raw[:value_name]) || capture_slice(raw[:lml_version])
70
+ processed[:position] = position_hash(slice) if slice
71
+
72
+ raw.each_key do |key|
73
+ attach_positions(raw[key], processed[key]) if processed.key?(key)
74
+ end
75
+ when Array
76
+ return unless processed.is_a?(Array)
77
+
78
+ raw.each_with_index do |item, index|
79
+ attach_positions(item, processed[index]) if index < processed.length
80
+ end
81
+ end
82
+ end
83
+
84
+ # A name capture is either a bare token (a Slice) or a quoted
85
+ # string (a hash holding the inner Slice).
86
+ def capture_slice(node)
87
+ return node if node.is_a?(Parsanol::Slice)
88
+ return node[:string] if node.is_a?(Hash) && node[:string].is_a?(Parsanol::Slice)
89
+
90
+ nil
91
+ end
92
+
93
+ def position_hash(slice)
94
+ line, column = slice.line_and_column
95
+ {
96
+ line: line,
97
+ column: column,
98
+ offset: slice.offset,
99
+ length: slice.content.to_s.bytesize,
100
+ }
54
101
  end
55
102
 
56
103
  def strip_block_layout(node)