odin-foundation 1.0.4 → 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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/odin/export.rb +1 -1
  3. data/lib/odin/forms/accessibility.rb +95 -0
  4. data/lib/odin/forms/css.rb +42 -0
  5. data/lib/odin/forms/parser.rb +719 -0
  6. data/lib/odin/forms/renderer.rb +534 -0
  7. data/lib/odin/forms/types.rb +102 -0
  8. data/lib/odin/forms/units.rb +41 -0
  9. data/lib/odin/forms.rb +55 -0
  10. data/lib/odin/parsing/parser.rb +25 -1
  11. data/lib/odin/parsing/tokenizer.rb +38 -20
  12. data/lib/odin/parsing/value_parser.rb +65 -7
  13. data/lib/odin/resolver/import_resolver.rb +40 -12
  14. data/lib/odin/resolver/type_registry.rb +54 -0
  15. data/lib/odin/transform/format_exporters.rb +88 -48
  16. data/lib/odin/transform/source_parsers.rb +2 -2
  17. data/lib/odin/transform/transform_engine.rb +1388 -246
  18. data/lib/odin/transform/transform_expr.rb +222 -0
  19. data/lib/odin/transform/transform_parser.rb +377 -19
  20. data/lib/odin/transform/transform_types.rb +23 -7
  21. data/lib/odin/transform/verb_context.rb +19 -1
  22. data/lib/odin/transform/verbs/aggregation_verbs.rb +2 -1
  23. data/lib/odin/transform/verbs/collection_verbs.rb +164 -89
  24. data/lib/odin/transform/verbs/datetime_verbs.rb +86 -15
  25. data/lib/odin/transform/verbs/extra_verbs.rb +613 -0
  26. data/lib/odin/transform/verbs/financial_verbs.rb +116 -27
  27. data/lib/odin/transform/verbs/geo_verbs.rb +7 -0
  28. data/lib/odin/transform/verbs/numeric_verbs.rb +85 -64
  29. data/lib/odin/transform/verbs/object_verbs.rb +31 -26
  30. data/lib/odin/types/errors.rb +9 -1
  31. data/lib/odin/types/schema.rb +20 -3
  32. data/lib/odin/utils/format_utils.rb +31 -15
  33. data/lib/odin/validation/format_validators.rb +7 -9
  34. data/lib/odin/validation/invariant_evaluator.rb +410 -0
  35. data/lib/odin/validation/schema_definition_validator.rb +357 -0
  36. data/lib/odin/validation/schema_parser.rb +234 -21
  37. data/lib/odin/validation/validator.rb +281 -123
  38. data/lib/odin/version.rb +1 -1
  39. data/lib/odin.rb +100 -4
  40. metadata +14 -2
data/lib/odin/forms.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "forms/types"
4
+ require_relative "forms/units"
5
+ require_relative "forms/accessibility"
6
+ require_relative "forms/css"
7
+ require_relative "forms/parser"
8
+ require_relative "forms/renderer"
9
+
10
+ module Odin
11
+ # ODIN Forms — parse and render declarative form definitions.
12
+ module Forms
13
+ class << self
14
+ # Parse ODIN forms text into a typed OdinForm.
15
+ def parse_form(text)
16
+ text = text.encode("UTF-8") if text.is_a?(String) && text.encoding != Encoding::UTF_8
17
+ Parser.new.parse(text)
18
+ end
19
+
20
+ # Render an OdinForm to a complete HTML string. +data+ is an optional
21
+ # OdinDocument bound to field values; +options+ accepts :className.
22
+ def render_form(form, data = nil, options = nil)
23
+ Renderer.new.render(form, data, options)
24
+ end
25
+
26
+ def generate_form_css
27
+ Css.generate_form_css
28
+ end
29
+
30
+ def generate_print_css
31
+ Css.generate_print_css
32
+ end
33
+
34
+ def to_pixels(value, unit)
35
+ Units.to_pixels(value, unit)
36
+ end
37
+
38
+ def from_pixels(px, unit)
39
+ Units.from_pixels(px, unit)
40
+ end
41
+
42
+ def generate_field_id(element_name, page_index)
43
+ Accessibility.generate_field_id(element_name, page_index)
44
+ end
45
+
46
+ def contrast_ratio(fg, bg)
47
+ Accessibility.contrast_ratio(fg, bg)
48
+ end
49
+
50
+ def meets_contrast_aa(fg, bg, font_size)
51
+ Accessibility.meets_contrast_aa(fg, bg, font_size)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -32,6 +32,7 @@ module Odin
32
32
  @tabular_columns = []
33
33
  @tabular_array_path = ""
34
34
  @tabular_row_index = 0
35
+ @pre_tabular_context = nil
35
36
 
36
37
  # Document chaining
37
38
  @documents = []
@@ -51,7 +52,7 @@ module Odin
51
52
  break
52
53
  when TokenType::NEWLINE
53
54
  @pos += 1
54
- # Blank line after {$} metadata exits metadata mode (Java parity)
55
+ # Blank line after {$} metadata exits metadata mode
55
56
  if @metadata_mode && @context.empty?
56
57
  nt = @tokens[@pos]
57
58
  if nt && nt.type == TokenType::NEWLINE
@@ -279,6 +280,10 @@ module Odin
279
280
  end
280
281
 
281
282
  def setup_tabular(array_path, columns_str, token)
283
+ # Section context to restore when the tabular block ends and a top-level
284
+ # assignment follows.
285
+ @pre_tabular_context = @context
286
+
282
287
  # Relative paths (starting with .) resolve relative to previous context
283
288
  # Absolute paths are used as-is (same logic as resolve_header_path)
284
289
  resolved_path = if array_path.start_with?(".")
@@ -334,6 +339,9 @@ module Odin
334
339
 
335
340
  def exit_tabular_mode!
336
341
  return unless @tabular_mode
342
+ # Restore the pre-tabular section context once data rows have been read, so
343
+ # a following top-level assignment is not nested under the array path.
344
+ @context = @pre_tabular_context if @tabular_row_index.positive? && !@pre_tabular_context.nil?
337
345
  @tabular_mode = false
338
346
  @tabular_primitive = false
339
347
  @tabular_columns = []
@@ -393,6 +401,22 @@ module Odin
393
401
  # Track array indices
394
402
  track_array_index(full_path, path_token)
395
403
 
404
+ # Top-level metadata assignment ($.key = value), e.g. from canonical output
405
+ if !@metadata_mode && (full_path.start_with?("$.") || full_path == "$")
406
+ meta_key = full_path.start_with?("$.") ? full_path[2..] : ""
407
+ unless meta_key.empty?
408
+ if @current_metadata.key?(meta_key)
409
+ raise Errors::ParseError.new(
410
+ Errors::ParseErrorCode::DUPLICATE_PATH_ASSIGNMENT,
411
+ path_token.line, path_token.column,
412
+ "Duplicate metadata key: #{meta_key}"
413
+ )
414
+ end
415
+ @current_metadata[meta_key] = value
416
+ end
417
+ return
418
+ end
419
+
396
420
  if @metadata_mode
397
421
  # Check duplicate in metadata
398
422
  if @current_metadata.key?(full_path)
@@ -196,6 +196,8 @@ module Odin
196
196
  scan_identifier(line, col)
197
197
  when 38 # &
198
198
  scan_identifier(line, col)
199
+ when 36 # $
200
+ scan_meta_path(line, col)
199
201
  when 91 # [
200
202
  scan_array_indexed_path(line, col)
201
203
  else
@@ -470,43 +472,33 @@ module Odin
470
472
  emit(TokenType::ERROR, "Unterminated string", line, col)
471
473
  end
472
474
 
475
+ # Triple-quoted string. Content is captured verbatim (no escape processing,
476
+ # no newline trimming) and may span newlines; closes at the next """.
473
477
  def scan_multiline_string(line, col)
474
478
  s = @scanner
475
- # Skip initial newline after opening """
476
- if !s.eos?
477
- byte = s.string.getbyte(s.pos)
478
- if byte == 10
479
- s.pos += 1; @line += 1; @col = 1
480
- elsif byte == 13
481
- s.pos += 1; @line += 1; @col = 1
482
- if !s.eos? && s.string.getbyte(s.pos) == 10
483
- s.pos += 1
484
- end
485
- end
486
- end
487
-
488
479
  result = +""
489
480
  until s.eos?
490
481
  # Check for closing """
491
482
  if s.string.getbyte(s.pos) == 34 &&
492
- s.pos + 2 < s.string.bytesize &&
493
483
  s.string.getbyte(s.pos + 1) == 34 &&
494
484
  s.string.getbyte(s.pos + 2) == 34
495
485
  s.pos += 3; @col += 3
496
- emit(TokenType::STRING, result, line, col)
486
+ emit(TokenType::STRING, result, line, col, raw: "multiline")
497
487
  return
498
488
  end
499
489
 
500
490
  byte = s.string.getbyte(s.pos)
501
- if byte == 13 # \r
491
+ if byte == 10 # \n
502
492
  result << "\n"
503
493
  s.pos += 1; @line += 1; @col = 1
494
+ elsif byte == 13 # \r
495
+ s.pos += 1; @line += 1; @col = 1
504
496
  if !s.eos? && s.string.getbyte(s.pos) == 10
497
+ result << "\n"
505
498
  s.pos += 1
499
+ else
500
+ result << "\r"
506
501
  end
507
- elsif byte == 10 # \n
508
- result << "\n"
509
- s.pos += 1; @line += 1; @col = 1
510
502
  else
511
503
  # Scan non-special chars in bulk
512
504
  chunk = s.scan(/[^"\r\n]+/)
@@ -514,7 +506,7 @@ module Odin
514
506
  result << chunk
515
507
  @col += chunk.length
516
508
  else
517
- # Single quote that isn't part of """
509
+ # Single/double quote that isn't part of closing """
518
510
  result << s.string[s.pos]
519
511
  s.pos += 1; @col += 1
520
512
  end
@@ -571,8 +563,15 @@ module Odin
571
563
  return
572
564
  end
573
565
 
566
+ prefix = +""
567
+ if !s.eos? && s.string.getbyte(s.pos) == 36 # @$ meta reference
568
+ prefix << "$"
569
+ s.pos += 1; @col += 1
570
+ end
571
+
574
572
  path = s.scan(RE_REF_PATH) || ""
575
573
  @col += path.length
574
+ path = prefix + path
576
575
  # Normalize leading zeros in array indices: [007] -> [7]
577
576
  path = path.gsub(/\[(\d+)\]/) { "[#{$1.to_i}]" }
578
577
  emit(TokenType::REFERENCE, path, line, col)
@@ -712,6 +711,25 @@ module Odin
712
711
  emit(TokenType::PATH, word, line, col)
713
712
  end
714
713
 
714
+ # Top-level metadata path: $ or $.foo (from canonical output)
715
+ def scan_meta_path(line, col)
716
+ s = @scanner
717
+ word = +"$"
718
+ s.pos += 1; @col += 1 # skip $
719
+ loop do
720
+ if (chunk = s.scan(/[a-zA-Z0-9_.\-]+/))
721
+ word << chunk
722
+ @col += chunk.length
723
+ elsif (idx = s.scan(RE_ARRAY_INDEX))
724
+ word << idx
725
+ @col += idx.length
726
+ else
727
+ break
728
+ end
729
+ end
730
+ emit(TokenType::PATH, word, line, col)
731
+ end
732
+
715
733
  def scan_identifier(line, col)
716
734
  s = @scanner
717
735
  word = +""
@@ -64,8 +64,16 @@ module Odin
64
64
  "Invalid numeric format"
65
65
  )
66
66
  end
67
- val = Integer(Float(raw))
68
- # Beyond JS safe integer range, store raw
67
+ fval = Float(raw)
68
+ unless fval == fval.to_i
69
+ raise Errors::ParseError.new(
70
+ Errors::ParseErrorCode::INVALID_TYPE_PREFIX,
71
+ token.line, token.column,
72
+ "Integer (##) value cannot have a fractional part: #{raw}"
73
+ )
74
+ end
75
+ val = fval.to_i
76
+ # Beyond the 2^53-1 safe integer range, store raw
69
77
  safe = val.abs <= 9_007_199_254_740_991
70
78
  Types::OdinInteger.new(val, raw: safe && raw.length <= 15 ? nil : raw)
71
79
  rescue ArgumentError
@@ -170,11 +178,8 @@ module Odin
170
178
 
171
179
  def parse_timestamp(token)
172
180
  raw = token.value
173
- # Validate the date part
174
- m = RE_TIMESTAMP_DATE.match(raw)
175
- if m
176
- validate_date!("#{m[1]}-#{m[2]}-#{m[3]}", token)
177
- end
181
+ # Validate date portion, time components, and timezone offset
182
+ validate_timestamp!(raw, token)
178
183
  # DateTime.new is much faster than DateTime.parse
179
184
  # Try fast path for ISO 8601 timestamps
180
185
  dt = fast_parse_timestamp(raw) || DateTime.parse(raw)
@@ -188,6 +193,7 @@ module Odin
188
193
  end
189
194
 
190
195
  def parse_time(token)
196
+ validate_time!(token.value, token)
191
197
  Types::OdinTime.new(token.value)
192
198
  end
193
199
 
@@ -269,6 +275,58 @@ module Odin
269
275
  (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
270
276
  end
271
277
 
278
+ RE_TIMESTAMP_FULL = /\A(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?\z/.freeze
279
+ RE_TIME_FULL = /\AT(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?\z/.freeze
280
+
281
+ # Validate timestamp date portion, time components, and timezone offset.
282
+ def validate_timestamp!(ts_str, token)
283
+ m = RE_TIMESTAMP_FULL.match(ts_str)
284
+ unless m
285
+ raise_temporal_error(ts_str, token)
286
+ end
287
+ validate_date!(m[1], token)
288
+ validate_time_components!(m[2], m[3], m[4], ts_str, token)
289
+ offset = m[5]
290
+ if offset && offset != "Z"
291
+ off_hour = offset[1, 2].to_i
292
+ off_min = offset[4, 2].to_i
293
+ raise_temporal_error(ts_str, token) if off_hour > 23 || off_min > 59
294
+ end
295
+ end
296
+
297
+ # Validate time-only value: THH:MM[:SS[.sss]].
298
+ def validate_time!(time_str, token)
299
+ m = RE_TIME_FULL.match(time_str)
300
+ unless m
301
+ raise_temporal_error(time_str, token)
302
+ end
303
+ validate_time_components!(m[1], m[2], m[3], time_str, token)
304
+ end
305
+
306
+ # Validate hour/minute/second bounds. Hour 24 only as end-of-day midnight;
307
+ # second may be 60 (leap second).
308
+ def validate_time_components!(hour_str, min_str, sec_str, raw_str, token)
309
+ hour = hour_str.to_i
310
+ minute = min_str.to_i
311
+ second = sec_str.nil? ? 0 : sec_str.to_i
312
+
313
+ if hour == 24
314
+ raise_temporal_error(raw_str, token) if minute != 0 || second != 0
315
+ elsif hour > 23
316
+ raise_temporal_error(raw_str, token)
317
+ end
318
+ raise_temporal_error(raw_str, token) if minute > 59
319
+ raise_temporal_error(raw_str, token) if second > 60
320
+ end
321
+
322
+ def raise_temporal_error(raw_str, token)
323
+ raise Errors::ParseError.new(
324
+ Errors::ParseErrorCode::UNEXPECTED_CHARACTER,
325
+ token.line, token.column,
326
+ "Invalid temporal value: #{raw_str}"
327
+ )
328
+ end
329
+
272
330
  def validate_base64!(data, token)
273
331
  # Check for invalid characters
274
332
  unless data.match?(/\A[A-Za-z0-9+\/]*=*\z/)
@@ -10,7 +10,8 @@ module Odin
10
10
 
11
11
  def initialize(loader: nil)
12
12
  @loader = loader || method(:default_loader)
13
- @resolved_paths = Set.new
13
+ @active_chain = Set.new # paths currently being resolved (true-cycle detection)
14
+ @schema_cache = {} # parsed schemas keyed by absolute path (diamond reuse)
14
15
  @total_loaded = 0
15
16
  end
16
17
 
@@ -26,6 +27,26 @@ module Odin
26
27
  flatten(schema, imported_schemas)
27
28
  end
28
29
 
30
+ # Resolve imports and return [flattened_schema, type_registry].
31
+ # base_path is the schema file path; imports resolve relative to its directory.
32
+ # The registry namespaces imported types by alias for @alias.typename lookup.
33
+ def resolve_with_registry(schema, base_path: ".")
34
+ import_dir = File.directory?(base_path) ? base_path : File.dirname(base_path)
35
+ imported_schemas = []
36
+ schema.imports.each do |imp|
37
+ resolve_import(imp, import_dir, imported_schemas, depth: 0)
38
+ end
39
+
40
+ registry = TypeRegistry.new
41
+ imported_schemas.each do |entry|
42
+ registry.register_all(entry[:schema].types, entry[:alias_name])
43
+ end
44
+ registry.register_all(schema.types, nil)
45
+
46
+ flattened = schema.imports.empty? ? schema : flatten(schema, imported_schemas)
47
+ [flattened, registry]
48
+ end
49
+
29
50
  private
30
51
 
31
52
  def resolve_import(imp, base_path, collected, depth:)
@@ -45,25 +66,32 @@ module Odin
45
66
 
46
67
  abs_path = resolve_path(base_path, imp.path)
47
68
 
48
- if @resolved_paths.include?(abs_path)
69
+ # True cycle: path is on the current import chain.
70
+ if @active_chain.include?(abs_path)
49
71
  raise Errors::OdinError.new(
50
72
  Errors::ValidationErrorCode::CIRCULAR_REFERENCE,
51
73
  "Circular import detected: #{abs_path}"
52
74
  )
53
75
  end
54
76
 
55
- @resolved_paths.add(abs_path)
56
- @total_loaded += 1
57
-
58
- text = @loader.call(abs_path)
59
- imported_schema = Validation::SchemaParser.new.parse_schema(text)
77
+ imported_schema = @schema_cache[abs_path]
78
+ unless imported_schema
79
+ @total_loaded += 1
80
+ text = @loader.call(abs_path)
81
+ imported_schema = Validation::SchemaParser.new.parse_schema(text)
82
+ @schema_cache[abs_path] = imported_schema
83
+ end
60
84
 
61
- # Recursively resolve nested imports
62
- unless imported_schema.imports.empty?
63
- import_dir = File.dirname(abs_path)
64
- imported_schema.imports.each do |nested_imp|
65
- resolve_import(nested_imp, import_dir, collected, depth: depth + 1)
85
+ @active_chain.add(abs_path)
86
+ begin
87
+ unless imported_schema.imports.empty?
88
+ import_dir = File.dirname(abs_path)
89
+ imported_schema.imports.each do |nested_imp|
90
+ resolve_import(nested_imp, import_dir, collected, depth: depth + 1)
91
+ end
66
92
  end
93
+ ensure
94
+ @active_chain.delete(abs_path)
67
95
  end
68
96
 
69
97
  collected << { schema: imported_schema, alias_name: imp.alias_name, path: abs_path }
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Resolver
5
+ # Namespaced type registry for import resolution.
6
+ class TypeRegistry
7
+ def initialize
8
+ @local_types = {}
9
+ @namespaces = {}
10
+ end
11
+
12
+ # Register all types from a schema under an optional namespace (import alias).
13
+ def register_all(types, namespace = nil)
14
+ if namespace
15
+ ns_map = (@namespaces[namespace] ||= {})
16
+ types.each { |name, type_def| ns_map[name] = type_def }
17
+ else
18
+ types.each { |name, type_def| @local_types[name] = type_def }
19
+ end
20
+ end
21
+
22
+ # Look up a type by name: local first, then dot-namespaced "alias.name",
23
+ # then any namespace for an unqualified name.
24
+ def lookup(name)
25
+ return @local_types[name] if @local_types.key?(name)
26
+
27
+ if (dot = name.index("."))
28
+ ns = name[0...dot]
29
+ type_name = name[(dot + 1)..]
30
+ ns_map = @namespaces[ns]
31
+ return ns_map[type_name] if ns_map&.key?(type_name)
32
+ end
33
+
34
+ @namespaces.each_value do |ns_map|
35
+ return ns_map[name] if ns_map.key?(name)
36
+ end
37
+ nil
38
+ end
39
+
40
+ def has?(name)
41
+ !lookup(name).nil?
42
+ end
43
+
44
+ # All type names, namespaced names rendered as "alias.name".
45
+ def all_type_names
46
+ names = @local_types.keys.dup
47
+ @namespaces.each do |ns, ns_map|
48
+ ns_map.each_key { |n| names << "#{ns}.#{n}" }
49
+ end
50
+ names
51
+ end
52
+ end
53
+ end
54
+ end
@@ -173,57 +173,89 @@ module Odin
173
173
  # ── ODIN Export ──
174
174
 
175
175
  def self.to_odin(value, header: true, modifiers: {})
176
- sb = +""
176
+ builder = Odin::Types::OdinDocumentBuilder.new
177
+ builder.set_metadata("odin", Odin::Types::OdinString.new("1.0.0")) if header
177
178
 
178
- if header
179
- sb << "{$}\n"
180
- sb << "odin = \"1.0.0\"\n"
179
+ if value.object?
180
+ flatten_odin_paths(builder, "", value, modifiers)
181
+ elsif value.array?
182
+ flatten_odin_paths(builder, "items", value, modifiers)
183
+ else
184
+ builder.set("value", dyn_to_odin_value(value))
181
185
  end
182
186
 
183
- if value.object?
184
- entries = value.value
185
- has_sections = entries.any? { |_k, v| v.object? || v.array? }
186
-
187
- if has_sections
188
- # Check if there are any top-level fields (non-section entries or leaf chains)
189
- has_top_level = entries.any? { |_k, v| !v.object? && !v.array? } ||
190
- entries.any? { |_k, v| v.object? && pure_leaf_chain?(v) }
191
-
192
- # Emit {} root section marker when header is present and there are top-level fields
193
- sb << "{}\n" if header && has_top_level
194
-
195
- # First pass: flat top-level fields and leaf chains
196
- entries.each do |key, val|
197
- if val.object?
198
- collect_leaf_paths(sb, key, val, key, modifiers)
199
- elsif !val.array?
200
- write_assignment(sb, key, val, key, modifiers)
201
- end
202
- end
187
+ Odin.stringify(builder.build, use_headers: true)
188
+ end
203
189
 
204
- # Second pass: sections and arrays
205
- last_ctx = +""
206
- entries.each do |key, val|
207
- if val.object? && !pure_leaf_chain?(val)
208
- write_section(sb, key, key, nil, val, modifiers, last_ctx_holder = [last_ctx])
209
- last_ctx = last_ctx_holder[0]
210
- elsif val.array?
211
- write_array_section(sb, key, nil, val.value, modifiers)
212
- last_ctx = ""
213
- end
214
- end
215
- else
216
- entries.each do |key, val|
217
- write_assignment(sb, key, val, key, modifiers)
218
- end
190
+ # Flatten a DynValue tree into path -> Odin value assignments on the builder.
191
+ def self.flatten_odin_paths(builder, prefix, value, modifiers)
192
+ if value.object?
193
+ value.value.each do |key, child|
194
+ path = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
195
+ flatten_odin_paths(builder, path, child, modifiers)
219
196
  end
220
197
  elsif value.array?
221
- sb << "items = #{format_odin_value(value)}\n"
198
+ value.value.each_with_index do |child, idx|
199
+ path = "#{prefix}[#{idx}]"
200
+ flatten_odin_paths(builder, path, child, modifiers)
201
+ end
222
202
  else
223
- sb << "value = #{format_odin_value(value)}\n"
203
+ builder.set(prefix, dyn_to_odin_value(value), modifiers: odin_modifiers_for(prefix, modifiers))
224
204
  end
205
+ end
225
206
 
226
- sb
207
+ # Build the document Modifiers for a path from tracked transform modifiers.
208
+ def self.odin_modifiers_for(path, modifiers)
209
+ return nil if modifiers.nil? || !modifiers.key?(path)
210
+ mods = modifiers[path]
211
+ return nil if mods.nil? || mods.empty?
212
+ Odin::Types::OdinModifiers.new(
213
+ required: mods.include?(:required) || mods.include?(Odin::Transform::FieldModifier::REQUIRED),
214
+ deprecated: mods.include?(:deprecated) || mods.include?(Odin::Transform::FieldModifier::DEPRECATED),
215
+ confidential: mods.include?(:confidential) || mods.include?(Odin::Transform::FieldModifier::CONFIDENTIAL)
216
+ )
217
+ end
218
+
219
+ # Convert a scalar DynValue into the corresponding Odin value, carrying
220
+ # ECMAScript-style numeric formatting through the raw field.
221
+ def self.dyn_to_odin_value(dv)
222
+ case dv.type
223
+ when :null then Odin::Types::OdinNull.new
224
+ when :bool then Odin::Types::OdinBoolean.new(dv.value)
225
+ when :integer then Odin::Types::OdinInteger.new(dv.value)
226
+ when :float
227
+ Odin::Types::OdinNumber.new(dv.value, raw: format_double_raw(dv.value))
228
+ when :float_raw
229
+ Odin::Types::OdinNumber.new(dv.value.to_f, raw: dv.value.to_s)
230
+ when :string then Odin::Types::OdinString.new(dv.value)
231
+ when :currency
232
+ Odin::Types::OdinCurrency.new(dv.value.to_f, currency_code: dv.currency_code,
233
+ decimal_places: dv.decimal_places || 2)
234
+ when :currency_raw
235
+ Odin::Types::OdinCurrency.new(dv.value.to_f, currency_code: dv.currency_code,
236
+ decimal_places: dv.decimal_places || 2, raw: dv.value.to_s)
237
+ when :percent
238
+ Odin::Types::OdinPercent.new(dv.value, raw: format_percent_raw(dv.value))
239
+ when :date then Odin::Types::OdinDate.new(dv.value, raw: dv.value.to_s)
240
+ when :timestamp then Odin::Types::OdinTimestamp.new(dv.value, raw: dv.value.to_s)
241
+ when :time
242
+ t = dv.value.to_s
243
+ Odin::Types::OdinTime.new(t.start_with?("T") ? t : "T#{t}")
244
+ when :duration then Odin::Types::OdinDuration.new(dv.value.to_s)
245
+ when :reference then Odin::Types::OdinReference.new(dv.value.to_s)
246
+ when :binary then Odin::Types::OdinBinary.new(dv.value.to_s)
247
+ else Odin::Types::OdinString.new(dv.value.to_s)
248
+ end
249
+ end
250
+
251
+ # ECMAScript number form: whole floats render without a fractional part.
252
+ def self.format_double_raw(v)
253
+ return v.to_i.to_s if v.finite? && v == v.to_i && v.abs < 1e15
254
+ format_double(v)
255
+ end
256
+
257
+ def self.format_percent_raw(v)
258
+ v == v.to_i.to_f && v.abs < 1e15 ? "#{v.to_i}.0" : v.to_s
227
259
  end
228
260
 
229
261
  # ── Flat KVP Export ──
@@ -314,13 +346,21 @@ module Odin
314
346
 
315
347
  # ── Private Helpers ──
316
348
 
349
+ # ECMAScript JSON.stringify form: whole-valued finite floats render as
350
+ # integers; others keep their numeric value.
351
+ def self.json_number(v)
352
+ return v unless v.is_a?(Float)
353
+ return v unless v.finite?
354
+ v == v.to_i && v.abs < 1e15 ? v.to_i : v
355
+ end
356
+
317
357
  def self.dynvalue_to_json_obj(dv)
318
358
  case dv.type
319
359
  when :null then nil
320
360
  when :bool then dv.value
321
361
  when :integer then dv.value
322
- when :float then dv.value
323
- when :float_raw then dv.value.to_f
362
+ when :float then json_number(dv.value)
363
+ when :float_raw then json_number(dv.value.to_f)
324
364
  when :string then dv.value
325
365
  when :currency
326
366
  f = dv.value.is_a?(BigDecimal) ? dv.value.to_f : dv.value.to_f
@@ -370,7 +410,7 @@ module Odin
370
410
 
371
411
  # ── ODIN formatting helpers ──
372
412
 
373
- # Section writing (matches Java OdinFormatter.writeSection)
413
+ # Section writing
374
414
  def self.write_section(sb, full_path, display_path, parent_section, val, modifiers, last_ctx, inside_relative: false)
375
415
  entries = val.value
376
416
  return unless entries.is_a?(Hash)
@@ -414,7 +454,7 @@ module Odin
414
454
  end
415
455
  end
416
456
 
417
- # Array section writing (matches Java OdinFormatter.writeArraySection)
457
+ # Array section writing
418
458
  def self.write_array_section(sb, name, parent_section, items, modifiers)
419
459
  prefix = parent_section ? "." : ""
420
460
 
@@ -529,7 +569,7 @@ module Odin
529
569
  result
530
570
  end
531
571
 
532
- # Leaf chain detection (matches Java isPureLeafChain)
572
+ # Leaf chain detection
533
573
  def self.pure_leaf_chain?(val)
534
574
  return false unless val.object?
535
575
  entries = val.value
@@ -540,7 +580,7 @@ module Odin
540
580
  true
541
581
  end
542
582
 
543
- # Collect leaf paths for flat nested objects (matches Java collectLeafPaths)
583
+ # Collect leaf paths for flat nested objects
544
584
  def self.collect_leaf_paths(sb, prefix, val, mod_path, modifiers)
545
585
  return unless pure_leaf_chain?(val)
546
586
  collect_leaf_paths_inner(sb, prefix, val, mod_path, modifiers)
@@ -336,7 +336,7 @@ module Odin
336
336
  fields["_text"] = Types::DynValue.of_string(text) unless text.empty?
337
337
  end
338
338
 
339
- # Child elements — use qualified names (with namespace prefix) to match Java behavior
339
+ # Child elements — use qualified names (with namespace prefix)
340
340
  child_counts = Hash.new(0)
341
341
  children.each { |c| child_counts[qualified_name(c)] += 1 }
342
342
 
@@ -345,7 +345,7 @@ module Odin
345
345
  name = qualified_name(child)
346
346
  child_val = parse_xml_element(child, depth + 1)
347
347
 
348
- # Elements named 'item' are always treated as arrays (matches TypeScript)
348
+ # Elements named 'item' are always treated as arrays
349
349
  if child_counts[name] > 1 || name == "item"
350
350
  child_arrays[name] ||= []
351
351
  child_arrays[name] << child_val