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.
- checksums.yaml +4 -4
- data/lib/odin/export.rb +1 -1
- data/lib/odin/forms/accessibility.rb +95 -0
- data/lib/odin/forms/css.rb +42 -0
- data/lib/odin/forms/parser.rb +719 -0
- data/lib/odin/forms/renderer.rb +534 -0
- data/lib/odin/forms/types.rb +102 -0
- data/lib/odin/forms/units.rb +41 -0
- data/lib/odin/forms.rb +55 -0
- data/lib/odin/parsing/parser.rb +25 -1
- data/lib/odin/parsing/tokenizer.rb +38 -20
- data/lib/odin/parsing/value_parser.rb +65 -7
- data/lib/odin/resolver/import_resolver.rb +40 -12
- data/lib/odin/resolver/type_registry.rb +54 -0
- data/lib/odin/transform/format_exporters.rb +88 -48
- data/lib/odin/transform/source_parsers.rb +2 -2
- data/lib/odin/transform/transform_engine.rb +1388 -246
- data/lib/odin/transform/transform_expr.rb +222 -0
- data/lib/odin/transform/transform_parser.rb +377 -19
- data/lib/odin/transform/transform_types.rb +23 -7
- data/lib/odin/transform/verb_context.rb +19 -1
- data/lib/odin/transform/verbs/aggregation_verbs.rb +2 -1
- data/lib/odin/transform/verbs/collection_verbs.rb +164 -89
- data/lib/odin/transform/verbs/datetime_verbs.rb +86 -15
- data/lib/odin/transform/verbs/extra_verbs.rb +613 -0
- data/lib/odin/transform/verbs/financial_verbs.rb +116 -27
- data/lib/odin/transform/verbs/geo_verbs.rb +7 -0
- data/lib/odin/transform/verbs/numeric_verbs.rb +85 -64
- data/lib/odin/transform/verbs/object_verbs.rb +31 -26
- data/lib/odin/types/errors.rb +9 -1
- data/lib/odin/types/schema.rb +20 -3
- data/lib/odin/utils/format_utils.rb +31 -15
- data/lib/odin/validation/format_validators.rb +7 -9
- data/lib/odin/validation/invariant_evaluator.rb +410 -0
- data/lib/odin/validation/schema_definition_validator.rb +357 -0
- data/lib/odin/validation/schema_parser.rb +234 -21
- data/lib/odin/validation/validator.rb +281 -123
- data/lib/odin/version.rb +1 -1
- data/lib/odin.rb +100 -4
- 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
|
-
|
|
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
|
|
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 =
|
|
679
|
+
discriminator_value = inline_discriminator_value
|
|
526
680
|
when_condition = nil
|
|
527
|
-
each_source =
|
|
528
|
-
|
|
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 =
|
|
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"
|
|
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
|
-
|
|
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
|
-
# (
|
|
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
|
-
|
|
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)
|