lutaml-xsd 1.1.13 → 1.2.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/CLAUDE.md +18 -1
  4. data/Gemfile +2 -1
  5. data/Gemfile.lock +327 -0
  6. data/Rakefile +78 -1
  7. data/TODO.VISION.md +123 -0
  8. data/TODO.cleanup.md +43 -0
  9. data/TODO.conformance.md +99 -0
  10. data/TODO.docs-spa.md +46 -0
  11. data/TODO.interop.md +52 -0
  12. data/TODO.performance.md +80 -0
  13. data/TODO.som.md +47 -0
  14. data/benchmark/engines.rb +77 -0
  15. data/config/validation.yml +4 -0
  16. data/docs/CONFORMANCE.adoc +43 -0
  17. data/docs/JSON_SCHEMA.adoc +42 -0
  18. data/docs/superpowers/plans/2026-10-20-moxml-leptris-migration.md +735 -0
  19. data/docs/superpowers/specs/2026-10-20-moxml-leptris-migration-design.md +169 -0
  20. data/docs/superpowers/specs/2026-10-21-rule-engine-wiring-design.md +84 -0
  21. data/lib/lutaml/xsd/conformance/runner.rb +169 -0
  22. data/lib/lutaml/xsd/json_schema_exporter.rb +240 -0
  23. data/lib/lutaml/xsd/package_builder.rb +2 -2
  24. data/lib/lutaml/xsd/spa/schema_serializer.rb +12 -7
  25. data/lib/lutaml/xsd/validation/engine_context.rb +190 -0
  26. data/lib/lutaml/xsd/validation/rule_engine.rb +36 -18
  27. data/lib/lutaml/xsd/validation/rule_registry.rb +34 -0
  28. data/lib/lutaml/xsd/validation/rules/attribute_validation_rule.rb +126 -17
  29. data/lib/lutaml/xsd/validation/rules/content_model_validation_rule.rb +304 -194
  30. data/lib/lutaml/xsd/validation/rules/element_structure_rule.rb +25 -15
  31. data/lib/lutaml/xsd/validation/rules/identity_constraints_rule.rb +134 -0
  32. data/lib/lutaml/xsd/validation/rules/occurrence_validation_rule.rb +76 -25
  33. data/lib/lutaml/xsd/validation/rules/type_validation_rule.rb +134 -17
  34. data/lib/lutaml/xsd/validation/schema_definition_resolver.rb +254 -0
  35. data/lib/lutaml/xsd/validation/validation_configuration.rb +1 -0
  36. data/lib/lutaml/xsd/validation/validation_job.rb +70 -3
  37. data/lib/lutaml/xsd/validation/validation_rule.rb +44 -5
  38. data/lib/lutaml/xsd/validation/validator.rb +2 -1
  39. data/lib/lutaml/xsd/validation/xml_element.rb +2 -2
  40. data/lib/lutaml/xsd/version.rb +1 -1
  41. data/lib/lutaml/xsd.rb +14 -0
  42. data/lutaml-xsd.gemspec +2 -2
  43. metadata +27 -8
@@ -47,95 +47,119 @@ module Lutaml
47
47
  # The schema definition
48
48
  # @param collector [ResultCollector] Collector for validation results
49
49
  # @return [void]
50
- def validate(xml_element, schema_def, collector)
50
+ def validate(xml_element, schema_def, collector, repository: nil)
51
51
  return unless schema_def
52
52
 
53
- # Resolve content model
54
- content_model = resolve_content_model(schema_def)
55
- return unless content_model
56
-
57
- # Validate based on content model type
58
- case content_model
59
- when Lutaml::Xsd::Sequence
60
- validate_sequence(xml_element, content_model, collector)
61
- when Lutaml::Xsd::Choice
62
- validate_choice(xml_element, content_model, collector)
63
- when Lutaml::Xsd::All
64
- validate_all(xml_element, content_model, collector)
65
- end
53
+ return if engine_content_check(xml_element, collector) == :handled
54
+
55
+ particles = effective_particles(
56
+ xml_element, schema_def, repository
57
+ )
58
+ # nil: an xsi:type override that does not resolve — the type
59
+ # rule already reports it, so no content walk runs here.
60
+ return if particles.nil? || particles.empty?
61
+
62
+ validate_particle_sequence(xml_element, particles, collector,
63
+ repository)
66
64
  end
67
65
 
68
66
  private
69
67
 
70
- # Resolve content model from schema definition
68
+ # Particles of the effective type: an xsi:type override wins over
69
+ # the declared type; the walk then consumes the list in order.
71
70
  #
72
- # @param schema_def [Lutaml::Xsd::ComplexType, Lutaml::Xsd::Element]
73
- # @return [Lutaml::Xsd::Sequence, Lutaml::Xsd::Choice,
74
- # Lutaml::Xsd::All, nil]
75
- def resolve_content_model(schema_def)
76
- # For elements, get type first
77
- if schema_def.is_a?(Lutaml::Xml::Schema::Xsd::Element)
78
- type_def = resolve_type(schema_def)
79
- return resolve_content_model(type_def) if type_def
71
+ # @return [Array, nil] nil when an xsi:type override does not
72
+ # resolve to a known type
73
+ def effective_particles(xml_element, schema_def, repository)
74
+ xsi = repository && xsi_attribute(xml_element, "type")
75
+ if xsi&.value.to_s.match?(/\S/)
76
+ local = xsi.value.to_s.split(":").last
77
+ named = repository.resolve_named_type(local)
78
+ return nil unless named
79
+
80
+ return type_particles(named, repository, {}.compare_by_identity)
80
81
  end
81
82
 
82
- # For complex types, check content model
83
- return schema_def.sequence if schema_def.sequence
84
- return schema_def.choice if schema_def.choice
85
- return schema_def.all if schema_def.all
83
+ case schema_def
84
+ when Lutaml::Xml::Schema::Xsd::Element
85
+ type_def = resolve_type(schema_def, repository)
86
+ return [] unless type_def
86
87
 
87
- # Check complex content
88
- if schema_def.complex_content
89
- return resolve_from_complex_content(schema_def.complex_content)
88
+ type_particles(type_def, repository, {}.compare_by_identity)
89
+ when Lutaml::Xsd::Sequence, Lutaml::Xsd::Choice, Lutaml::Xsd::All
90
+ # A rooted group walks as a single particle: the sequence
91
+ # walk's container handling then matches any leaf in any
92
+ # consecutive run, which is permissive-correct for choice
93
+ # and all without the old ambiguity false positive.
94
+ [schema_def]
95
+ else
96
+ type_particles(schema_def, repository, {}.compare_by_identity)
90
97
  end
91
-
92
- nil
93
98
  end
94
99
 
95
- # Resolve type from element
100
+ # Ordered particles of a type: own sequence/choice/all/element/
101
+ # any/group particles plus, for extensions, the base type's
102
+ # particles first. Cycle-guarded. Particles are kept whole —
103
+ # nested containers are expanded by the walk's matching, which
104
+ # preserves their own occurrence constraints.
96
105
  #
97
- # @param element [Lutaml::Xsd::Element] Schema element
98
- # @return [Lutaml::Xsd::ComplexType, nil]
99
- def resolve_type(element)
100
- return element.complex_type if element.complex_type
106
+ # @return [Array]
107
+ def type_particles(type, repository, seen)
108
+ return [] if type.nil? || seen.key?(type)
101
109
 
102
- # TODO: Resolve type reference from repository
103
- nil
104
- end
110
+ seen[type] = true
111
+ particles = []
105
112
 
106
- # Resolve content model from complex content
107
- #
108
- # @param complex_content [Lutaml::Xsd::ComplexContent]
109
- # @return [Lutaml::Xsd::Sequence, Lutaml::Xsd::Choice,
110
- # Lutaml::Xsd::All, nil]
111
- def resolve_from_complex_content(complex_content)
112
- if complex_content.extension
113
- ext = complex_content.extension
114
- return ext.sequence if ext.sequence
115
- return ext.choice if ext.choice
116
- return ext.all if ext.all
113
+ %i[sequence choice all element any group].each do |kind|
114
+ next unless type.respond_to?(kind)
115
+
116
+ Array(type.public_send(kind)).each do |particle|
117
+ next unless particle
118
+
119
+ # A sequence's direct particles join the walk in order;
120
+ # choice/all/any/element/group stay whole so their own
121
+ # occurrence constraints survive the walk.
122
+ if particle.is_a?(Lutaml::Xsd::Sequence)
123
+ particles.concat(collect_particles(particle))
124
+ else
125
+ particles << particle
126
+ end
127
+ end
117
128
  end
118
129
 
119
- if complex_content.restriction
120
- restr = complex_content.restriction
121
- return restr.sequence if restr.sequence
122
- return restr.choice if restr.choice
123
- return restr.all if restr.all
130
+ if type.respond_to?(:complex_content) && type.complex_content
131
+ content = type.complex_content
132
+ extension = Array(content.extension).first
133
+ if extension
134
+ base_local = extension.base.to_s.split(":").last
135
+ base_type = base_local && repository&.resolve_named_type(base_local)
136
+ particles = type_particles(base_type, repository, seen) +
137
+ particles
138
+ end
139
+ %i[sequence choice all element any group].each do |kind|
140
+ next unless extension.respond_to?(kind)
141
+
142
+ Array(extension.public_send(kind)).each do |particle|
143
+ next unless particle
144
+
145
+ if particle.is_a?(Lutaml::Xsd::Sequence)
146
+ particles.concat(collect_particles(particle))
147
+ else
148
+ particles << particle
149
+ end
150
+ end
151
+ end
124
152
  end
125
153
 
126
- nil
154
+ particles
127
155
  end
128
156
 
129
- # Validate sequence content model
130
- #
131
- # Elements must appear in the order defined by the sequence.
157
+ # Sequential walk over a particle list (min/max per particle,
158
+ # recursive matching, wildcard permission for unmatched tails).
132
159
  #
133
- # @param xml_element [XmlElement] The XML element
134
- # @param sequence [Lutaml::Xsd::Sequence] The sequence definition
135
- # @param collector [ResultCollector] Result collector
136
160
  # @return [void]
137
- def validate_sequence(xml_element, sequence, collector)
138
- particles = collect_particles(sequence)
161
+ def validate_particle_sequence(xml_element, particles, collector,
162
+ repository)
139
163
  return if particles.empty?
140
164
 
141
165
  children = xml_element.children
@@ -143,14 +167,30 @@ module Lutaml
143
167
 
144
168
  particles.each do |particle|
145
169
  min_occurs = parse_occurs(particle.min_occurs, 1)
146
- max_occurs = parse_max_occurs(particle.max_occurs, 1)
170
+ # Container particles (nested sequence/choice/all) consume
171
+ # their whole consecutive run: the walk counts individual
172
+ # children, so a default max of 1 would truncate the group
173
+ # after its first child. Explicit caps on containers are
174
+ # enforced loosely for the same reason.
175
+ container = particle.is_a?(Lutaml::Xsd::Sequence) ||
176
+ particle.is_a?(Lutaml::Xsd::Choice) ||
177
+ particle.is_a?(Lutaml::Xsd::All)
178
+ max_occurs = parse_max_occurs(
179
+ particle.max_occurs, container ? :unbounded : 1
180
+ )
147
181
  matched_count = 0
148
182
 
149
183
  # Try to match children against this particle
150
184
  while child_index < children.size
151
185
  child = children[child_index]
152
186
 
153
- break unless particle_matches?(child, particle)
187
+ break unless particle_matches?(child, particle, repository,
188
+ parent: xml_element)
189
+
190
+ if particle.is_a?(Lutaml::Xsd::Any)
191
+ check_strict_wildcard_child(child, particle, collector,
192
+ repository)
193
+ end
154
194
 
155
195
  matched_count += 1
156
196
  child_index += 1
@@ -160,32 +200,29 @@ module Lutaml
160
200
  matched_count >= max_occurs
161
201
 
162
202
  # Child doesn't match, move to next particle
163
-
164
203
  end
165
204
 
166
- # Check minimum occurrences
205
+ # Check minimum occurrence
167
206
  next unless matched_count < min_occurs
207
+ next if particle.is_a?(Lutaml::Xsd::Any) && min_occurs.zero?
168
208
 
169
- report_error(
170
- collector,
171
- code: "sequence_min_occurs_violation",
172
- message: "Element '#{particle_name(particle)}' must occur " \
173
- "at least #{min_occurs} time(s) in sequence, " \
174
- "found #{matched_count}",
175
- location: xml_element.xpath,
176
- context: {
177
- particle: particle_name(particle),
178
- min_occurs: min_occurs,
179
- actual: matched_count,
180
- },
181
- )
209
+ report_min_occurs_violation(particle, xml_element, collector,
210
+ min_occurs, matched_count)
182
211
  end
183
212
 
184
213
  # Check for unexpected children after sequence
185
214
  return unless child_index < children.size
186
215
 
216
+ wildcard = particles.find { |p| p.is_a?(Lutaml::Xsd::Any) }
187
217
  unexpected = children[child_index..]
188
218
  unexpected.each do |child|
219
+ if wildcard && wildcard_permits?(wildcard, xml_element, child,
220
+ repository)
221
+ check_strict_wildcard_child(child, wildcard, collector,
222
+ repository)
223
+ next
224
+ end
225
+
189
226
  report_error(
190
227
  collector,
191
228
  code: "unexpected_element_in_sequence",
@@ -200,118 +237,149 @@ module Lutaml
200
237
  end
201
238
  end
202
239
 
203
- # Validate choice content model
240
+ # Report an unsatisfied minimum occurrence: a choice container
241
+ # names its alternatives, anything else reports generically.
204
242
  #
205
- # One (and only one) of the alternatives must be present.
206
- #
207
- # @param xml_element [XmlElement] The XML element
208
- # @param choice [Lutaml::Xsd::Choice] The choice definition
209
- # @param collector [ResultCollector] Result collector
210
243
  # @return [void]
211
- def validate_choice(xml_element, choice, collector)
212
- particles = collect_particles(choice)
213
- return if particles.empty?
214
-
215
- children = xml_element.children
216
- matched_particles = []
217
-
218
- # Find which particles match
219
- particles.each do |particle|
220
- matches = children.select do |child|
221
- particle_matches?(child, particle)
244
+ def report_min_occurs_violation(particle, xml_element, collector,
245
+ min_occurs, matched_count)
246
+ if particle.is_a?(Lutaml::Xsd::Choice)
247
+ alternatives = collect_particles(particle).map do |p|
248
+ particle_name(p)
222
249
  end
223
- matched_particles << particle if matches.any?
224
- end
225
-
226
- min_occurs = parse_occurs(choice.min_occurs, 1)
227
-
228
- # Check if at least one alternative is present
229
- if matched_particles.empty? && min_occurs.positive?
230
- particle_names = particles.map { |p| particle_name(p) }
231
250
  report_error(
232
251
  collector,
233
252
  code: "choice_not_satisfied",
234
253
  message: "One of the following elements must be present: " \
235
- "#{particle_names.join(', ')}",
254
+ "#{alternatives.join(', ')}",
236
255
  location: xml_element.xpath,
237
- context: {
238
- choices: particle_names,
239
- },
240
- suggestion: "Add one of: #{particle_names.join(', ')}",
256
+ context: { choices: alternatives },
257
+ suggestion: "Add one of: #{alternatives.join(', ')}",
241
258
  )
242
- elsif matched_particles.size > 1
243
- # Multiple alternatives present (ambiguous choice)
259
+ else
244
260
  report_error(
245
261
  collector,
246
- code: "choice_ambiguous",
247
- message: "Only one choice alternative should be present, " \
248
- "found #{matched_particles.size}",
262
+ code: "sequence_min_occurs_violation",
263
+ message: "Element '#{particle_name(particle)}' must occur " \
264
+ "at least #{min_occurs} time(s)",
249
265
  location: xml_element.xpath,
250
266
  context: {
251
- matched: matched_particles.map { |p| particle_name(p) },
267
+ particle: particle_name(particle),
268
+ min_occurs: min_occurs,
269
+ actual: matched_count,
252
270
  },
253
- suggestion: "Use only one of the choice alternatives",
254
271
  )
255
272
  end
256
273
  end
257
274
 
258
- # Validate all content model
259
- #
260
- # All elements must appear, but order doesn't matter.
275
+ # Engine-first content-model check for this element.
261
276
  #
262
277
  # @param xml_element [XmlElement] The XML element
263
- # @param all [Lutaml::Xsd::All] The all definition
264
- # @param collector [ResultCollector] Result collector
265
- # @return [void]
266
- def validate_all(xml_element, all, collector)
267
- particles = collect_particles(all)
268
- return if particles.empty?
278
+ # @param collector [ResultCollector] Collector for results
279
+ # @return [:handled, :skipped] :skipped lets the Ruby walk run
280
+ def engine_content_check(xml_element, collector)
281
+ engine = @options[:engine_context]
282
+ return :skipped unless engine&.supported?
283
+
284
+ answer = engine.content_valid(
285
+ xml_element.name, xml_element.moxml_element.children.to_a
286
+ )
287
+ return :skipped if answer.nil?
288
+
289
+ unless answer
290
+ report_error(
291
+ collector,
292
+ code: "content_model_error",
293
+ message: "Content of element '#{xml_element.name}' is not " \
294
+ "valid against its content model",
295
+ location: xml_element.xpath,
296
+ )
297
+ end
269
298
 
270
- children = xml_element.children
299
+ :handled
300
+ end
271
301
 
272
- particles.each do |particle|
273
- min_occurs = parse_occurs(particle.min_occurs, 1)
274
- max_occurs = parse_max_occurs(particle.max_occurs, 1)
302
+ # Resolve type from element
303
+ #
304
+ # @param element [Lutaml::Xsd::Element] Schema element
305
+ # @return [Lutaml::Xsd::ComplexType, nil]
306
+ def resolve_type(element, repository = nil)
307
+ return element.complex_type if element.complex_type
308
+ return nil unless element.type
275
309
 
276
- # Count matches
277
- matched_count = children.count do |child|
278
- particle_matches?(child, particle)
279
- end
310
+ resolved = repository&.resolve_type(element)
311
+ resolved if resolved.is_a?(Lutaml::Xml::Schema::Xsd::ComplexType)
312
+ end
280
313
 
281
- # Check minimum occurrence
282
- if matched_count < min_occurs
283
- report_error(
284
- collector,
285
- code: "all_min_occurs_violation",
286
- message: "Element '#{particle_name(particle)}' must occur " \
287
- "at least #{min_occurs} time(s), found " \
288
- "#{matched_count}",
289
- location: xml_element.xpath,
290
- context: {
291
- element: particle_name(particle),
292
- min_occurs: min_occurs,
293
- actual: matched_count,
294
- },
295
- )
314
+ # Whether a wildcard's namespace constraint admits the child.
315
+ #
316
+ # @param wildcard [Lutaml::Xsd::Any]
317
+ # @param parent [XmlElement] Content-model owner
318
+ # @param child [XmlElement]
319
+ # @param repository [SchemaDefinitionResolver, nil]
320
+ # @return [Boolean]
321
+ def wildcard_permits?(wildcard, parent, child, repository)
322
+ child_ns = child.namespace_uri.to_s
323
+ case wildcard.namespace.to_s
324
+ when "##any", ""
325
+ true
326
+ when "##other"
327
+ target = content_model_target_namespace(parent, repository)
328
+ !child_ns.empty? && child_ns != target.to_s
329
+ when "##local"
330
+ child_ns.empty?
331
+ when "##targetNamespace"
332
+ child_ns == content_model_target_namespace(parent, repository).to_s
333
+ else
334
+ # space-separated namespace list (##targetNamespace ##local allowed)
335
+ wildcard.namespace.to_s.split(/\s+/).any? do |entry|
336
+ case entry
337
+ when "##local" then child_ns.empty?
338
+ when "##targetNamespace"
339
+ child_ns == content_model_target_namespace(parent,
340
+ repository).to_s
341
+ else entry == child_ns
342
+ end
296
343
  end
344
+ end
345
+ end
297
346
 
298
- # Check maximum occurrence (in xs:all, max is usually 1)
299
- next unless max_occurs != :unbounded && matched_count > max_occurs
347
+ # @return [Boolean]
348
+ def wildcard_child_declared?(child, repository)
349
+ return true if repository.nil?
300
350
 
301
- report_error(
302
- collector,
303
- code: "all_max_occurs_violation",
304
- message: "Element '#{particle_name(particle)}' must occur " \
305
- "at most #{max_occurs} time(s), found " \
306
- "#{matched_count}",
307
- location: xml_element.xpath,
308
- context: {
309
- element: particle_name(particle),
310
- max_occurs: max_occurs,
311
- actual: matched_count,
312
- },
313
- )
314
- end
351
+ !repository.resolve(child.name, child.namespace_uri).nil?
352
+ end
353
+
354
+ # processContents="strict" on a wildcard demands the matched
355
+ # child be declared; lax/skip place no declaration requirement.
356
+ #
357
+ # @return [void]
358
+ def check_strict_wildcard_child(child, wildcard, collector,
359
+ repository)
360
+ return unless wildcard.process_contents.to_s == "strict"
361
+ return if wildcard_child_declared?(child, repository)
362
+
363
+ report_error(
364
+ collector,
365
+ code: "wildcard_undeclared_element",
366
+ message: "Element '#{child.qualified_name}' matches " \
367
+ "xs:any with processContents=strict but is not " \
368
+ "declared",
369
+ location: child.xpath,
370
+ context: { element: child.qualified_name },
371
+ )
372
+ end
373
+
374
+ # Best-effort target namespace of the schema owning the parent's
375
+ # declaration (via the resolver index).
376
+ #
377
+ # @return [String, nil]
378
+ def content_model_target_namespace(parent, repository)
379
+ return nil unless repository
380
+
381
+ declaration = repository.resolve(parent.name, parent.namespace_uri)
382
+ repository.target_namespace_for(declaration) if declaration
315
383
  end
316
384
 
317
385
  # Collect particles from content model
@@ -320,11 +388,21 @@ module Lutaml
320
388
  # Lutaml::Xsd::All]
321
389
  # @return [Array]
322
390
  def collect_particles(content_model)
391
+ # resolved_element_order preserves declaration order including
392
+ # xs:any particles (which child_elements drops).
393
+ if content_model.respond_to?(:resolved_element_order) &&
394
+ content_model.resolved_element_order.to_a.any?
395
+ return content_model.resolved_element_order.to_a
396
+ end
397
+
323
398
  particles = []
324
399
 
325
400
  # Collect elements
326
401
  particles.concat(Array(content_model.element)) if content_model.element.any?
327
402
 
403
+ # Collect wildcards
404
+ particles.concat(Array(content_model.any)) if content_model.any
405
+
328
406
  # Collect nested sequences
329
407
  particles.concat(Array(content_model.sequence)) if content_model.sequence
330
408
 
@@ -341,38 +419,78 @@ module Lutaml
341
419
  #
342
420
  # @param xml_element [XmlElement] The XML element
343
421
  # @param particle [Object] The schema particle
422
+ # @param repository [SchemaDefinitionResolver, nil]
423
+ # @param parent [XmlElement, nil] Content-model owner, for
424
+ # wildcard namespace constraints scoped to the target namespace
344
425
  # @return [Boolean]
345
- def particle_matches?(xml_element, particle)
426
+ def particle_matches?(xml_element, particle, repository = nil,
427
+ parent: nil)
346
428
  case particle
347
429
  when Lutaml::Xsd::Element
348
- element_matches?(xml_element, particle)
430
+ element_matches?(xml_element, particle, repository)
431
+ when Lutaml::Xsd::Any
432
+ wildcard_permits?(particle, parent || xml_element,
433
+ xml_element, repository)
349
434
  when Lutaml::Xsd::Sequence, Lutaml::Xsd::Choice, Lutaml::Xsd::All
350
- # Nested content model - would need recursive validation
351
- false
435
+ # Nested content model: a child matches the container when it
436
+ # matches any leaf particle inside it. Inner ordering is
437
+ # permissive; the outer walk stays sequential.
438
+ collect_particles(particle).any? do |leaf|
439
+ particle_matches?(xml_element, leaf, repository,
440
+ parent: parent)
441
+ end
352
442
  else
353
443
  false
354
444
  end
355
445
  end
356
446
 
357
- # Check if XML element matches schema element
447
+ # Check if XML element matches schema element.
448
+ #
449
+ # A substitution-group member matches a reference to its head.
358
450
  #
359
451
  # @param xml_element [XmlElement] The XML element
360
452
  # @param schema_element [Lutaml::Xsd::Element] The schema element
453
+ # @param repository [SchemaDefinitionResolver, nil]
361
454
  # @return [Boolean]
362
- def element_matches?(xml_element, schema_element)
363
- return false unless xml_element.name == schema_element.name
455
+ def element_matches?(xml_element, schema_element, repository = nil)
456
+ if xml_element.name == schema_element.name
457
+ return namespaces_match_instance?(
458
+ xml_element,
459
+ resolve_target_namespace(schema_element, repository),
460
+ )
461
+ end
364
462
 
365
- # Check namespace
366
- target_ns = resolve_target_namespace(schema_element)
463
+ substitution_member?(xml_element, schema_element, repository)
464
+ end
465
+
466
+ # Whether the instance element's declaration is in the
467
+ # substitution group headed by the particle's name/ref.
468
+ #
469
+ # @param xml_element [XmlElement]
470
+ # @param particle [Lutaml::Xsd::Element]
471
+ # @param repository [SchemaDefinitionResolver, nil]
472
+ # @return [Boolean]
473
+ def substitution_member?(xml_element, particle, repository)
474
+ return false unless repository
475
+
476
+ expected = particle.name || particle.ref
477
+ return false if expected.to_s.empty?
478
+
479
+ declaration = repository.resolve(
480
+ xml_element.name, xml_element.namespace_uri
481
+ )
482
+ return false unless declaration
483
+
484
+ declaration.substitution_group.to_s.split(":").last ==
485
+ expected.to_s.split(":").last
486
+ end
367
487
 
368
- # Get XML element namespace, treating nil and empty string as equivalent
488
+ # @return [Boolean]
489
+ def namespaces_match_instance?(xml_element, target_ns)
369
490
  xml_ns = xml_element.namespace_uri
370
491
  xml_ns = nil if xml_ns.to_s.empty?
371
492
  target_ns = nil if target_ns.to_s.empty?
372
493
 
373
- # For inline elements with no explicit namespace, match if both are nil
374
- # This handles the common case where elements are defined without
375
- # a target namespace in the schema and XML has no namespace declarations
376
494
  xml_ns == target_ns
377
495
  end
378
496
 
@@ -411,21 +529,13 @@ module Lutaml
411
529
  #
412
530
  # @param schema_element [Lutaml::Xsd::Element] Schema element
413
531
  # @return [String, nil]
414
- def resolve_target_namespace(schema_element)
415
- schema_element.target_namespace
416
- end
532
+ def resolve_target_namespace(schema_element, repository = nil)
533
+ # Inline declarations carry no namespace of their own; the
534
+ # resolver knows the owning schema's target namespace.
535
+ from_index = repository&.target_namespace_for(schema_element)
536
+ return from_index unless from_index.nil?
417
537
 
418
- # Check if namespaces match
419
- #
420
- # @param ns1 [String, nil] First namespace
421
- # @param ns2 [String, nil] Second namespace
422
- # @return [Boolean]
423
- def namespaces_match?(ns1, ns2)
424
- # Normalize empty strings to nil
425
- normalized_ns1 = ns1.to_s.empty? ? nil : ns1
426
- normalized_ns2 = ns2.to_s.empty? ? nil : ns2
427
-
428
- normalized_ns1 == normalized_ns2
538
+ schema_element.target_namespace
429
539
  end
430
540
  end
431
541
  end