lutaml-lml 0.2.1 → 0.3.1

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.
@@ -83,15 +83,19 @@ grammar Lml version "1.0.0" {
83
83
  # match['"\'']
84
84
  quotes = %x22 / %x27
85
85
 
86
- # quoted('"', :string) | quoted("'", :string)
87
- atomic dq_string = %x22 until "\"" as string %x22
88
- atomic sq_string = %x27 until "'" as string %x27
86
+ # RS 3001 §String values: exactly two escape sequences, \" and \\,
87
+ # strict in both quote kinds; a backslash before anything else is not
88
+ # a string. atomic keeps the scan verbatim (skip trivia must not eat
89
+ # interior runs).
90
+ atomic dq_string = %x22 ( *( ( %x5C %x22 ) / ( %x5C %x5C ) / ( !( %x22 / %x5C ) %x00-10FFFF ) ) ) as string %x22
91
+ atomic sq_string = %x27 ( *( ( %x5C %x27 ) / ( %x5C %x5C ) / ( !( %x27 / %x5C ) %x00-10FFFF ) ) ) as string %x27
89
92
  quoted_string = dq_string / sq_string
90
93
  # any_quoted(:path) for view import
91
94
  atomic dq_path = %x22 until "\"" as path %x22
92
95
  atomic sq_path = %x27 until "'" as path %x27
93
96
 
94
97
  boolean = ( "true" / "false" ) as boolean
98
+ null_literal = "null" as null
95
99
  number = ( 1*%x30-39 "." 1*%x30-39 ) as float / ( 1*%x30-39 ) as number
96
100
  variable = quoted_string / 1*word
97
101
  reference = ( "reference:(" / "ref:(" ) ( variable *( "." variable ) ) as reference ")"
@@ -100,12 +104,22 @@ grammar Lml version "1.0.0" {
100
104
  comment_definition = ( "**" / "#" ) ( *( !newline ANY ) ) as comments
101
105
  comment_multiline_definition = "*|" until "|*" as comments [ whitespace ] "|*"
102
106
 
103
- class_name_chars = 1*( %x41-5A / %x61-7A / %x30-39 / " " / "_" / "-" / ":" / "." )
107
+ # Spaces are interior-only in name and type tokens: a run may be
108
+ # followed by space-then-run, never a trailing space. The trailing
109
+ # separator is left for skip trivia, so captures need no post-strip
110
+ # and quoted string values keep their padding verbatim (RS 3001
111
+ # §String values).
112
+ # Atomic composite: the interior %x20 must match verbatim — skip
113
+ # trivia injects before sequence children and would otherwise eat the
114
+ # space before the grammar's own literal can (the type_token
115
+ # precedent).
116
+ atomic class_run = 1*( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / ":" / "." )
117
+ atomic class_name_chars = class_run *( %x20 class_run )
104
118
  class_name = class_name_chars [ "(" class_name_chars ")" ]
105
119
  cardinality_body_definition = cardchar as min [ ".." [ cardchar as max ] ]
106
120
  cardinality = "[" cardinality_body_definition as cardinality "]"
107
121
 
108
- data_value = boolean / reference / range / number / quoted_string
122
+ data_value = boolean / null_literal / reference / range / number / quoted_string
109
123
  key_value_pair = variable as key [ "=" ] data_value as value
110
124
  key_value_map = "{" [ whitespace ] ( *( key_value_pair [ whitespace ] ) ) as key_value_map "}"
111
125
  kw_visibility_modifier = "+" / "-" / "#" / "~"
@@ -116,12 +130,14 @@ grammar Lml version "1.0.0" {
116
130
  attribute_line = attribute [ [ "," ] whitespace ]
117
131
  visibility = kw_visibility_modifier as visibility_modifier
118
132
  attribute_stereotype = "<<" ( 1*( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / "/" ) ) as stereotype ">>"
119
- atomic type_token = 1*( !( "//" / "/*" ) typechar )
120
- atomic typechar = %x41-5A / %x61-7A / %x30-39 / "_" / "-" / " " / ":" / "/" / "+"
133
+ atomic type_run = 1*( !( "//" / "/*" ) ( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / ":" / "/" / "+" ) )
134
+ atomic type_token = type_run *( %x20 type_run )
121
135
  atomic namechar = %x41-5A / %x61-7A / %x30-39 / "_" / "-" / "/" / "+"
122
136
  attribute_type = [ ":" ] [ attribute_stereotype ] [ quotes as type_literal ] ( type_token ) as type [ quotes ]
123
137
  attribute_name = ( 1*namechar ) as name
124
- attribute_definition_name = ( quotes ( 1*( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / " " / "/" / "+" ) ) as name quotes ) / attribute_name
138
+ atomic quoted_name_run = 1*( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / "/" / "+" )
139
+ atomic quoted_name_body = quoted_name_run *( %x20 quoted_name_run )
140
+ attribute_definition_name = ( quotes ( quoted_name_body ) as name quotes ) / attribute_name
125
141
  attribute_definition = ( [ visibility ] as visibility attribute_definition_name [ attribute_type ] [ cardinality ] [ class_body ] ) as attributes
126
142
 
127
143
  keyword_type_argument = "type" ( 1*nonspace ) as type [ whitespace ]
@@ -218,7 +234,7 @@ grammar Lml version "1.0.0" {
218
234
  class_inner_definition = class_inner_definitions [ whitespace ]
219
235
  class_body = "{" [ whitespace ] ( *( class_inner_definition ) ) as members "}"
220
236
  parent_class = "<" ( class_name_chars ) as parent_class
221
- class_definition = [ element_visibility ] class_modifier [ whitespace ] kw_class_sp class_name as name [ parent_class ] [ attribute_stereotype ] [ class_body ]
237
+ class_definition = [ whitespace ] [ element_visibility ] [ whitespace ] class_modifier [ whitespace ] kw_class_sp class_name as name [ parent_class ] [ attribute_stereotype ] [ class_body ]
222
238
 
223
239
  # Balanced-brace definition text: nested "{...}" and "\"-escapes allowed
224
240
  definition_content = *( ( "\\" any_char ) / ( "{" definition_content "}" ) / ( !"}" any_char ) )
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Compiles LML enums into Serializable subclasses with member
7
+ # payloads, table-driven expansion, and value accessors.
8
+ module Enums
9
+ EnumValue = Struct.new(:name, :description, :payload)
10
+
11
+ private
12
+
13
+ def compile_enum(enum_def)
14
+ name = enum_def.name.to_s
15
+ expand_from_table(enum_def)
16
+ values = extract_enum_values(enum_def)
17
+
18
+ payload_fields = Array(enum_def.member_fields).map(&:name).map(&:to_s)
19
+ value_names = values.map { |v| v.name.to_s }
20
+ compiled_klass = Class.new(Lutaml::Model::Serializable) do
21
+ attribute :value, :string, values: value_names, default: values.first.name
22
+ attribute :description, :string
23
+ payload_fields.each do |field|
24
+ attribute field.to_sym, :string
25
+ end
26
+
27
+ define_method(:to_s) { value }
28
+ end
29
+
30
+ values.each do |value|
31
+ accessor = value.name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
32
+ compiled_klass.define_singleton_method(accessor) do
33
+ new({ value: value.name, description: value.description }.merge(value.payload || {}))
34
+ end
35
+ end
36
+ compiled_klass.define_singleton_method(:values) { values }
37
+
38
+ @enum_names << name
39
+ @enum_classes[name] = compiled_klass
40
+ register(name, compiled_klass)
41
+ end
42
+
43
+ # Builds a compiled Serializable subclass from a class or data-type
44
+ # definition: declares attributes and stashes forward-referenced
45
+ # attributes for post-registration resolution.
46
+
47
+ # RS 3001 §Attribute: `values { v1, v2 }` expands into an anonymous
48
+ # enum baked from the inline set; the attribute is typed with it.
49
+ def resolve_values_type(klass_def, attr)
50
+ return attr.type.to_s unless attr.value_set&.any?
51
+
52
+ enum_name = anonymous_enum_name(klass_def.name, attr.name)
53
+ members = attr.value_set.map { |v| ::Lutaml::Lml::TopElementAttribute.new(name: v.to_s) }
54
+ compile_enum(::Lutaml::Lml::Enum.new(name: enum_name, attributes: members))
55
+ enum_name
56
+ end
57
+
58
+ def anonymous_enum_name(class_name, attr_name)
59
+ base = class_name.to_s.split('::').last
60
+ attr_part = attr_name.to_s.split('_').map(&:capitalize).join
61
+ "#{base}#{attr_part}Values"
62
+ end
63
+
64
+ # L7a point 3: from_table members are baked at compile time — the
65
+ # artifact table's rows become the values, validated against the
66
+ # declared member_fields (unknown field = error; missing = nil).
67
+ # The member name comes from the row's "name" column.
68
+ def expand_from_table(enum_def)
69
+ return unless enum_def.from_table_name
70
+
71
+ rows = artifact_table_rows(enum_def)
72
+ known = Array(enum_def.member_fields).map { |f| f.name.to_s }
73
+ rows.each do |row|
74
+ row = row.transform_keys(&:to_s)
75
+ member_name = row.fetch('name')
76
+ unknown = row.keys - known - ['name']
77
+ if unknown.any?
78
+ raise ArgumentError,
79
+ "enum #{enum_def.name}: table row #{member_name.inspect} has " \
80
+ "undeclared fields #{unknown.inspect} (declared: #{known.inspect})"
81
+ end
82
+ payload = row.slice(*known).compact
83
+ enum_def.attributes << TopElementAttribute.new(
84
+ name: member_name,
85
+ properties: payload.map do |field, value|
86
+ TopElementAttribute.new(name: field, value: value)
87
+ end,
88
+ )
89
+ end
90
+ end
91
+
92
+ def artifact_table_rows(enum_def)
93
+ reference = enum_def.from_table_artifact || @default_artifact
94
+ path = artifact_path_for(reference.to_s)
95
+ unless path
96
+ raise ArgumentError,
97
+ "enum #{enum_def.name}: from_table artifact #{reference.inspect} not found"
98
+ end
99
+
100
+ envelope = JSON.parse(File.read(path))
101
+ table = envelope['tables'].to_h[enum_def.from_table_name]
102
+ unless table
103
+ raise ArgumentError,
104
+ "enum #{enum_def.name}: table #{enum_def.from_table_name.inspect} " \
105
+ "not found in artifact #{reference.inspect}"
106
+ end
107
+
108
+ table['rows'].to_a
109
+ end
110
+
111
+ def extract_enum_values(enum_def)
112
+ member_fields = Array(enum_def.member_fields).map do |f|
113
+ [f.name.to_s, f.type.to_s]
114
+ end
115
+ enum_def.attributes.map do |attr|
116
+ description = member_description(attr)
117
+ payload = extract_member_payload(attr)
118
+ validate_member_payload(enum_def, attr, payload, member_fields)
119
+ EnumValue.new(attr.name.to_s, description, payload)
120
+ end
121
+ end
122
+
123
+ # The member description rides a nested attribute named `description`
124
+ # (bare-member form) or `definition` (RS 3001 `value` blocks); both
125
+ # spellings are accepted.
126
+
127
+ # The member description rides a nested attribute named `description`
128
+ # (bare-member form) or `definition` (RS 3001 `value` blocks); both
129
+ # spellings are accepted.
130
+ def member_description(attr)
131
+ nested = Array(attr.attributes).find do |n|
132
+ %w[description definition].include?(n.name.to_s)
133
+ end
134
+ text = nested&.type || nested&.definition || nested&.value
135
+ text.is_a?(Hash) && text.key?(:string) ? text[:string] : text
136
+ end
137
+
138
+ # L7a: per-member payload lines ride the keyword-attribute
139
+ # properties (`abbreviation = "WD"`)
140
+
141
+ # L7a: per-member payload lines ride the keyword-attribute
142
+ # properties (`abbreviation = "WD"`)
143
+ def extract_member_payload(attr)
144
+ Array(attr.properties).to_h { |p| [p.name.to_s, p.value&.to_s] }
145
+ end
146
+
147
+ def validate_member_payload(enum_def, attr, payload, member_fields)
148
+ known = member_fields.map(&:first)
149
+ unknown = payload.keys - known
150
+ return unless unknown.any?
151
+
152
+ raise ArgumentError,
153
+ "enum #{enum_def.name}: member #{attr.name} has undeclared " \
154
+ "payload fields #{unknown.inspect} (declared: #{known.inspect})"
155
+ end
156
+
157
+ def enum_class_for(type)
158
+ type.is_a?(Class) && @enum_classes.value?(type) and return type
159
+ end
160
+
161
+ def enum_value_name(raw)
162
+ raw.split('::').last
163
+ end
164
+
165
+ # L7b: derived fields declare existence + type; computation is
166
+ # bound by the binder from artifact derive specs. The attribute
167
+ # participates in mappings like any other (relaton consumes URNs
168
+ # from model JSON).
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Builds typed instances from parsed instance documents
7
+ # against the compiled class set.
8
+ module Hydration
9
+ private
10
+
11
+ def hydrate_instance(instance)
12
+ inner = instance.instance
13
+ return hydrate_instance(inner) if inner && Array(instance.attributes).empty?
14
+
15
+ type_name = resolve_instance_type(instance)
16
+ klass = @compiled[type_name]
17
+ return hydrate_as_untyped(instance, type_name) unless klass
18
+
19
+ attrs = extract_instance_attributes(instance, klass)
20
+ klass.new(**attrs)
21
+ end
22
+
23
+ # An inline map's cargo is {key_value_map: [{key:, value:}, ...]} with
24
+ # literal-shaped keys/values ({string: ..}, {number: ..}, {boolean: ..});
25
+ # materialize it as a plain Hash (issue #17: maps hydrate as data).
26
+
27
+ # An inline map's cargo is {key_value_map: [{key:, value:}, ...]} with
28
+ # literal-shaped keys/values ({string: ..}, {number: ..}, {boolean: ..});
29
+ # materialize it as a plain Hash (issue #17: maps hydrate as data).
30
+ def hydrate_map_cargo(cargo)
31
+ cargo[:key_value_map].to_h do |pair|
32
+ [hydrate_scalar(pair[:key]).to_s, hydrate_scalar(pair[:value])]
33
+ end
34
+ end
35
+
36
+ def hydrate_scalar(literal)
37
+ literal = literal.value if literal.respond_to?(:value)
38
+ literal.is_a?(Hash) ? literal.values.first : literal
39
+ end
40
+
41
+ def resolve_instance_type(instance)
42
+ # L6: the explicit isa keyword is the type override; a `type`
43
+ # attribute is ordinary data (pubid's identifier-kind field)
44
+ return demodulize(instance.isa.to_s) if instance.isa
45
+
46
+ demodulize(instance.type.to_s)
47
+ end
48
+
49
+ def demodulize(name)
50
+ name.split('::').last.to_s
51
+ end
52
+
53
+ def hydrate_as_untyped(instance, type_name)
54
+ { _name: type_name, **extract_raw_attributes(instance) }
55
+ end
56
+
57
+ def extract_raw_attributes(instance)
58
+ instance.each_attribute.with_object({}) do |(name, value, nested), hash|
59
+ hash[name.to_sym] = resolve_instance_value(value, nested)
60
+ end
61
+ end
62
+
63
+ def extract_instance_attributes(instance, klass)
64
+ schema_keys = klass.attributes.keys.to_set
65
+ instance.each_attribute.with_object({}) do |(name, value, nested), hash|
66
+ key = name.to_sym
67
+ next unless schema_keys.include?(key)
68
+
69
+ hash[key] = resolve_instance_value(value, nested, klass.attributes[key])
70
+ end
71
+ end
72
+
73
+ def resolve_instance_value(value, nested, attr_def = nil)
74
+ value = value.value if value.respond_to?(:value)
75
+ return hydrate_map_cargo(value) if value.is_a?(Hash) && value.key?(:key_value_map)
76
+
77
+ if nested.any?
78
+ # A scalar attribute with a single nested instance hydrates to
79
+ # the object itself; the declaration is the truth (issue #17).
80
+ return hydrate_typed(nested.first, attr_def) if !attr_def&.collection && nested.one?
81
+
82
+ nested.map { |i| hydrate_typed(i, attr_def) }
83
+ # The Array guard must run before the enum branch: an array
84
+ # value under an enum-typed attribute stays a raw array.
85
+ # rubocop:disable Lint/DuplicateBranch
86
+ elsif value.is_a?(Array)
87
+ value
88
+ elsif (enum_class = attr_def && enum_class_for(attr_def.type))
89
+ value_name = enum_value_name(value.to_s)
90
+ description = enum_class.values
91
+ .find { |v| v.name == value_name }&.description
92
+ enum_class.new(value: value_name, description: description)
93
+ elsif !value.nil?
94
+ value
95
+ # rubocop:enable Lint/DuplicateBranch
96
+ end
97
+ end
98
+
99
+ # An untyped nested instance takes the enclosing attribute's type;
100
+ # hydrate_as_untyped would otherwise emit a raw hash polluted with
101
+ # a `_name` key, which also defeats union member key-coverage.
102
+
103
+ # An untyped nested instance takes the enclosing attribute's type;
104
+ # hydrate_as_untyped would otherwise emit a raw hash polluted with
105
+ # a `_name` key, which also defeats union member key-coverage.
106
+ def hydrate_typed(instance, attr_def)
107
+ return hydrate_instance(instance) if instance.isa || !instance.type.to_s.empty?
108
+
109
+ type = attr_def&.type
110
+ return hydrate_instance(instance) unless type.is_a?(Class)
111
+
112
+ if type.include?(Lutaml::Model::Serialize)
113
+ type.new(**extract_instance_attributes(instance, type))
114
+ elsif type == Lutaml::Model::Type::Union
115
+ extract_raw_attributes(instance)
116
+ else
117
+ hydrate_instance(instance)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Applies declared LML serialization mappings
7
+ # (xml / key_value wire names, namespaces) onto compiled classes.
8
+ module SerializationMappings
9
+ private
10
+
11
+ # In-class `mapping <format> { … }` is the shortcut for a sibling
12
+ # mapping targeting the enclosing class; hoist them so emission is
13
+ # uniform.
14
+ def hoist_class_serialization_mappings(doc)
15
+ (Array(doc.classes) + Array(doc.data_types)).each do |klass_def|
16
+ Array(klass_def.serialization_mappings).each do |mapping|
17
+ mapping.target = klass_def.name.to_s
18
+ @serialization_mappings << mapping
19
+ end
20
+ end
21
+ end
22
+
23
+ # Emit the lutaml-model mapping blocks declared in the LML source:
24
+ # mapping yaml { map "full-name", to: "name" }
25
+ # becomes klass.yml do |m| m.map "full-name", to: :name end
26
+ def apply_declared_mappings(compiled_klass, def_obj)
27
+ return unless def_obj.is_a?(UmlClass) || def_obj.is_a?(DataType)
28
+
29
+ def_obj.wire_mappings.to_a.group_by(&:format).each do |format, entries|
30
+ next unless format && Lutaml::Model::FormatRegistry.registered?(format.to_sym)
31
+
32
+ compiled_klass.public_send(format) do |mapping|
33
+ entries.each { |entry| mapping.map(entry.wire, to: entry.to.to_sym) }
34
+ end
35
+ end
36
+ end
37
+
38
+ # Apply a lutaml-model XML mapping to each compiled class so they
39
+ # support from_xml/to_xml. Runs after forward references are resolved
40
+ # so all attributes (immediate + deferred) get element mappings.
41
+ def apply_xml_mappings
42
+ @compiled.each do |name, klass|
43
+ next if @enum_names.include?(name)
44
+
45
+ custom = serialization_mapping_for(name, 'xml')
46
+ next apply_custom_xml_mapping(klass, custom) if custom
47
+
48
+ apply_xml_mapping(klass, name)
49
+ end
50
+ end
51
+
52
+ def apply_xml_mapping(klass, root_name)
53
+ attr_names = klass.attributes.keys
54
+ return if attr_names.empty?
55
+
56
+ klass.xml do |mapping|
57
+ mapping.element(root_name)
58
+ attr_names.each do |attr_name|
59
+ mapping.map_element(attr_name.to_s, to: attr_name)
60
+ end
61
+ end
62
+ end
63
+
64
+ # RS 3010 extension: key-value mapping blocks for the declared
65
+ # formats (yaml/json/toml share lutaml-model's key_value model).
66
+
67
+ # RS 3010 extension: key-value mapping blocks for the declared
68
+ # formats (yaml/json/toml share lutaml-model's key_value model).
69
+ def apply_key_value_mappings
70
+ @compiled.each do |name, klass|
71
+ next if @enum_names.include?(name)
72
+
73
+ custom = serialization_mapping_for(name, 'key_value')
74
+ apply_custom_key_value_mapping(klass, custom) if custom
75
+ end
76
+ end
77
+
78
+ def serialization_mapping_for(target, format)
79
+ @serialization_mappings.find do |m|
80
+ m.target.to_s == target && serialization_format(m.format) == format
81
+ end
82
+ end
83
+
84
+ def serialization_format(format)
85
+ %w[yaml json toml].include?(format.to_s) ? 'key_value' : format.to_s
86
+ end
87
+
88
+ def apply_custom_xml_mapping(klass, mapping)
89
+ check_namespace_ambiguity(mapping)
90
+ ns_class = namespace_class_for(mapping.namespace_ref) if mapping.namespace_ref
91
+ rule_ns = mapping.rules.select { |r| r.kind == 'namespace' }
92
+ .to_h { |r| [r.field, namespace_class_for(r.field)] }
93
+ klass.xml do |m|
94
+ m.element(mapping.element_name) if mapping.element_name
95
+ m.namespace(ns_class) if ns_class
96
+ m.mixed_content if mapping.mixed_content
97
+ mapping.rules.each do |rule|
98
+ case rule.kind
99
+ when 'element' then m.map_element(rule.wire, to: rule.field.to_sym)
100
+ when 'attribute' then m.map_attribute(rule.wire, to: rule.field.to_sym)
101
+ when 'content' then m.map_content(to: rule.field.to_sym)
102
+ when 'namespace' then m.namespace(rule_ns[rule.field])
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def apply_custom_key_value_mapping(klass, mapping)
109
+ formats = mapping.format.to_s == 'key_value' ? %w[yaml json toml] : [mapping.format.to_s]
110
+ klass.instance_eval do
111
+ formats.each do |format|
112
+ public_send(format) do |m|
113
+ mapping.rules.each do |rule|
114
+ case rule.kind
115
+ when 'map' then m.map(rule.wire, to: rule.field.to_sym)
116
+ when 'key' then m.map_key(to_instance: rule.field.to_sym)
117
+ when 'value'
118
+ if rule.mode == 'as_attribute'
119
+ m.map_value(as_attribute: rule.field.to_sym)
120
+ else
121
+ m.map_value(to_instance: rule.field.to_sym)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ def check_namespace_ambiguity(mapping)
131
+ return unless mapping.format.to_s == 'xml'
132
+ return if mapping.namespace_ref || @declared_namespaces.length < 2
133
+
134
+ raise ArgumentError,
135
+ "mapping #{mapping.format} #{mapping.target}: ambiguous default " \
136
+ "namespace — #{@declared_namespaces.length} namespaces in scope " \
137
+ "(#{@declared_namespaces.map(&:name).join(', ')}); declare one with " \
138
+ "'namespace <name>'"
139
+ end
140
+
141
+ def namespace_class_for(ref)
142
+ return nil if ref.nil?
143
+
144
+ require 'lutaml/xml/namespace' unless defined?(Lutaml::Xml::Namespace)
145
+
146
+ ns_class = @namespace_classes[ref]
147
+ return ns_class if ns_class
148
+
149
+ declared = @declared_namespaces.find { |ns| ns.name.to_s == ref }
150
+ raise ArgumentError, "unknown namespace '#{ref}'" unless declared
151
+
152
+ ns_class = Class.new(Lutaml::Xml::Namespace)
153
+ uri = declared.uri
154
+ prefix = declared.prefix.to_s
155
+ ns_class.define_singleton_method(:uri) { uri }
156
+ ns_class.define_singleton_method(:prefix_default) { prefix }
157
+ @namespace_classes[ref] = ns_class
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Registers declared string_format handlers on
7
+ # compiled classes.
8
+ module StringFormats
9
+ private
10
+
11
+ # L5: register grammar-backed string formats. Class-level
12
+ # declarations win; a models-block default applies to classes
13
+ # without their own. Artifact resolution: the compiler's
14
+ # artifacts map (name => path), else the string as a path.
15
+ def register_string_formats(doc)
16
+ defaults = doc.packages.flat_map(&:default_string_formats)
17
+ doc.classes.each do |class_def|
18
+ declared = Array(class_def.string_formats)
19
+ declared.each { |fmt| register_string_format(class_def.name.to_s, fmt) }
20
+
21
+ declared_formats = declared.map(&:format)
22
+ defaults.reject { |fmt| declared_formats.include?(fmt.format) }
23
+ .each { |fmt| register_string_format(class_def.name.to_s, fmt) }
24
+ end
25
+ end
26
+
27
+ def register_string_format(class_name, fmt)
28
+ artifact_path = artifact_path_for(fmt.artifact.to_s)
29
+ return unless artifact_path
30
+
31
+ compiled = @compiled[class_name]
32
+ return unless compiled
33
+
34
+ Parsanol::PARG::Lutaml.register(compiled,
35
+ format_name: fmt.format.to_sym,
36
+ artifact: artifact_path,
37
+ entry: fmt.root.to_s)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Evaluates hydrated instances against their compiled
7
+ # models (unknown attributes, enum membership, nesting).
8
+ module Validation
9
+ private
10
+
11
+ def validate_instance(instance, errors, path = 'root')
12
+ if instance.instance && Array(instance.attributes).empty?
13
+ validate_instance(instance.instance, errors, "#{path}.instance")
14
+ return
15
+ end
16
+
17
+ attrs = Array(instance.attributes)
18
+ type_attr = attrs.find { |a| a.name == 'type' }
19
+ type_name = type_attr ? type_attr.value.to_s : instance.type
20
+ klass = @compiled[demodulize(type_name)]
21
+ unless klass
22
+ errors << "#{path}: unknown type '#{type_name}'"
23
+ return
24
+ end
25
+
26
+ schema_attrs = klass.attributes.keys.map(&:to_s)
27
+ present_attrs = attrs.map(&:name)
28
+ schema_set = schema_attrs.to_set
29
+ present_set = present_attrs.to_set
30
+
31
+ unknown = present_set - schema_set - Set.new(%w[type])
32
+ unknown.each do |name|
33
+ errors << "#{path}.#{name}: attribute not defined on #{type_name}"
34
+ end
35
+
36
+ required = klass.attributes.values
37
+ .reject { |v| v.collection? || v.options.key?(:default) }
38
+ .to_set { |v| v.name.to_s }
39
+
40
+ missing = required - present_set
41
+ missing.each do |name|
42
+ errors << "#{path}.#{name}: required attribute missing (cardinality min >= 1)"
43
+ end
44
+
45
+ attrs.each do |attr|
46
+ next unless schema_attrs.include?(attr.name)
47
+
48
+ attr_def = klass.attributes[attr.name.to_sym]
49
+ next unless attr_def
50
+
51
+ next unless attr_def.collection? && attr.instances.any?
52
+
53
+ attr.instances.each_with_index do |nested, i|
54
+ validate_instance(nested, errors, "#{path}.#{attr.name}[#{i}]")
55
+ end
56
+ end
57
+
58
+ return unless instance.instance
59
+
60
+ validate_instance(instance.instance, errors, "#{path}.instance")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end