expressir 2.4.29-x86_64-linux → 2.4.35-x86_64-linux

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/scripts/test_installed_gem.rb +18 -4
  3. data/.github/workflows/native-gems.yml +4 -1
  4. data/TODO.eengine/00-overview.md +39 -0
  5. data/TODO.eengine/01-resolution-diagnostics.md +30 -0
  6. data/TODO.eengine/02-check-catalog-completion.md +53 -0
  7. data/TODO.eengine/03-entity-computations.md +22 -0
  8. data/TODO.eengine/04-shtolo-rule-audit.md +32 -0
  9. data/TODO.eengine/05-shtolo-longform-flatten.md +12 -0
  10. data/TODO.eengine/06-population-p21.md +34 -0
  11. data/TODO.eengine/07-schema-compare-patch.md +25 -0
  12. data/TODO.eengine/08-official-corpus-gate.md +7 -0
  13. data/TODO.eengine/09-mim-mapping-parser.md +9 -0
  14. data/TODO.parity-ee/00-overview.md +1 -1
  15. data/TODO.suma-improvements/07-part28-xml.md +10 -13
  16. data/expressir.gemspec +5 -5
  17. data/lib/expressir/3.2/expressir_core.so +0 -0
  18. data/lib/expressir/3.3/expressir_core.so +0 -0
  19. data/lib/expressir/3.4/expressir_core.so +0 -0
  20. data/lib/expressir/4.0/expressir_core.so +0 -0
  21. data/lib/expressir/cli.rb +7 -0
  22. data/lib/expressir/commands/parity_inputs.rb +6 -1
  23. data/lib/expressir/commands/smrl_xml_index.rb +25 -0
  24. data/lib/expressir/commands.rb +2 -1
  25. data/lib/expressir/express/builder.rb +17 -5
  26. data/lib/expressir/express/builders/entity_decl_builder.rb +17 -5
  27. data/lib/expressir/express/checker.rb +191 -3
  28. data/lib/expressir/express/core.rb +1 -0
  29. data/lib/expressir/express/formatters/declarations_formatter.rb +21 -14
  30. data/lib/expressir/express/grammar/parser.rb +29 -1
  31. data/lib/expressir/express/pretty_formatter.rb +9 -14
  32. data/lib/expressir/express/schema_compare.rb +194 -0
  33. data/lib/expressir/express/shtolo.rb +194 -63
  34. data/lib/expressir/express.rb +2 -0
  35. data/lib/expressir/model/declarations/entity.rb +132 -0
  36. data/lib/expressir/model/declarations/schema.rb +37 -6
  37. data/lib/expressir/p21/builder.rb +92 -0
  38. data/lib/expressir/p21/grammar.rb +107 -0
  39. data/lib/expressir/p21/parser.rb +16 -0
  40. data/lib/expressir/p21/population.rb +35 -0
  41. data/lib/expressir/p21/validator.rb +213 -0
  42. data/lib/expressir/p21.rb +17 -0
  43. data/lib/expressir/version.rb +1 -1
  44. data/lib/expressir.rb +1 -0
  45. metadata +37 -19
  46. /data/lib/expressir/commands/{version.rb → version_command.rb} +0 -0
@@ -82,6 +82,13 @@ module Expressir
82
82
  blength insert remove
83
83
  ].freeze
84
84
 
85
+ USE_ITEM_KINDS = [Model::Declarations::Entity, Model::Declarations::Type].freeze
86
+ REFERENCE_ITEM_KINDS = [
87
+ Model::Declarations::Constant, Model::Declarations::Entity,
88
+ Model::Declarations::Function, Model::Declarations::Procedure,
89
+ Model::Declarations::Type
90
+ ].freeze
91
+
85
92
  private
86
93
 
87
94
  def note!(id, severity, schema, message, node = nil)
@@ -92,6 +99,7 @@ module Expressir
92
99
  def check_schema(schema)
93
100
  check_duplicates(schema)
94
101
  check_interfaces(schema)
102
+ check_type_resolutions(schema)
95
103
  check_self_schema_reference(schema)
96
104
  schema.entities.each { |e| check_entity(schema, e) }
97
105
  schema.types.each do |t|
@@ -100,9 +108,11 @@ module Expressir
100
108
  check_where_rules(schema, t, t.where_rules)
101
109
  end
102
110
  schema.rules.each { |r| check_where_rules(schema, r, r.where_rules) }
111
+ check_subtype_constraints(schema)
103
112
  schema.entities.each do |entity|
104
113
  check_aggregate_bounds(schema, entity)
105
114
  check_attrib_name_fun(schema, entity)
115
+ check_attribute_redeclarations(schema, entity)
106
116
  end
107
117
  check_string_references(schema)
108
118
  check_unresolved_refs(schema)
@@ -184,14 +194,44 @@ module Expressir
184
194
  end
185
195
 
186
196
  resource_names(iface).each do |name|
187
- next if foreign_decl?(foreign, name)
197
+ decl = foreign_declaration(foreign, name)
198
+ unless decl
199
+ note!(:check_unresolved_ref, :error, schema,
200
+ "interface resource '#{name}' not in schema '#{target}'", iface)
201
+ next
202
+ end
188
203
 
189
- note!(:check_unresolved_ref, :error, schema,
190
- "interface resource '#{name}' not in schema '#{target}'", iface)
204
+ # eeng resolve-interface: use-from resolves via
205
+ # find-type/entity, reference-from via the 5-kind or-chain;
206
+ # anything else is "Resource '~A' not found".
207
+ next if item_kind_interfacable?(iface.kind, decl)
208
+
209
+ note!(:check_interface_kind_restriction, :error, schema,
210
+ "interface resource '#{name}' is a #{decl.class.name.split('::').last}: " + "#{iface.kind} resolves #{allowed_kinds(iface.kind)} only", iface)
191
211
  end
192
212
  end
193
213
  end
194
214
 
215
+ def item_kind_interfacable?(kind, decl)
216
+ (kind == Model::Declarations::Interface::REFERENCE ? REFERENCE_ITEM_KINDS : USE_ITEM_KINDS)
217
+ .any? { |k| decl.is_a?(k) }
218
+ end
219
+
220
+ def allowed_kinds(kind)
221
+ (kind == Model::Declarations::Interface::REFERENCE ? REFERENCE_ITEM_KINDS : USE_ITEM_KINDS)
222
+ .map { |k| k.name.split("::").last }.join(", ")
223
+ end
224
+
225
+ def foreign_declaration(schema, name)
226
+ key = name.safe_downcase
227
+ DECL_COLLECTIONS.each do |coll|
228
+ Array(schema.public_send(coll)).each do |d|
229
+ return d if d.id&.safe_downcase == key
230
+ end
231
+ end
232
+ nil
233
+ end
234
+
195
235
  def iface_schema_name(iface)
196
236
  iface.schema.is_a?(String) ? iface.schema : iface.schema&.id
197
237
  end
@@ -210,6 +250,85 @@ module Expressir
210
250
  end
211
251
  end
212
252
 
253
+ # eeng check-p11 context checks (kernel/check.lisp:160-201,
254
+ # 299-336, 337-395): constant/local/parameter/return types must
255
+ # resolve to a TYPE or ENTITY visible in the schema (find-type/
256
+ # entity semantics - constants, functions, procedures do NOT
257
+ # satisfy them). Note ids are the eeng ones.
258
+ def check_type_resolutions(schema)
259
+ schema.constants.each do |constant|
260
+ check_type_ref(schema, Array(constant.type), :check_constant_type, constant.id, constant)
261
+ end
262
+
263
+ schema.functions.each do |function|
264
+ Array(function.parameters).each do |param|
265
+ check_type_ref(schema, Array(param.type), :check_parameter_type, param.id, param)
266
+ end
267
+ check_type_ref(schema, Array(function.return_type),
268
+ :check_function_return_type, function.id, function)
269
+ Array(function.variables).each do |variable|
270
+ check_type_ref(schema, Array(variable.type), :check_local_type, variable.id, variable)
271
+ end
272
+ end
273
+
274
+ schema.procedures.each do |procedure|
275
+ Array(procedure.parameters).each do |param|
276
+ check_type_ref(schema, Array(param.type), :check_parameter_type, param.id, param)
277
+ end
278
+ Array(procedure.variables).each do |variable|
279
+ check_type_ref(schema, Array(variable.type), :check_local_type, variable.id, variable)
280
+ end
281
+ end
282
+
283
+ schema.rules.each do |rule|
284
+ Array(rule.applies_to).each do |entity_ref|
285
+ # parameter-rule: the FOR entities resolve via
286
+ # find-type/entity too (kernel/check.lisp:311-322)
287
+ check_entity_ref(schema, Array(entity_ref), :check_parameter_type, rule.id, rule)
288
+ end
289
+ Array(rule.variables).each do |variable|
290
+ check_type_ref(schema, Array(variable.type), :check_local_type, variable.id, variable)
291
+ end
292
+ end
293
+ end
294
+
295
+ def check_type_ref(schema, nodes, note_id, name, node)
296
+ nodes.each do |type|
297
+ next unless type.is_a?(Model::References::SimpleReference)
298
+ next if type.id.nil? || builtin?(type.id)
299
+ next if resolves_to_type_or_entity?(schema, type.id)
300
+
301
+ note!(note_id, :error, schema,
302
+ "type '#{type.id}' of '#{name}' does not resolve to a type or entity", node || type)
303
+ end
304
+ end
305
+
306
+ def check_entity_ref(schema, nodes, note_id, name, node)
307
+ nodes.each do |ref|
308
+ next unless ref.is_a?(Model::References::SimpleReference)
309
+ next if ref.id.nil?
310
+ next if resolves_to_type_or_entity?(schema, ref.id)
311
+
312
+ note!(note_id, :error, schema,
313
+ "entity '#{ref.id}' of '#{name}' does not resolve to a type or entity", node || ref)
314
+ end
315
+ end
316
+
317
+ def resolves_to_type_or_entity?(schema, name)
318
+ key = name.safe_downcase
319
+ schema.children.any? do |child|
320
+ next false unless child.id&.safe_downcase == key
321
+
322
+ child.is_a?(Model::Declarations::Type) || child.is_a?(Model::Declarations::Entity) ||
323
+ (child.is_a?(Model::Declarations::InterfacedItem) && interface_item_type_or_entity?(child))
324
+ end
325
+ end
326
+
327
+ def interface_item_type_or_entity?(item)
328
+ base = item.base_item
329
+ base.is_a?(Model::Declarations::Type) || base.is_a?(Model::Declarations::Entity)
330
+ end
331
+
213
332
  def check_entity(schema, entity)
214
333
  Array(entity.subtype_of).each do |ref|
215
334
  id = ref.is_a?(String) ? ref : ref.id
@@ -282,6 +401,75 @@ module Expressir
282
401
 
283
402
  # eeng check-supertype-ref: every entity named in the SUPERTYPE
284
403
  # expression must resolve to an entity of this schema.
404
+ # eeng check-p11 subtype_constraint (kernel/check.lisp:426) only
405
+ # checks the supertype expression; the note id matches the entity
406
+ # supertype path (kernel/check.lisp:523).
407
+ def explicit_attributes(entity)
408
+ Array(entity.attributes)
409
+ .select { |attr| attr.instance_of?(Model::Declarations::Attribute) }
410
+ end
411
+
412
+ def check_subtype_constraints(schema)
413
+ schema.subtype_constraints.each do |constraint|
414
+ expr = constraint.supertype_expression
415
+ next unless expr
416
+
417
+ each_reference(expr) do |ref|
418
+ id = ref.id
419
+ next if id.nil?
420
+ # Names outside this schema's scope (foreign entities of a
421
+ # single-file parse) cannot be verified here.
422
+ next unless resolves_to_type_or_entity?(schema, id)
423
+ next if find_entity(schema, id)
424
+
425
+ note!(:check_supertype_ref, :error, schema,
426
+ "SUBTYPE_CONSTRAINT #{constraint.id}: supertype " \
427
+ "'#{id}' is not an entity", constraint)
428
+ end
429
+ end
430
+ end
431
+
432
+ # A `SELF\<supertype>.<attr>` redeclaration must name a supertype
433
+ # in the inheritance closure carrying that attribute (ISO
434
+ # 10303-11:2004, 9.2.2.3). eeng kernel/check.lisp:255 leaves this
435
+ # as a TODO comment, so this check has no eeng note id.
436
+ def check_attribute_redeclarations(schema, entity)
437
+ explicit_attributes(entity).each do |attr|
438
+ tag = attr.supertype_attribute
439
+ next unless tag.is_a?(Model::References::AttributeReference)
440
+
441
+ sup_id = tag.ref&.entity&.id
442
+ attr_id = tag.attribute&.id
443
+ if sup_id.nil?
444
+ note!(:check_attribute_redeclaration, :error, schema,
445
+ "ENTITY #{entity.id}: redeclaration of '#{attr.id}' " \
446
+ "names no supertype", attr)
447
+ next
448
+ end
449
+
450
+ supertype = entity.supertype_closure
451
+ .find { |st| st.id&.safe_downcase == sup_id.safe_downcase }
452
+ if supertype.nil?
453
+ # The named supertype may be foreign (USE FROM) and thus
454
+ # outside the closure of a single-file parse; only flag it
455
+ # when it resolves in this scope yet is not a supertype.
456
+ if find_entity(schema, sup_id)
457
+ note!(:check_attribute_redeclaration, :error, schema,
458
+ "ENTITY #{entity.id}: redeclaration '#{attr.id}' names " \
459
+ "'#{sup_id}', which is not a supertype", attr)
460
+ end
461
+ next
462
+ end
463
+
464
+ next if explicit_attributes(supertype)
465
+ .any? { |candidate| candidate.id&.safe_downcase == attr_id&.safe_downcase }
466
+
467
+ note!(:check_attribute_redeclaration, :error, schema,
468
+ "ENTITY #{entity.id}: redeclaration '#{attr.id}': supertype " \
469
+ "'#{sup_id}' has no attribute '#{attr_id}'", attr)
470
+ end
471
+ end
472
+
285
473
  def check_supertype_refs(schema, entity)
286
474
  expr = entity.supertype_expression
287
475
  return unless expr
@@ -7,6 +7,7 @@ module Expressir
7
7
  # Ruby bridge to the expressir-core Rust crate (parse + model
8
8
  # extraction). Available when the native extension is built;
9
9
  # callers fall back to the pure-Ruby parser otherwise.
10
+ #
10
11
  module Core
11
12
  NATIVE_AVAILABLE = begin
12
13
  require "expressir/expressir_core"
@@ -110,20 +110,15 @@ module Expressir
110
110
  end
111
111
 
112
112
  def format_declarations_entity(node)
113
- if node.attributes.nil?
114
- explicit_attributes = []
115
- derived_attributes = []
116
- inverse_attributes = []
117
- else
118
- explicit_attributes = node.attributes.select do |x|
119
- x.kind == Model::Declarations::Attribute::EXPLICIT
120
- end
121
- derived_attributes = node.attributes.select do |x|
122
- x.kind == Model::Declarations::Attribute::DERIVED
123
- end
124
- inverse_attributes = node.attributes.select do |x|
125
- x.kind == Model::Declarations::Attribute::INVERSE
126
- end
113
+ attrs = entity_attribute_items(node)
114
+ explicit_attributes = attrs.select do |x|
115
+ x.kind == Model::Declarations::Attribute::EXPLICIT
116
+ end
117
+ derived_attributes = attrs.select do |x|
118
+ x.kind == Model::Declarations::Attribute::DERIVED
119
+ end
120
+ inverse_attributes = attrs.select do |x|
121
+ x.kind == Model::Declarations::Attribute::INVERSE
127
122
  end
128
123
 
129
124
  indent_const = self.class.const_get(:INDENT)
@@ -231,6 +226,18 @@ module Expressir
231
226
  ].join("\n")
232
227
  end
233
228
 
229
+ # Entity#attributes is the Ruby-parse layout (every attribute kind
230
+ # in one list). The core path parks derived/inverse in their own
231
+ # collections instead. Both may also arrive as Array-of-Array when
232
+ # a builder nested list productions. Normalize to a flat list of
233
+ # Attribute-like nodes before selecting on `kind`.
234
+ def entity_attribute_items(node)
235
+ items = Array(node.attributes)
236
+ items += Array(node.derived_attributes) if node.respond_to?(:derived_attributes)
237
+ items += Array(node.inverse_attributes) if node.respond_to?(:inverse_attributes)
238
+ items.flatten.compact.uniq(&:object_id)
239
+ end
240
+
234
241
  def format_declarations_function(node)
235
242
  indent_char = self.class.const_get(:INDENT_CHAR)
236
243
  [
@@ -27,6 +27,28 @@ module Expressir
27
27
  # Files above this use parse_fresh which has no packrat cache
28
28
  LARGE_FILE_THRESHOLD = 1024 * 1024 # 1 MB
29
29
 
30
+ # Whitespace rides the engine's injected trivia (parsanol-ruby
31
+ # #180's source-preserving mode, 1.3.82+): each unit records
32
+ # verbatim under :space and attaches to the next Named capture.
33
+ # Remarks stay OUT of the skip rule — they are product data,
34
+ # captured by the tailRemark/embeddedRemark rules and attached
35
+ # by RemarkScanner; tail remarks attach backward, the engine's
36
+ # next-capture channel is the wrong direction for them.
37
+ rule(:trivia) { match[" \r\n\t\f"].repeat(1) }
38
+ # exempt (parsanol#195, shipped 1.3.89): token rules whose bodies
39
+ # manage their own internals — injection before a char-class tail
40
+ # repetition absorbs the following token across whitespace
41
+ # ("RULE r FOR" parsed as one simpleId). simpleId additionally
42
+ # matches as a single quantified Re, which needs no exemption;
43
+ # the two mechanisms agree and stay for defense in depth.
44
+ # The whitespace/remark rules manage their own trivia end to end
45
+ # (they ARE the trivia surface for remarks); injection around
46
+ # them breaks consecutive remark lines on the Ruby engine
47
+ # ("-- X\n--\nSCHEMA ..." failed - the #487 windows follow-up,
48
+ # parsanol-ruby#195 family).
49
+ skip(:trivia, whitespace: :space,
50
+ exempt: %i[simpleId digits spaces space embeddedRemark tailRemark])
51
+
30
52
  # Get cached parser instance (thread-safe)
31
53
  # Reusing the parser avoids the overhead of reinitializing all rule definitions
32
54
  def self.cached_parser
@@ -604,7 +626,13 @@ module Expressir
604
626
  (unaryOp >> simpleFactorExpression).as(:simpleFactorUnaryExpression)
605
627
  end
606
628
  rule(:simpleId) do
607
- anyKeyword.absent? >> cts((match["a-zA-Z_"] >> match["a-zA-Z0-9_"].repeat).as(:str)).as(:simpleId)
629
+ # One quantified Re, not lead >> tail-repeat: under skip
630
+ # injection the DSL interleaves trivia before the tail
631
+ # repetition, so a two-atom ident would absorb the following
632
+ # whitespace-separated identifier ("RULE r FOR (e)" parses
633
+ # "r FOR" as one simpleId). A quantified Re consumes the
634
+ # whole identifier in a single step — parsanol GH-69.
635
+ anyKeyword.absent? >> cts(match("[a-zA-Z_][a-zA-Z0-9_]*").as(:str)).as(:simpleId)
608
636
  end
609
637
  rule(:simpleStringLiteral) do
610
638
  cts((str("'") >> (str("'").absent? >> any).repeat >> str("'")).as(:str)).as(:simpleStringLiteral)
@@ -112,20 +112,15 @@ module Expressir
112
112
  # @param node [Model::Declarations::Entity] Entity node
113
113
  # @return [String] Formatted entity
114
114
  def format_declarations_entity(node)
115
- if node.attributes.nil?
116
- explicit_attributes = []
117
- derived_attributes = []
118
- inverse_attributes = []
119
- else
120
- explicit_attributes = node.attributes.select do |x|
121
- x.kind == Model::Declarations::Attribute::EXPLICIT
122
- end
123
- derived_attributes = node.attributes.select do |x|
124
- x.kind == Model::Declarations::Attribute::DERIVED
125
- end
126
- inverse_attributes = node.attributes.select do |x|
127
- x.kind == Model::Declarations::Attribute::INVERSE
128
- end
115
+ attrs = entity_attribute_items(node)
116
+ explicit_attributes = attrs.select do |x|
117
+ x.kind == Model::Declarations::Attribute::EXPLICIT
118
+ end
119
+ derived_attributes = attrs.select do |x|
120
+ x.kind == Model::Declarations::Attribute::DERIVED
121
+ end
122
+ inverse_attributes = attrs.select do |x|
123
+ x.kind == Model::Declarations::Attribute::INVERSE
129
124
  end
130
125
 
131
126
  indent_str = " " * @config.indent
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Structural diff of two schemata with a machine-applicable patch
6
+ # (TODO.eengine/07; eeng --compare/--patch semantics, plugin/p11
7
+ # compare entry). Declarations match by canonical path
8
+ # `schema.item`; entity attributes get their own sub-deltas at
9
+ # `schema.item.attribute` carrying types, optionality and
10
+ # aggregates inside the signature.
11
+ #
12
+ # Interfaces are out of scope: the patch target is a flattened
13
+ # (longform) schema, which has none.
14
+ module SchemaCompare
15
+ # `node` carries the revised declaration for :added and :changed
16
+ # (nil for :removed) so {SchemaPatch.apply} can rebuild.
17
+ Delta = Struct.new(:op, :path, :node, keyword_init: true)
18
+
19
+ # Resolution artifacts and presentation metadata are not part of
20
+ # a declaration's structure.
21
+ SIGNATURE_IGNORE = %w[source include_source remarks remark_items
22
+ ref base_path file].freeze
23
+
24
+ COLLECTIONS = %i[constants types entities subtype_constraints
25
+ functions rules procedures].freeze
26
+
27
+ NODE_COLLECTION = {
28
+ "Expressir::Model::Declarations::Constant" => :constants,
29
+ "Expressir::Model::Declarations::Type" => :types,
30
+ "Expressir::Model::Declarations::Entity" => :entities,
31
+ "Expressir::Model::Declarations::SubtypeConstraint" => :subtype_constraints,
32
+ "Expressir::Model::Declarations::Function" => :functions,
33
+ "Expressir::Model::Declarations::Rule" => :rules,
34
+ "Expressir::Model::Declarations::Procedure" => :procedures,
35
+ }.freeze
36
+
37
+ module_function
38
+
39
+ def compare(base, revised)
40
+ COLLECTIONS.flat_map { |collection| collection_deltas(base, revised, collection) }
41
+ end
42
+
43
+ def collection_deltas(base, revised, collection)
44
+ base_items = index(base, collection)
45
+ revised_items = index(revised, collection)
46
+ deltas = (revised_items.keys - base_items.keys).map do |path|
47
+ Delta.new(op: :added, path: path, node: revised_items[path])
48
+ end
49
+ (base_items.keys - revised_items.keys).each do |path|
50
+ deltas << Delta.new(op: :removed, path: path, node: nil)
51
+ end
52
+ (base_items.keys & revised_items.keys).each do |path|
53
+ old_item = base_items[path]
54
+ new_item = revised_items[path]
55
+ next if signature(old_item) == signature(new_item)
56
+
57
+ deltas << Delta.new(op: :changed, path: path, node: new_item)
58
+ next unless new_item.is_a?(Model::Declarations::Entity)
59
+
60
+ deltas.concat(attribute_deltas(old_item, new_item, path))
61
+ end
62
+ deltas
63
+ end
64
+
65
+ def attribute_deltas(old_item, new_item, path)
66
+ old_attrs = index_by_id(Array(old_item.attributes))
67
+ new_attrs = index_by_id(Array(new_item.attributes))
68
+ deltas = (new_attrs.keys - old_attrs.keys).map do |name|
69
+ Delta.new(op: :added, path: "#{path}.#{name}",
70
+ node: new_attrs[name])
71
+ end
72
+ (old_attrs.keys - new_attrs.keys).each do |name|
73
+ deltas << Delta.new(op: :removed, path: "#{path}.#{name}", node: nil)
74
+ end
75
+ (old_attrs.keys & new_attrs.keys).each do |name|
76
+ next if signature(old_attrs[name]) == signature(new_attrs[name])
77
+
78
+ deltas << Delta.new(op: :changed, path: "#{path}.#{name}",
79
+ node: new_attrs[name])
80
+ end
81
+ deltas
82
+ end
83
+
84
+ def index_by_id(items)
85
+ items.filter_map { |item| [item.id&.downcase, item] if item.id }.to_h
86
+ end
87
+
88
+ def index(schema, collection)
89
+ index_by_id(Array(schema.send(collection)))
90
+ .transform_keys { |key| "#{schema.id.downcase}.#{key}" }
91
+ end
92
+
93
+ def signature(node)
94
+ strip(node.to_hash)
95
+ end
96
+
97
+ def strip(value)
98
+ case value
99
+ when Hash
100
+ value.each_with_object({}) do |(key, inner), out|
101
+ next if SIGNATURE_IGNORE.include?(key.to_s)
102
+
103
+ out[key] = strip(inner)
104
+ end
105
+ when Array then value.map { |inner| strip(inner) }
106
+ else value
107
+ end
108
+ end
109
+ end
110
+
111
+ # Applies {SchemaCompare} deltas to a schema model, returning the
112
+ # revised schema. The base schema is deep-copied through the
113
+ # lutaml-model round trip; the input schema is never mutated.
114
+ module SchemaPatch
115
+ module_function
116
+
117
+ def apply(base, deltas)
118
+ revised = base.class.from_hash(base.to_hash)
119
+ item_deltas, attribute_deltas = deltas.partition do |delta|
120
+ delta.path.split(".").size == 2
121
+ end
122
+ item_deltas.each { |delta| apply_item(revised, delta) }
123
+ # An item-level :added/:changed node already carries the final
124
+ # attribute list; its sub-deltas are informational only.
125
+ replaced_ops = %i[added changed].freeze
126
+ replaced = item_deltas
127
+ .select { |delta| replaced_ops.include?(delta.op) }
128
+ .map(&:path)
129
+ attribute_deltas
130
+ .reject { |delta| replaced.include?(delta.path.split(".")[0..1].join(".")) }
131
+ .each { |delta| apply_attribute(revised, delta) }
132
+ revised
133
+ end
134
+
135
+ def apply_item(schema, delta)
136
+ item_key = delta.path.split(".").last.downcase
137
+ collection = if delta.op == :added
138
+ schema.send(SchemaCompare::NODE_COLLECTION.fetch(delta.node.class.name))
139
+ else
140
+ live_collection(schema, item_key)
141
+ end
142
+ return unless collection
143
+
144
+ case delta.op
145
+ when :added then collection << delta.node
146
+ when :removed then collection.delete_if { |item| key(item) == item_key }
147
+ when :changed
148
+ index = collection.index { |item| key(item) == item_key }
149
+ return unless index
150
+
151
+ collection.delete_at(index)
152
+ collection.insert(index, delta.node)
153
+ end
154
+ end
155
+
156
+ def apply_attribute(schema, delta)
157
+ segments = delta.path.split(".")
158
+ item = SchemaCompare.index_by_id(all_declarations(schema))[segments[1].downcase]
159
+ return unless item
160
+
161
+ attr_key = segments[2]
162
+ attrs = Array(item.attributes)
163
+ case delta.op
164
+ when :added
165
+ item.attributes = [*attrs, delta.node]
166
+ when :removed
167
+ item.attributes = attrs.reject { |attr| key(attr) == attr_key }
168
+ when :changed
169
+ index = attrs.index { |attr| key(attr) == attr_key }
170
+ return unless index
171
+
172
+ item.attributes = attrs
173
+ item.attributes.delete_at(index)
174
+ item.attributes.insert(index, delta.node)
175
+ end
176
+ end
177
+
178
+ def live_collection(schema, item_key)
179
+ SchemaCompare::COLLECTIONS.map { |name| schema.send(name) }
180
+ .find do |collection|
181
+ Array(collection).any? { |item| key(item) == item_key }
182
+ end
183
+ end
184
+
185
+ def all_declarations(schema)
186
+ SchemaCompare::COLLECTIONS.flat_map { |name| Array(schema.send(name)) }
187
+ end
188
+
189
+ def key(item)
190
+ item.id&.downcase
191
+ end
192
+ end
193
+ end
194
+ end