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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lutaml
6
+ module Lml
7
+ module Lsp
8
+ # LSP base protocol framing: `Content-Length: N\r\n\r\n<json>`.
9
+ # Reads one message at a time and writes responses to the output
10
+ # stream. stdout is the protocol channel; stderr is for logging.
11
+ class Transport
12
+ def initialize(input: $stdin, output: $stdout)
13
+ @input = input
14
+ @output = output
15
+ end
16
+
17
+ def read_message
18
+ headers = read_headers
19
+ return nil unless headers
20
+
21
+ length = headers["content-length"]
22
+ return nil unless length
23
+
24
+ body = @input.read(length.to_i)
25
+ body.nil? || body.empty? ? nil : JSON.parse(body)
26
+ end
27
+
28
+ def write_message(payload)
29
+ body = JSON.generate(payload)
30
+ @output.write("Content-Length: #{body.bytesize}\r\n\r\n#{body}")
31
+ @output.flush
32
+ end
33
+
34
+ private
35
+
36
+ def read_headers
37
+ headers = {}
38
+ loop do
39
+ line = @input.gets
40
+ return nil if line.nil?
41
+
42
+ line = line.chomp
43
+ break if line.empty?
44
+
45
+ key, value = line.split(":", 2)
46
+ headers[key.to_s.strip.downcase] = value.to_s.strip
47
+ end
48
+ headers
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ # Language Server Protocol support: a stdio JSON-RPC server exposing
6
+ # diagnostics, symbols, navigation, hover, completion, and canonical
7
+ # formatting over the parsed document model.
8
+ module Lsp
9
+ autoload :Transport, "lutaml/lml/lsp/transport"
10
+ autoload :Server, "lutaml/lml/lsp/server"
11
+ autoload :Store, "lutaml/lml/lsp/store"
12
+ autoload :Keywords, "lutaml/lml/lsp/keywords"
13
+ autoload :Conversions, "lutaml/lml/lsp/conversions"
14
+ end
15
+ end
16
+ end
@@ -73,6 +73,17 @@ module Lutaml
73
73
  return {} unless doc.instance
74
74
 
75
75
  hydrate_instance(doc.instance)
76
+ rescue Lutaml::Lml::Error
77
+ raise
78
+ rescue StandardError => e
79
+ raise HydrationError.new(
80
+ "hydration failed: #{e.message}",
81
+ diagnostic: Diagnostic.build(
82
+ 'hydration/LML-H01',
83
+ message: e.message,
84
+ file: input.is_a?(Document) ? input.source_path : nil,
85
+ ),
86
+ )
76
87
  end
77
88
 
78
89
  def compiled_classes
@@ -236,7 +247,11 @@ module Lutaml
236
247
  return Float::INFINITY if UNBOUNDED_TOKENS.include?(val.to_s)
237
248
  return val.to_i if val.to_s.match?(/\A\d+\z/)
238
249
 
239
- raise ValidationError, "Unrecognized cardinality token: #{val.inspect}"
250
+ message = "Unrecognized cardinality token: #{val.inspect}"
251
+ raise ValidationError.new(
252
+ message,
253
+ diagnostic: Diagnostic.build('contract/LML-C04', message: message),
254
+ )
240
255
  end
241
256
 
242
257
  def declare_derived_fields(compiled_klass, def_obj)
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A relationship between two class ends.
5
6
  class Association < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  # From TopElement
7
10
  attribute :name, :string
8
11
  attribute :definition, Lutaml::Lml::Types::TextType
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A named constraint on a class.
5
6
  class Constraint < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  # From TopElement
7
10
  attribute :name, :string
8
11
  attribute :definition, Lutaml::Lml::Types::TextType
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A user-defined data type declaration.
5
6
  class DataType < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  # From TopElement
7
10
  attribute :name, :string
8
11
  attribute :wire_mappings, "Lutaml::Lml::Mapping", collection: true, default: -> { [] }
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A diagram declaration.
5
6
  class Diagram < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  attribute :name, :string
7
10
  attribute :definition, Lutaml::Lml::Types::TextType
8
11
  attribute :keyword, :string
@@ -2,9 +2,18 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # The parsed LML document model.
5
6
  class Document < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
9
+ # Non-serialized navigation index (definitions and uses).
10
+ def symbols
11
+ @symbols ||= SymbolIndex.new(self)
12
+ end
13
+
6
14
  # Core attributes
7
15
  attribute :name, :string
16
+ attribute :lml_version, :string
8
17
  attribute :title, :string
9
18
  attribute :caption, :string
10
19
  attribute :groups, "Lutaml::Lml::Group", collection: true, default: -> { [] }
@@ -47,9 +56,7 @@ module Lutaml
47
56
  def merge_instances(incoming)
48
57
  names = @instances.collections.map(&:name)
49
58
  incoming.collections.each do |collection|
50
- if names.include?(collection.name)
51
- raise Lutaml::Lml::Error, "duplicate collection name '#{collection.name}'"
52
- end
59
+ raise Lutaml::Lml::Error, "duplicate collection name '#{collection.name}'" if names.include?(collection.name)
53
60
  end
54
61
  @instances.instances.concat(incoming.instances)
55
62
  @instances.imports.concat(incoming.imports)
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # An enumeration declaration with its value members.
5
6
  class Enum < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  # From TopElement
7
10
  attribute :name, :string
8
11
  attribute :definition, Lutaml::Lml::Types::TextType
@@ -15,7 +18,7 @@ module Lutaml
15
18
 
16
19
  # From Enum
17
20
  attribute :attributes, "Lutaml::Lml::TopElementAttribute", collection: true,
18
- default: -> { [] }
21
+ default: -> { [] }
19
22
  attribute :modifier, :string
20
23
  attribute :member_fields, "Lutaml::Lml::MemberField", collection: true, default: -> { [] }
21
24
  attribute :from_table_name, :string
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # An instance with attribute assignments.
5
6
  class Instance < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  attribute :name, :string
7
10
  attribute :type, :string
8
11
  attribute :isa, :string
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A named instances collection with its instances and imports/exports.
5
6
  class InstanceCollection < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  attribute :name, :string
7
10
  attribute :instances, "Lutaml::Lml::Instance", collection: true, default: []
8
11
  attribute :imports, "Lutaml::Lml::InstancesImport", collection: true, default: []
@@ -5,6 +5,8 @@ module Lutaml
5
5
  # A typed payload field declared on an enum:
6
6
  # member_fields { abbreviation String urn_segment String }
7
7
  class MemberField < Lutaml::Model::Serializable
8
+ include Lutaml::Lml::Positioned
9
+
8
10
  attribute :name, :string
9
11
  attribute :type, :string
10
12
  end
@@ -6,6 +6,8 @@ module Lutaml
6
6
  # The compiler generates the corresponding Lutaml::Model::XmlNamespace
7
7
  # subclass for the compiled model namespace.
8
8
  class Namespace < Lutaml::Model::Serializable
9
+ include Lutaml::Lml::Positioned
10
+
9
11
  attribute :name, :string
10
12
  attribute :uri, :string
11
13
  attribute :prefix, :string
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A class operation with parameters and a return type.
5
6
  class Operation < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  # From TopElement
7
10
  attribute :name, :string
8
11
  attribute :definition, Lutaml::Lml::Types::TextType
@@ -17,7 +20,7 @@ module Lutaml
17
20
  attribute :is_static, :boolean, default: false
18
21
  attribute :is_abstract, :boolean, default: false
19
22
  attribute :owned_parameter, "Lutaml::Lml::OperationParameter", collection: true,
20
- default: -> { [] }
23
+ default: -> { [] }
21
24
  end
22
25
  end
23
26
  end
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A package grouping model definitions.
5
6
  class Package < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  # From TopElement
7
10
  attribute :name, :string
8
11
  attribute :definition, Lutaml::Lml::Types::TextType
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A primitive type declaration.
5
6
  class PrimitiveType < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  # Primitives in LML are name-only entities, but the formatter
7
10
  # accesses the same attributes as DataType via format_class.
8
11
  attribute :name, :string
@@ -12,11 +15,11 @@ module Lutaml
12
15
  attribute :visibility, :string, default: "public"
13
16
  attribute :is_abstract, :boolean, default: false
14
17
  attribute :attributes, "Lutaml::Lml::TopElementAttribute", collection: true,
15
- default: -> { [] }
18
+ default: -> { [] }
16
19
  attribute :operations, "Lutaml::Lml::Operation", collection: true,
17
- default: -> { [] }
20
+ default: -> { [] }
18
21
  attribute :associations, "Lutaml::Lml::Association", collection: true,
19
- default: -> { [] }
22
+ default: -> { [] }
20
23
 
21
24
  def self.entity_type
22
25
  :primitives
@@ -6,6 +6,8 @@ module Lutaml
6
6
  # its class and linked by target name —
7
7
  # mapping xml Ceramic { element "ceramic" … }
8
8
  class SerializationMapping < Lutaml::Model::Serializable
9
+ include Lutaml::Lml::Positioned
10
+
9
11
  attribute :format, :string
10
12
  attribute :target, :string
11
13
  attribute :element_name, :string
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # An attribute declaration or instance assignment entry.
5
6
  class TopElementAttribute < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  # Core attributes
7
10
  attribute :name, :string
8
11
  attribute :visibility, :string, default: "public"
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A class declaration with its members.
5
6
  class UmlClass < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  # From TopElement. TextType unescapes \{ \} and normalizes lines
7
10
  # on every assignment (builder, yaml, direct).
8
11
  attribute :name, :string
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # A value construct member.
5
6
  class Value < Lutaml::Model::Serializable
7
+ include Lutaml::Lml::Positioned
8
+
6
9
  attribute :name, :string
7
10
  attribute :id, :string
8
11
  attribute :type, :string
@@ -29,7 +29,18 @@ module Lutaml
29
29
  def parse_raw(data)
30
30
  Transform.new.apply(Parser.new.parse(data))
31
31
  rescue Parsanol::ParseFailed => e
32
- raise(ParsingError, failure_message(e))
32
+ raise ParsingError.new(failure_message(e), diagnostic: parse_diagnostic(e))
33
+ end
34
+
35
+ def parse_diagnostic(error)
36
+ line, column = failure_location(error)
37
+ Diagnostic.build(
38
+ 'parse/LML-001',
39
+ message: error.message.lines.first.to_s.strip,
40
+ file: @input.path,
41
+ line: line,
42
+ column: column,
43
+ )
33
44
  end
34
45
 
35
46
  # Parsanol releases up to 1.3.57 annotate native failures with a
@@ -38,18 +49,24 @@ module Lutaml
38
49
  # rebuild the location when the marker is present.
39
50
  def failure_message(error)
40
51
  detail = error.message.lines.first.to_s.strip
52
+ line, column = failure_location(error)
53
+ location = (" at line #{line} char #{column}." if line && !detail.match?(/at line \d+ char \d+/))
54
+ "#{detail}#{location}\ncause: #{error.parse_failure_cause&.ascii_tree}"
55
+ end
56
+
57
+ def failure_location(error)
41
58
  cause = error.parse_failure_cause
42
59
  offset = error.message[/@@parsanol_pos:(\d+)/, 1]&.to_i
43
- location =
44
- if offset && cause&.source
45
- line, column = cause.source.line_and_column(offset)
46
- " at line #{line} char #{column}."
47
- end
48
- "#{detail}#{location}\ncause: #{cause&.ascii_tree}"
60
+ return cause.source.line_and_column(offset) if offset && cause&.source
61
+
62
+ match = error.message.match(/at line (\d+) char (\d+)/)
63
+ match ? [match[1].to_i, match[2].to_i] : [nil, nil]
49
64
  end
50
65
 
51
66
  def build_document(hash)
52
- DocumentBuilder.new(DocumentBuilder::DEFAULT_REGISTRY).build(:document, hash)
67
+ document = DocumentBuilder.new(DocumentBuilder::DEFAULT_REGISTRY).build(:document, hash)
68
+ document.source_path = @input.path
69
+ document
53
70
  end
54
71
  end
55
72
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ # Non-serialized source metadata carried by parsed entities. These
6
+ # accessors are deliberately not lutaml-model attributes: positions
7
+ # and the source path are diagnostics metadata, not document data,
8
+ # and must not appear in to_hash/YAML/JSON output.
9
+ module Positioned
10
+ attr_accessor :source_position, :source_path
11
+ end
12
+ end
13
+ end
@@ -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