lutaml-lml 0.2.1 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41662922310b1ec54e30485a3ec783c99f385f5e80eb0e4a98277b72ac5a71dc
4
- data.tar.gz: 2b6030ded9c8bb82941ceefdf5e1df9a8531af5a61888faee4ecbc23df0ca961
3
+ metadata.gz: c44e08a243084f376e006dfa845a878267d5867d1c07e9cb2f957cadfdf593af
4
+ data.tar.gz: 56b169b4f31489f51713071e48002ec2b0babc8f6b510aa6660e7b02976b0f93
5
5
  SHA512:
6
- metadata.gz: 94f5275ce72d247c2842a60b265bdc8980cf82b49ce45d1970b606a0a9329505776235efb2df3247165b40cd5aaccc977b9aadcf77eb4ad044119ec71e4ea0a7
7
- data.tar.gz: bfe8561322f20e76736e00a6c67ab862207e344637097f270a1111c7a5edbc540f8137989858e8fe3584dbaded200fe150c35da83523a637d26523f9cc64f8e6
6
+ metadata.gz: 12590d775d46b14bbefe7733610b6b60c08ddaa169e6aa3e4010b13979e565d36bde270adffc5e7ef6cdb37c9fbcadedb16d66b0bbc1e6404d74c46da5b2c1ec
7
+ data.tar.gz: 1dcf78fc5244594d03992cdb8dd4b2e286c0069741b3985bad2667ea1752eab19534aa16878e9e2c80ac3ab4b0c5c2381756634590a99df1070e72a0e6b7bf83
@@ -30,7 +30,7 @@ module Lutaml
30
30
  'exp' => lambda { |path|
31
31
  require 'lutaml/express'
32
32
  Lutaml::Express::Parsers::Exp.parse(File.new(path))
33
- }
33
+ },
34
34
  }.freeze
35
35
 
36
36
  FORMATTER_ATTR_TARGETS = %i[graph edge node].freeze
@@ -115,36 +115,95 @@ module Lutaml
115
115
  Parse an LML file containing model definitions and compile them
116
116
  into Ruby classes that can be used to instantiate and validate data.
117
117
 
118
- The compiled classes are anonymous Serializable subclasses registered
119
- in the ModelCompiler. With --namespace, classes are also registered
120
- as constants in the given module.
118
+ With --namespace, classes are registered as constants in the given
119
+ module (created if absent).
120
+
121
+ With --schema, emits a JSON Schema or YAML Schema document for every
122
+ compiled model instead of the compile listing. Schema output requires
123
+ named classes, so --namespace defaults to Lutaml::Lml::Generated when omitted.
121
124
 
122
125
  Examples:
123
126
  lutaml lml compile models.lml
124
127
 
125
128
  lutaml lml compile models.lml --namespace MyModels
129
+
130
+ lutaml lml compile models.lml --schema json -o schemas/
131
+
132
+ lutaml lml compile models.lml --schema yaml -o widget.schema.yaml
126
133
  DESC
127
134
  method_option :namespace, type: :string, aliases: '-n',
128
- desc: 'Register compiled classes in a module'
135
+ desc: 'Register compiled classes in a module (created if absent)'
136
+ method_option :schema, type: :string, enum: %w[json yaml],
137
+ desc: 'Emit JSON Schema or YAML Schema for the compiled models'
138
+ method_option :output, type: :string, aliases: '-o',
139
+ desc: 'Schema output path (file for one model, directory for many)'
129
140
  def compile(path)
130
141
  raise Thor::Error, "File does not exist: #{path}" unless File.exist?(path)
131
142
 
132
- ns = options[:namespace] ? resolve_namespace(options[:namespace]) : nil
133
- result = Lutaml::Lml::ModelCompiler.new(namespace: ns).compile(File.new(path))
143
+ ns = options[:namespace] || (::Lutaml::Lml::ModelCompiler::DEFAULT_NAMESPACE if options[:schema])
144
+ result = File.open(path) { |file| Lutaml::Lml::ModelCompiler.new(namespace: ns).compile(file) }
145
+ return emit_schemas(result) if options[:schema]
134
146
 
135
- result.each_key do |name|
136
- say " compiled: #{name}", :green
137
- end
138
- say "\n#{result.size} class(es) compiled", :green
147
+ report_compilation(result)
139
148
  end
140
149
 
141
150
  no_commands do
142
- def resolve_namespace(name)
143
- name.split('::').reduce(Object) do |mod, const_name|
144
- mod.const_get(const_name)
151
+ def report_compilation(result)
152
+ result.each_key do |name|
153
+ say " compiled: #{name}", :green
154
+ end
155
+ say "\n#{result.size} class(es) compiled", :green
156
+ end
157
+
158
+ def emit_schemas(result)
159
+ require 'lutaml/model/schema'
160
+ if options[:output] && Pathname.new(options[:output]).directory?
161
+ generator = "to_#{options[:schema]}"
162
+ schemas = result.transform_values do |klass|
163
+ Lutaml::Model::Schema.public_send(generator, klass, pretty: true)
164
+ end
165
+ write_schemas(schemas)
166
+ else
167
+ # One multi-root document covers every compiled model; each
168
+ # root stays addressable under $defs by class name.
169
+ schema = generate_multi_root_schema(result)
170
+ return puts schema unless options[:output]
171
+
172
+ File.write(options[:output], schema)
173
+ say "Generated: #{options[:output]}", :green
174
+ end
175
+ end
176
+
177
+ def generate_multi_root_schema(result)
178
+ klass = options[:schema] == 'yaml' ? Lutaml::Model::Schema::YamlSchema : Lutaml::Model::Schema::JsonSchema
179
+ klass.generate_many(result.values, pretty: true)
180
+ end
181
+
182
+ def write_schemas(schemas)
183
+ target = Pathname.new(options[:output])
184
+ if schemas.size > 1
185
+ unless target.directory?
186
+ raise Thor::Error,
187
+ 'Output path must be a directory if multiple schemas ' \
188
+ 'are generated'
189
+ end
190
+ write_schema_files(target, schemas)
191
+ else
192
+ target.write(schemas.values.first)
193
+ say "Generated: #{target}", :green
145
194
  end
146
- rescue NameError
147
- raise Thor::Error, "Namespace '#{name}' not found"
195
+ end
196
+
197
+ def write_schema_files(dir, schemas)
198
+ schemas.each do |name, schema|
199
+ file = dir.join("#{schema_file_stem(name)}.#{options[:schema]}")
200
+ file.write(schema)
201
+ say "Generated: #{file}", :green
202
+ end
203
+ end
204
+
205
+ def schema_file_stem(name)
206
+ name.split('::').last.gsub(/[^a-zA-Z0-9]+/, '_').downcase
148
207
  end
149
208
 
150
209
  def assert_input_paths(paths)
@@ -182,7 +241,7 @@ module Lutaml
182
241
  def write_output(input_path, result)
183
242
  target = resolve_output_path(input_path)
184
243
  if target.is_a?(Pathname)
185
- target.open('w+') { |f| f.write(result) }
244
+ target.open('wb+') { |f| f.write(result) }
186
245
  say "Generated: #{target}", :green
187
246
  else
188
247
  target.puts(result)
@@ -4,13 +4,14 @@ module Lutaml
4
4
  module Lml
5
5
  class DataProcessor
6
6
  module ValueProcessing
7
- VALUE_TYPE_KEYS = %i[instance list string boolean key_value_map number float condition require].freeze
7
+ VALUE_TYPE_KEYS = %i[instance list string boolean null key_value_map number float condition require].freeze
8
8
 
9
9
  VALUE_TYPE_HANDLERS = {
10
10
  instance: :handle_instance_value,
11
11
  list: :handle_list_value,
12
12
  string: :handle_string_value,
13
13
  boolean: :handle_boolean_value,
14
+ null: :handle_null_value,
14
15
  key_value_map: :handle_key_value_map,
15
16
  number: :handle_number_value,
16
17
  float: :handle_float_value,
@@ -50,6 +51,10 @@ module Lutaml
50
51
  ['Boolean', value[:boolean] == 'true']
51
52
  end
52
53
 
54
+ def handle_null_value(_key, _value)
55
+ ['Null', nil]
56
+ end
57
+
53
58
  def handle_key_value_map(_key, value)
54
59
  process_key_value_map(value[:key_value_map])
55
60
  end
@@ -12,6 +12,18 @@ module Lutaml
12
12
  def dup_instance
13
13
  self.class.new
14
14
  end
15
+
16
+ # RS 3001 §String values: a present value — including a
17
+ # zero-length string — is distinct from an absent one, so the
18
+ # lml format emits empty values. Nil stays omitted: absence is
19
+ # the unassigned line, not a rendered null.
20
+ def map(name = nil, to: nil, render_empty: true, **options, &block)
21
+ super(name, to: to, render_empty: render_empty, **options, &block)
22
+ end
23
+ # The parent's `alias map_element map` binds the alias to the
24
+ # parent method at definition time, so it bypasses the map
25
+ # override above; default_mappings goes through map_element.
26
+ alias map_element map
15
27
  end
16
28
  end
17
29
  end
@@ -4,9 +4,21 @@ module Lutaml
4
4
  module Lml
5
5
  module Format
6
6
  module Adapter
7
+ # Serializes instance data to and from LML instance syntax on
8
+ # behalf of the lutaml-model format registry (to_lml/from_lml on
9
+ # compiled classes).
7
10
  class StandardAdapter < Document
8
11
  TYPE_KEY = "__type__"
9
12
 
13
+ # The grammar's `word` rule is [A-Za-z0-9_] only (lml.parg) and
14
+ # `variable = quoted_string / 1*word` — a bare value is a run of
15
+ # word characters and nothing else. Anything else (a dash, a
16
+ # space, a digit-leading date...) commits the value parser to
17
+ # `number` or fails re-parsing, so it must be emitted quoted.
18
+ # This predicate is the single source of truth for the emit
19
+ # side; the parse side is pinned by the grammar itself.
20
+ BARE_WORD = /\A\w+\z/
21
+
10
22
  def self.parse(data, _options = {})
11
23
  return data if data.is_a?(Hash)
12
24
 
@@ -22,8 +34,6 @@ module Lutaml
22
34
  "instance #{type_name} {\n#{body}\n}"
23
35
  end
24
36
 
25
- private
26
-
27
37
  def self.instance_to_hash(instance)
28
38
  return nil unless instance
29
39
 
@@ -41,20 +51,18 @@ module Lutaml
41
51
  end
42
52
  end
43
53
 
44
- if instance.instance
45
- hash.merge!(instance_to_hash(instance.instance))
46
- end
54
+ hash.merge!(instance_to_hash(instance.instance)) if instance.instance
47
55
 
48
56
  hash
49
57
  end
50
58
 
51
59
  def self.primitive_value(val)
52
60
  case val
53
- when TrueClass, FalseClass then val
54
- when Integer, Float then val
61
+ when TrueClass, FalseClass, Integer, Float then val
55
62
  else val.to_s
56
63
  end
57
64
  end
65
+ private_class_method :primitive_value
58
66
 
59
67
  def hash_to_lml_body(hash, indent = 1)
60
68
  lines = []
@@ -126,8 +134,16 @@ module Lutaml
126
134
 
127
135
  def quote_value(val)
128
136
  return val.to_s if val.is_a?(Numeric) || val.is_a?(TrueClass) || val.is_a?(FalseClass)
137
+
129
138
  str = val.to_s
130
- str.match?(/^[\w-]+$/) ? str : "\"#{str}\""
139
+ return str if str.match?(BARE_WORD)
140
+
141
+ # RS 3001 §String values: escape backslash and double quote;
142
+ # every value is representable in double quotes.
143
+ # gsub replacement strings re-interpret backslash pairs, so
144
+ # the backslash doubling uses the block form.
145
+ escaped = str.gsub('\\') { |bs| bs + bs }.gsub('"') { |q| '\\' + q }
146
+ "\"#{escaped}\""
131
147
  end
132
148
  end
133
149
  end
@@ -4,9 +4,10 @@ module Lutaml
4
4
  module Formatter
5
5
  # ELK-layout diagram formatter: builds an Elkrb::Graph from the
6
6
  # document, lays it out in-process (no external tools) and emits SVG,
7
- # or converts the SVG to PS/EPS/EMF through vectory.
7
+ # PS/EPS/EMF through vectory, or PDF through the pdfrb-backed
8
+ # SvgPdf renderer.
8
9
  class Elk
9
- VALID_TYPES = %i[svg ps eps emf].freeze
10
+ VALID_TYPES = %i[svg ps eps emf pdf].freeze
10
11
 
11
12
  attr_reader :type
12
13
 
@@ -31,6 +32,8 @@ module Lutaml
31
32
  private
32
33
 
33
34
  def convert(svg)
35
+ return SvgPdf.new(svg).render if @type == :pdf
36
+
34
37
  require 'vectory'
35
38
  Vectory::Svg.new(svg).public_send(:"to_#{@type}").content
36
39
  rescue LoadError
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Formatter
5
+ class SvgPdf
6
+ # Minimal SVG path data parser: absolute and relative M/m, L/l,
7
+ # H/h, V/v, C/c, Z/z — enough for ELK geometry and arrow shapes.
8
+ class PathParser
9
+ Point = Struct.new(:x, :y)
10
+ Line = Struct.new(:point) do
11
+ def last_point
12
+ point
13
+ end
14
+ end
15
+ Curve = Struct.new(:c1, :c2, :point) do
16
+ def last_point
17
+ point
18
+ end
19
+ end
20
+ Close = Struct.new(:last_point)
21
+
22
+ attr_reader :subpaths
23
+
24
+ def initialize(d, dx, dy)
25
+ @dx = dx
26
+ @dy = dy
27
+ parse(d)
28
+ end
29
+
30
+ private
31
+
32
+ def parse(d)
33
+ tokens = d.scan(/[MmLlHhVvCcZz]|-?\d*\.?\d+(?:[eE][-+]?\d+)?/)
34
+ current = Point.new(0.0, 0.0)
35
+ start = current
36
+ @subpaths = [[]]
37
+ i = 0
38
+ while i < tokens.size
39
+ cmd = tokens[i]
40
+ i += 1
41
+ case cmd
42
+ when 'Z', 'z'
43
+ @subpaths.last << Close.new(start)
44
+ current = start.dup
45
+ when 'M', 'm'
46
+ current, start = move_to(tokens, i, current, cmd == 'm')
47
+ i += 2
48
+ @subpaths << [] if cmd == 'm'
49
+ when 'L', 'l'
50
+ current = line_to(tokens, i, current, cmd == 'l')
51
+ i += 2
52
+ when 'H', 'h'
53
+ current = h_line(tokens, i, current, cmd == 'h')
54
+ i += 1
55
+ when 'V', 'v'
56
+ current = v_line(tokens, i, current, cmd == 'v')
57
+ i += 1
58
+ when 'C', 'c'
59
+ current = curve_to(tokens, i, current, cmd == 'c')
60
+ i += 6
61
+ end
62
+ end
63
+ finish
64
+ end
65
+
66
+ def move_to(tokens, i, current, relative)
67
+ x = num(tokens, i)
68
+ y = num(tokens, i + 1)
69
+ point = relative ? Point.new(current.x + x, current.y + y) : Point.new(x, y)
70
+ [point, point.dup]
71
+ end
72
+
73
+ def line_to(tokens, i, current, relative)
74
+ x = num(tokens, i)
75
+ y = num(tokens, i + 1)
76
+ point = relative ? Point.new(current.x + x, current.y + y) : Point.new(x, y)
77
+ @subpaths.last << Line.new(point.dup)
78
+ point
79
+ end
80
+
81
+ def h_line(tokens, i, current, relative)
82
+ x = num(tokens, i) + (relative ? current.x : 0)
83
+ point = Point.new(x, current.y)
84
+ @subpaths.last << Line.new(point.dup)
85
+ point
86
+ end
87
+
88
+ def v_line(tokens, i, current, relative)
89
+ y = num(tokens, i) + (relative ? current.y : 0)
90
+ point = Point.new(current.x, y)
91
+ @subpaths.last << Line.new(point.dup)
92
+ point
93
+ end
94
+
95
+ def curve_to(tokens, i, current, relative)
96
+ c1 = Point.new(num(tokens, i), num(tokens, i + 1))
97
+ c2 = Point.new(num(tokens, i + 2), num(tokens, i + 3))
98
+ to = Point.new(num(tokens, i + 4), num(tokens, i + 5))
99
+ if relative
100
+ c1 = Point.new(c1.x + current.x, c1.y + current.y)
101
+ c2 = Point.new(c2.x + current.x, c2.y + current.y)
102
+ to = Point.new(to.x + current.x, to.y + current.y)
103
+ end
104
+ @subpaths.last << Curve.new(c1, c2, to)
105
+ to
106
+ end
107
+
108
+ def num(tokens, i)
109
+ tokens[i].to_f
110
+ end
111
+
112
+ def finish
113
+ @subpaths.map { |segments| segments.map { |s| offset(s) } }
114
+ .reject(&:empty?)
115
+ end
116
+
117
+ def offset(segment)
118
+ case segment
119
+ when Line
120
+ Line.new(shift(segment.point))
121
+ when Curve
122
+ Curve.new(shift(segment.c1), shift(segment.c2), shift(segment.point))
123
+ else
124
+ segment
125
+ end
126
+ end
127
+
128
+ def shift(point)
129
+ Point.new(point.x + @dx, point.y + @dy)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end