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
@@ -71,6 +71,20 @@ module Odin
71
71
  "has" => 2, "merge" => 2, "jsonPath" => 2,
72
72
  "assert" => 2,
73
73
  "formatPhone" => 2, "movingAvg" => 2, "businessDays" => 2,
74
+ "fromEntries" => 1, "invert" => 1, "defaults" => 2, "renameKeys" => 2,
75
+ "compactObject" => 1,
76
+ "intersection" => 2, "union" => 2, "difference" => 2,
77
+ "symmetricDifference" => 2, "countBy" => 2, "keyBy" => 2,
78
+ "explode" => 2, "window" => 2,
79
+ "base64urlEncode" => 1, "base64urlDecode" => 1, "hmac" => 3,
80
+ "parseUrl" => 1, "buildUrl" => 1, "parseQuery" => 1, "buildQuery" => 1,
81
+ "stableStringify" => 1, "canonicalHash" => 1,
82
+ "escapeHtml" => 1, "unescapeHtml" => 1, "escapeXml" => 1, "stripTags" => 1,
83
+ "template" => 2,
84
+ "gcd" => 2, "lcm" => 2, "factorial" => 1,
85
+ "expr" => 2,
86
+ "countIf" => 4, "sumIf" => 5, "avgIf" => 5,
87
+ "xnpv" => 3, "xirr" => 3,
74
88
 
75
89
  # Arity 3
76
90
  "ifElse" => 3, "between" => 3,
@@ -100,14 +114,14 @@ module Odin
100
114
  }.freeze
101
115
 
102
116
  VARIADIC_VERBS = %w[
103
- concat coalesce cond switch lookup lookupDefault minOf maxOf
117
+ concat coalesce cond switch lookup lookupDefault minOf maxOf pick omit
104
118
  ].freeze
105
119
 
106
120
  ALL_DIRECTIVES = %w[
107
121
  pos len field trim type date time timestamp boolean integer number
108
122
  currency percent binary duration reference leftPad rightPad truncate
109
123
  upper lower default decimals currencyCode required confidential
110
- deprecated attr if unless omitNull omitEmpty
124
+ deprecated attr ns if unless omitNull omitEmpty
111
125
  ].freeze
112
126
 
113
127
  class ParseError < StandardError
@@ -156,14 +170,56 @@ module Odin
156
170
  accumulator_sections = {} # section_name -> [assignments]
157
171
  table_sections = {} # table_name -> { columns: [...], rows: [...] }
158
172
  source_section_fields = {} # {$source} section fields
159
-
160
- lines.each do |line|
173
+ target_section_fields = {} # {$target} section fields
174
+ target_namespaces = {} # {$target.namespace} prefix -> URI (insertion order)
175
+ imports = [] # @import paths, in order
176
+
177
+ li = 0
178
+ while li < lines.length
179
+ line = lines[li]
180
+ li += 1
161
181
  stripped = line.strip
162
182
 
183
+ # A triple-quoted body under a :literal segment is captured verbatim,
184
+ # spanning lines until the closing """. Interior blank lines are kept.
185
+ if current_section_type == :segment && current_section &&
186
+ sections[current_section][:assignments].any? { |a| a[:key] == "_literal" } &&
187
+ sections[current_section][:assignments].none? { |a| a[:key] == "_literalBody" } &&
188
+ stripped.start_with?('"""')
189
+ body_lines = []
190
+ after = line.sub(/\A\s*"""/, "")
191
+ if after.end_with?('"""') && !after.strip.empty?
192
+ body_lines << after[0...-3]
193
+ else
194
+ body_lines << after unless after.empty?
195
+ loop do
196
+ break if li >= lines.length
197
+ l = lines[li]
198
+ li += 1
199
+ if l.strip == '"""' || l.rstrip.end_with?('"""')
200
+ trimmed = l.rstrip.sub(/"""\z/, "")
201
+ body_lines << trimmed unless trimmed.empty? && l.strip == '"""'
202
+ break
203
+ end
204
+ body_lines << l
205
+ end
206
+ end
207
+ sections[current_section][:assignments] << { key: "_literalBody", value: body_lines.join("\n") }
208
+ next
209
+ end
210
+
163
211
  # Skip empty lines and comments
164
212
  next if stripped.empty?
165
213
  next if stripped.start_with?(";")
166
214
 
215
+ # Import directive: @import <path>
216
+ if stripped.start_with?("@import")
217
+ path = stripped.sub(/\A@import\s+/, "").strip
218
+ path = unquote(path) || path
219
+ imports << path unless path.empty?
220
+ next
221
+ end
222
+
167
223
  # Section header: {SectionName}
168
224
  if stripped =~ /\A\{(.*)\}\s*\z/
169
225
  section_name = $1
@@ -178,6 +234,12 @@ module Odin
178
234
  elsif section_name == "$source"
179
235
  current_section = section_name
180
236
  current_section_type = :source
237
+ elsif section_name == "$target"
238
+ current_section = section_name
239
+ current_section_type = :target
240
+ elsif section_name == "$target.namespace"
241
+ current_section = section_name
242
+ current_section_type = :target_namespace
181
243
  elsif section_name == "$accumulator" || section_name == "$accumulators"
182
244
  current_section = section_name
183
245
  current_section_type = :accumulator
@@ -209,6 +271,19 @@ module Odin
209
271
  next
210
272
  end
211
273
 
274
+ # Bare segment-directive line (e.g. ":loop items", ":counter n", ":from path").
275
+ # Parsed like "_<name> = \"rest\"", reusing the existing directive handling.
276
+ if current_section_type == :segment && current_section &&
277
+ (bare = parse_bare_directive(stripped))
278
+ # Repeated :loop lines stack as _loop, _loop2, _loop3, … (cross-product).
279
+ if bare[:key] == "_loop"
280
+ existing = sections[current_section][:assignments].count { |a| a[:key].to_s.match?(/\A_loop\d*\z/) }
281
+ bare = { key: "_loop#{existing + 1}", value: bare[:value] } if existing.positive?
282
+ end
283
+ sections[current_section][:assignments] << bare
284
+ next
285
+ end
286
+
212
287
  # Assignment: key = value
213
288
  if stripped =~ /\A([^=]+?)\s*=\s*(.*)\z/
214
289
  key = $1.strip
@@ -216,11 +291,18 @@ module Odin
216
291
  # Strip trailing comments
217
292
  raw_value = strip_comment(raw_value)
218
293
 
294
+ # Section-as-target: {path} = expr assigns to that path's section.
295
+ key = key[1...-1].strip if key.start_with?("{") && key.end_with?("}")
296
+
219
297
  case current_section_type
220
298
  when :header
221
299
  header_fields[key] = raw_value
222
300
  when :source
223
301
  source_section_fields[key] = raw_value
302
+ when :target
303
+ target_section_fields[key] = raw_value
304
+ when :target_namespace
305
+ target_namespaces[key] = unquote(raw_value) || raw_value
224
306
  when :const
225
307
  const_sections[current_section] << { key: key, value: raw_value }
226
308
  when :accumulator
@@ -234,8 +316,8 @@ module Odin
234
316
  end
235
317
  end
236
318
 
237
- # Parse header (merge source section into source_options)
238
- header = parse_header(header_fields, source_section_fields)
319
+ # Parse header (merge source/target sections into options)
320
+ header = parse_header(header_fields, source_section_fields, target_namespaces, target_section_fields)
239
321
 
240
322
  # Parse constants from header fields and {$const} sections
241
323
  constants = parse_constants(header_fields, sections)
@@ -296,10 +378,28 @@ module Odin
296
378
  constants: constants,
297
379
  tables: tables,
298
380
  accumulators: accumulators,
299
- passes: passes
381
+ passes: passes,
382
+ imports: imports
300
383
  )
301
384
  end
302
385
 
386
+ # Segment directives that may be written as a bare ":name args" body line.
387
+ BARE_DIRECTIVES = %w[loop counter from if elif else literal].freeze
388
+
389
+ # Convert ":name rest-of-line" into a synthetic "_name = \"rest\"" assignment.
390
+ # Returns nil when the line is not a bare directive.
391
+ def parse_bare_directive(stripped)
392
+ return nil unless stripped.start_with?(":")
393
+ return nil unless stripped =~ /\A:(\w+)(?:\s+(.*))?\z/
394
+
395
+ name = $1
396
+ return nil unless BARE_DIRECTIVES.include?(name)
397
+
398
+ value = ($2 || "").strip
399
+ value = "true" if value.empty?
400
+ { key: "_#{name}", value: value }
401
+ end
402
+
303
403
  def parse_table_csv_line(line)
304
404
  # Remove trailing comment
305
405
  line = strip_comment(line)
@@ -352,7 +452,7 @@ module Odin
352
452
 
353
453
  # ── Header Parsing ──
354
454
 
355
- def parse_header(fields, source_section_fields = {})
455
+ def parse_header(fields, source_section_fields = {}, target_namespaces = {}, target_section_fields = {})
356
456
  direction = unquote(fields["direction"])
357
457
  target_format = unquote(fields["target.format"])
358
458
  odin_version = unquote(fields["odin"]) || "1.0.0"
@@ -367,7 +467,7 @@ module Odin
367
467
  else ConfidentialMode::NONE
368
468
  end
369
469
 
370
- strict_types = unquote(fields["strictTypes"]) == "true"
470
+ strict_types = unquote(fields["strictTypes"]).to_s.delete_prefix("?") == "true"
371
471
 
372
472
  source_options = {}
373
473
  target_options = {}
@@ -379,11 +479,21 @@ module Odin
379
479
  end
380
480
  end
381
481
 
482
+ # Root-level emitTypeHints is an alias for target.emitTypeHints.
483
+ if fields.key?("emitTypeHints") && !target_options.key?("emitTypeHints")
484
+ target_options["emitTypeHints"] = unquote(fields["emitTypeHints"])
485
+ end
486
+
382
487
  # Merge {$source} section fields into source_options
383
488
  source_section_fields.each do |key, val|
384
489
  source_options[key] = unquote(val) || val
385
490
  end
386
491
 
492
+ # Merge {$target} section fields into target_options
493
+ target_section_fields.each do |key, val|
494
+ target_options[key] = unquote(val) || val
495
+ end
496
+
387
497
  target_format ||= Direction.target_format(direction) if direction
388
498
 
389
499
  TransformHeader.new(
@@ -394,6 +504,7 @@ module Odin
394
504
  enforce_confidential: enforce_confidential,
395
505
  source_options: source_options,
396
506
  target_options: target_options,
507
+ target_namespaces: target_namespaces,
397
508
  strict_types: strict_types,
398
509
  id: id,
399
510
  name: name
@@ -510,6 +621,49 @@ module Odin
510
621
  array_index = nil
511
622
  is_array = false
512
623
 
624
+ inline_discriminator_value = nil
625
+ inline_if_condition = nil
626
+ inline_elif_condition = nil
627
+ inline_is_else = false
628
+ inline_loop = nil
629
+ inline_counter = nil
630
+ inline_from = nil
631
+
632
+ # Capture header-inline ":loop"/":counter"/":from"; each value runs to the
633
+ # next inline directive or the end of the header.
634
+ terminator = '(?=\s+:loop\s|\s+:counter\s|\s+:from\s|\z)'
635
+ if name =~ /\A(.+?)\s+:loop\s+(.+?)#{terminator}/
636
+ inline_loop = $2.strip
637
+ end
638
+ if name =~ /\A(.+?)\s+:counter\s+(.+?)#{terminator}/
639
+ inline_counter = $2.strip
640
+ end
641
+ if name =~ /\A(.+?)\s+:from\s+(.+?)#{terminator}/
642
+ inline_from = $2.strip
643
+ end
644
+ name = $1 if name =~ /\A(.+?)\s+:(?:loop|counter|from)\s/
645
+
646
+ # Strip inline header directive: ":type "value"" keeps the quoted value;
647
+ # ":if"/":elif" capture the unquoted expression; ":else" is a bare flag.
648
+ if name =~ /\A(.+?)\s+:type\s+"((?:[^"\\]|\\.)*)"\s*\z/
649
+ name = $1
650
+ inline_discriminator_value = unescape_string($2)
651
+ elsif name =~ /\A(.+?)\s+:(if|elif)\s+(.+?)\s*\z/
652
+ name = $1
653
+ expr = $3.strip
654
+ # A fully-quoted expression is the legacy infix form; strip the quotes.
655
+ if expr.length >= 2 && expr.start_with?('"') && expr.end_with?('"')
656
+ expr = unescape_string(expr[1...-1])
657
+ end
658
+ case $2
659
+ when "if" then inline_if_condition = expr
660
+ when "elif" then inline_elif_condition = expr
661
+ end
662
+ elsif name =~ /\A(.+?)\s+:else\s*\z/
663
+ name = $1
664
+ inline_is_else = true
665
+ end
666
+
513
667
  # Strip tabular column spec: "Items[] : col1, col2" -> "Items[]"
514
668
  name = $1 if name =~ /\A(.+\[\d*\])\s*:.*\z/
515
669
 
@@ -522,12 +676,20 @@ module Odin
522
676
 
523
677
  field_mappings = []
524
678
  discriminator = nil
525
- discriminator_value = nil
679
+ discriminator_value = inline_discriminator_value
526
680
  when_condition = nil
527
- each_source = nil
528
- if_condition = nil
681
+ each_source = inline_loop || inline_from
682
+ is_array = true if each_source
683
+ if_condition = inline_if_condition
684
+ elif_condition = inline_elif_condition
685
+ is_else = inline_is_else
529
686
  pass = nil
530
- counter_name = nil
687
+ counter_name = inline_counter
688
+ loops = []
689
+ loops << parse_loop_directive(inline_loop) if inline_loop
690
+ from_source = inline_from
691
+ is_literal = false
692
+ literal_body = nil
531
693
 
532
694
  assignments.each do |a|
533
695
  key = a[:key]
@@ -542,23 +704,51 @@ module Odin
542
704
  discriminator_value = unquote_or_raw(raw_val)
543
705
  when "_when"
544
706
  when_condition = unquote_or_raw(raw_val)
545
- when "_each", "_loop", "_from"
707
+ when "_each"
546
708
  each_source = unquote_or_raw(raw_val)
547
709
  is_array = true
710
+ when "_from"
711
+ from_source = unquote_or_raw(raw_val)
712
+ each_source = from_source
713
+ is_array = true
714
+ when /\A_loop\d*\z/
715
+ spec = parse_loop_directive(unquote_or_raw(raw_val))
716
+ loops << spec
717
+ each_source ||= spec[:source]
718
+ is_array = true
548
719
  when "_if"
549
720
  if_condition = unquote_or_raw(raw_val)
721
+ when "_elif"
722
+ elif_condition = unquote_or_raw(raw_val)
723
+ when "_else"
724
+ is_else = true
550
725
  when "_pass"
551
726
  pass = parse_int_literal(raw_val)
552
727
  when "_counter"
553
728
  counter_name = unquote_or_raw(raw_val)
729
+ when "_literal"
730
+ is_literal = true
731
+ when "_literalBody"
732
+ literal_body = raw_val
554
733
  else
555
- next if key.start_with?("_") && key != "_"
556
-
734
+ # An unrecognized `_`-prefixed field is a computation-only sink:
735
+ # it runs for side effects but is not emitted. Recognized loop
736
+ # directives are handled above; these flow through as mappings.
557
737
  mapping = parse_field_mapping(key, raw_val)
558
738
  field_mappings << mapping
559
739
  end
560
740
  end
561
741
 
742
+ # A :from directive supplies the array path for a self-referential
743
+ # (@ / empty) :loop source.
744
+ if from_source && !loops.empty?
745
+ loops.each do |spec|
746
+ src = spec[:source].to_s
747
+ spec[:source] = from_source if src.empty? || src == "@"
748
+ end
749
+ each_source = loops.first[:source]
750
+ end
751
+
562
752
  SegmentDef.new(
563
753
  name: name,
564
754
  path: section_name,
@@ -569,16 +759,38 @@ module Odin
569
759
  when_condition: when_condition,
570
760
  each_source: each_source,
571
761
  if_condition: if_condition,
762
+ elif_condition: elif_condition,
763
+ is_else: is_else,
572
764
  pass: pass,
573
765
  counter_name: counter_name,
574
- is_array: is_array
766
+ is_array: is_array,
767
+ loops: loops,
768
+ is_literal: is_literal,
769
+ literal_body: literal_body
575
770
  )
576
771
  end
577
772
 
773
+ # Split a loop directive value into its source path and optional ":as" alias.
774
+ def parse_loop_directive(value)
775
+ v = value.to_s.strip
776
+ if v.include?(":as")
777
+ parts = v.split(":as", 2).map(&:strip)
778
+ { source: parts[0], alias: (parts[1].nil? || parts[1].empty? ? nil : parts[1]) }
779
+ else
780
+ { source: v, alias: nil }
781
+ end
782
+ end
783
+
578
784
  # ── Field Mapping ──
579
785
 
580
786
  def parse_field_mapping(target_field, raw_value)
787
+ # Pull out multi-token trailing directives the expression tokenizer can't carry:
788
+ # :if/:unless -> rest-of-line comparison expression
789
+ # :object -> brace-delimited inline-object spec
790
+ raw_value, captured = extract_capturing_directives(raw_value)
791
+
581
792
  expr, directives = parse_expression_with_directives(raw_value)
793
+ directives = captured + directives
582
794
 
583
795
  modifiers = []
584
796
  remaining_directives = []
@@ -593,7 +805,7 @@ module Odin
593
805
  end
594
806
 
595
807
  # Collect extraction directives from CopyExpr into field mapping directives
596
- # (TypeScript parity: directives appear on BOTH CopyExpr and FieldMapping)
808
+ # (directives appear on BOTH CopyExpr and FieldMapping)
597
809
  collect_expr_directives(expr, remaining_directives)
598
810
 
599
811
  FieldMapping.new(
@@ -604,6 +816,75 @@ module Odin
604
816
  )
605
817
  end
606
818
 
819
+ # Strip trailing ":if"/":unless"/":object" directives whose values span multiple
820
+ # expression tokens, returning the remaining value text and the captured directives.
821
+ def extract_capturing_directives(raw_value)
822
+ return [raw_value, []] if raw_value.nil?
823
+
824
+ # Determine the directive-bearing body: unwrap a fully-quoted RHS so the
825
+ # capturing scan sees the raw ":if"/":object" tokens.
826
+ prefix = +""
827
+ suffix = +""
828
+ body = raw_value
829
+ stripped = raw_value.strip
830
+ if stripped.start_with?('"') && stripped.end_with?('"') && stripped.length >= 2
831
+ inner = unescape_string(stripped[1...-1])
832
+ if find_directive(inner, "if") || find_directive(inner, "unless") || find_directive(inner, "object")
833
+ body = inner
834
+ if inner.start_with?("@") || inner.start_with?("%")
835
+ prefix = '"'
836
+ suffix = '"'
837
+ end
838
+ end
839
+ elsif !(find_directive(body, "if") || find_directive(body, "unless") || find_directive(body, "object"))
840
+ return [raw_value, []]
841
+ end
842
+
843
+ captured = []
844
+
845
+ # :object {…} — capture the brace-delimited spec.
846
+ if (idx = find_directive(body, "object"))
847
+ brace = body.index("{", idx)
848
+ if brace
849
+ close = matching_brace(body, brace)
850
+ if close
851
+ captured << OdinDirective.new("object", body[brace..close])
852
+ body = body[0...idx].rstrip
853
+ end
854
+ end
855
+ end
856
+
857
+ # :if / :unless — capture the rest of the line as the condition expression.
858
+ %w[if unless].each do |kw|
859
+ next unless (idx = find_directive(body, kw))
860
+
861
+ cond = body[(idx + kw.length + 1)..].to_s.strip
862
+ captured << OdinDirective.new(kw, cond)
863
+ body = body[0...idx].rstrip
864
+ end
865
+
866
+ ["#{prefix}#{body}#{suffix}", captured]
867
+ end
868
+
869
+ # Index of a " :name" directive token in str, or nil.
870
+ def find_directive(str, name)
871
+ m = str.match(/(?:\A|\s):#{Regexp.escape(name)}(?=\s|\z|\{)/)
872
+ m ? m.begin(0) + (m[0].start_with?(":") ? 0 : 1) : nil
873
+ end
874
+
875
+ # Index of the brace matching the "{" at open, or nil.
876
+ def matching_brace(str, open)
877
+ depth = 0
878
+ i = open
879
+ while i < str.length
880
+ depth += 1 if str[i] == "{"
881
+ depth -= 1 if str[i] == "}"
882
+ return i if depth.zero?
883
+ i += 1
884
+ end
885
+ nil
886
+ end
887
+
607
888
  # Recursively collect extraction directives from expression tree into the field mapping
608
889
  def collect_expr_directives(expr, directives)
609
890
  case expr
@@ -739,6 +1020,9 @@ module Odin
739
1020
  parse_verb_expr(tokens)
740
1021
  elsif token.start_with?("@")
741
1022
  parse_copy_expr(tokens)
1023
+ elsif (prefixed = parse_prefixed_reference(token))
1024
+ tokens.shift
1025
+ [prefixed, tokens]
742
1026
  elsif token.start_with?(":")
743
1027
  [LiteralExpr.new(Types::DynValue.of_null), tokens]
744
1028
  else
@@ -746,6 +1030,27 @@ module Odin
746
1030
  end
747
1031
  end
748
1032
 
1033
+ PREFIX_REF_TYPES = {
1034
+ "##" => "integer",
1035
+ '#$' => "currency",
1036
+ '#%' => "percent",
1037
+ "#" => "number"
1038
+ }.freeze
1039
+
1040
+ # A typed prefix on a reference (##@.x, #$@.x, #%@.x, #@.x) copies the
1041
+ # source value and coerces it to the prefix type on output.
1042
+ def parse_prefixed_reference(token)
1043
+ ["##", '#$', '#%', "#"].each do |prefix|
1044
+ next unless token.start_with?(prefix)
1045
+ rest = token[prefix.length..]
1046
+ next unless rest.start_with?("@")
1047
+
1048
+ path = rest == "@" ? "" : rest[1..]
1049
+ return CopyExpr.new(path, directives: [OdinDirective.new("type", PREFIX_REF_TYPES[prefix])])
1050
+ end
1051
+ nil
1052
+ end
1053
+
749
1054
  def parse_verb_expr(tokens)
750
1055
  verb_token = tokens.shift
751
1056
  custom = false
@@ -757,6 +1062,11 @@ module Odin
757
1062
  verb_name = verb_token[1..]
758
1063
  end
759
1064
 
1065
+ # %expr is a parse-time macro: compile the formula string into a verb tree.
1066
+ if verb_name == "expr" && !custom
1067
+ return compile_expr_macro(tokens)
1068
+ end
1069
+
760
1070
  arity = VERB_ARITY[verb_name]
761
1071
 
762
1072
  if arity.nil?
@@ -780,6 +1090,27 @@ module Odin
780
1090
  end
781
1091
  end
782
1092
 
1093
+ # Compile a %expr formula from the remaining tokens. arg0 is a quoted
1094
+ # formula string; arg1, if present, is an @-reference bindings object.
1095
+ def compile_expr_macro(tokens)
1096
+ formula_token = tokens.shift
1097
+ unless formula_token&.match?(/\A".*"\z/m)
1098
+ raise Expr::ExprSyntaxError, "expected a quoted formula string"
1099
+ end
1100
+ formula = unescape_string(formula_token[1...-1])
1101
+
1102
+ binding_path = nil
1103
+ if !tokens.empty? && !tokens.first.start_with?(":")
1104
+ ref_token = tokens.shift
1105
+ unless ref_token.start_with?("@")
1106
+ raise Expr::ExprSyntaxError, "the bindings argument must be a reference such as @.vars"
1107
+ end
1108
+ binding_path = ref_token == "@" ? "" : ref_token[1..]
1109
+ end
1110
+
1111
+ [Expr.compile(formula, binding_path), tokens]
1112
+ end
1113
+
783
1114
  EXTRACTION_DIRECTIVES = %w[pos len field trim].freeze
784
1115
  # Directives that take a value argument (not boolean flags)
785
1116
  VALUE_DIRECTIVES = %w[pos len field].freeze
@@ -893,7 +1224,34 @@ module Odin
893
1224
  # ── Helpers ──
894
1225
 
895
1226
  def unescape_string(s)
896
- s.gsub("\\n", "\n").gsub("\\r", "\r").gsub("\\t", "\t").gsub('\\"', '"').gsub("\\\\", "\\")
1227
+ result = +""
1228
+ i = 0
1229
+ len = s.length
1230
+ while i < len
1231
+ ch = s[i]
1232
+ if ch == "\\" && i + 1 < len
1233
+ nxt = s[i + 1]
1234
+ case nxt
1235
+ when "n" then result << "\n"; i += 2
1236
+ when "r" then result << "\r"; i += 2
1237
+ when "t" then result << "\t"; i += 2
1238
+ when '"' then result << '"'; i += 2
1239
+ when "\\" then result << "\\"; i += 2
1240
+ when "$"
1241
+ # Literal dollar; keep the backslash before `${` so the
1242
+ # interpolation layer treats it as an escaped marker.
1243
+ result << (s[i + 2] == "{" ? "\\$" : "$")
1244
+ i += 2
1245
+ else
1246
+ result << ch << nxt
1247
+ i += 2
1248
+ end
1249
+ else
1250
+ result << ch
1251
+ i += 1
1252
+ end
1253
+ end
1254
+ result
897
1255
  end
898
1256
 
899
1257
  def unquote(val)