odin-foundation 1.0.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/lib/odin/diff/differ.rb +115 -0
  3. data/lib/odin/diff/patcher.rb +64 -0
  4. data/lib/odin/export.rb +330 -0
  5. data/lib/odin/parsing/parser.rb +1193 -0
  6. data/lib/odin/parsing/token.rb +26 -0
  7. data/lib/odin/parsing/token_type.rb +40 -0
  8. data/lib/odin/parsing/tokenizer.rb +825 -0
  9. data/lib/odin/parsing/value_parser.rb +322 -0
  10. data/lib/odin/resolver/import_resolver.rb +137 -0
  11. data/lib/odin/serialization/canonicalize.rb +112 -0
  12. data/lib/odin/serialization/stringify.rb +582 -0
  13. data/lib/odin/transform/format_exporters.rb +819 -0
  14. data/lib/odin/transform/source_parsers.rb +385 -0
  15. data/lib/odin/transform/transform_engine.rb +2837 -0
  16. data/lib/odin/transform/transform_parser.rb +979 -0
  17. data/lib/odin/transform/transform_types.rb +278 -0
  18. data/lib/odin/transform/verb_context.rb +87 -0
  19. data/lib/odin/transform/verbs/aggregation_verbs.rb +106 -0
  20. data/lib/odin/transform/verbs/collection_verbs.rb +640 -0
  21. data/lib/odin/transform/verbs/datetime_verbs.rb +602 -0
  22. data/lib/odin/transform/verbs/financial_verbs.rb +356 -0
  23. data/lib/odin/transform/verbs/geo_verbs.rb +125 -0
  24. data/lib/odin/transform/verbs/numeric_verbs.rb +434 -0
  25. data/lib/odin/transform/verbs/object_verbs.rb +123 -0
  26. data/lib/odin/types/array_item.rb +42 -0
  27. data/lib/odin/types/diff.rb +89 -0
  28. data/lib/odin/types/directive.rb +28 -0
  29. data/lib/odin/types/document.rb +92 -0
  30. data/lib/odin/types/document_builder.rb +67 -0
  31. data/lib/odin/types/dyn_value.rb +270 -0
  32. data/lib/odin/types/errors.rb +149 -0
  33. data/lib/odin/types/modifiers.rb +45 -0
  34. data/lib/odin/types/ordered_map.rb +79 -0
  35. data/lib/odin/types/schema.rb +262 -0
  36. data/lib/odin/types/value_type.rb +28 -0
  37. data/lib/odin/types/values.rb +618 -0
  38. data/lib/odin/types.rb +12 -0
  39. data/lib/odin/utils/format_utils.rb +186 -0
  40. data/lib/odin/utils/path_utils.rb +25 -0
  41. data/lib/odin/utils/security_limits.rb +17 -0
  42. data/lib/odin/validation/format_validators.rb +238 -0
  43. data/lib/odin/validation/redos_protection.rb +102 -0
  44. data/lib/odin/validation/schema_parser.rb +813 -0
  45. data/lib/odin/validation/schema_serializer.rb +262 -0
  46. data/lib/odin/validation/validator.rb +1061 -0
  47. data/lib/odin/version.rb +5 -0
  48. data/lib/odin.rb +90 -0
  49. metadata +160 -0
@@ -0,0 +1,813 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Validation
5
+ class SchemaParser
6
+ # Core schema metadata keys (always metadata, never field definitions)
7
+ SCHEMA_META_KEYS = %w[odin schema].freeze
8
+
9
+ KEYWORD_TYPES = [
10
+ ["timestamp", Types::SchemaFieldType::TIMESTAMP],
11
+ ["datetime", Types::SchemaFieldType::TIMESTAMP],
12
+ ["date", Types::SchemaFieldType::DATE],
13
+ ["time", Types::SchemaFieldType::TIME],
14
+ ["duration", Types::SchemaFieldType::DURATION],
15
+ ["string", Types::SchemaFieldType::STRING],
16
+ ["integer", Types::SchemaFieldType::INTEGER],
17
+ ["number", Types::SchemaFieldType::NUMBER],
18
+ ["boolean", Types::SchemaFieldType::BOOLEAN],
19
+ ["currency", Types::SchemaFieldType::CURRENCY],
20
+ ["percent", Types::SchemaFieldType::PERCENT],
21
+ ["binary", Types::SchemaFieldType::BINARY],
22
+ ["null", Types::SchemaFieldType::NULL],
23
+ ].freeze
24
+
25
+ def initialize
26
+ @metadata = {}
27
+ @types = {}
28
+ @fields = {}
29
+ @arrays = {}
30
+ @imports = []
31
+ @object_constraints = {}
32
+
33
+ @current_header = nil
34
+ @current_header_kind = :root # :root, :metadata, :type, :array, :object
35
+ @current_type_name = nil
36
+ @current_array_path = nil
37
+ end
38
+
39
+ # Parse an ODIN schema document text into an OdinSchema
40
+ def parse_schema(text)
41
+ lines = text.split("\n")
42
+ lines.each do |raw_line|
43
+ line = raw_line.strip
44
+
45
+ if line.empty?
46
+ # Blank line resets metadata mode to root
47
+ if @current_header_kind == :metadata
48
+ @current_header_kind = :root
49
+ @current_header = nil
50
+ end
51
+ next
52
+ end
53
+
54
+ next if line.start_with?(";")
55
+
56
+ if line.start_with?("@import ")
57
+ parse_import(line)
58
+ elsif line.start_with?("{") && line.include?("}")
59
+ parse_header(line)
60
+ elsif line.start_with?("@") && !line.include?("=")
61
+ parse_bare_type_line(line)
62
+ elsif line.start_with?(":")
63
+ parse_object_constraint(line)
64
+ elsif line.include?("=")
65
+ parse_field_definition(line)
66
+ end
67
+ end
68
+
69
+ Types::OdinSchema.new(
70
+ metadata: @metadata,
71
+ types: @types,
72
+ fields: @fields,
73
+ arrays: @arrays,
74
+ imports: @imports,
75
+ object_constraints: @object_constraints
76
+ )
77
+ end
78
+
79
+ private
80
+
81
+ def parse_import(line)
82
+ parts = line[8..].strip.split
83
+ @imports << Types::SchemaImport.new(path: parts[0]) if parts.any?
84
+ end
85
+
86
+ # Handle bare @TypeName lines (not inside {})
87
+ # Supports: @TypeName, @Extended : @Base, @TypeA & @TypeB
88
+ def parse_bare_type_line(line)
89
+ rest = line[1..].strip
90
+ @current_header_kind = :bare_type
91
+ @current_array_path = nil
92
+
93
+ # Check for inheritance: @Extended : @Base
94
+ if rest.include?(" : ")
95
+ parts = rest.split(" : ", 2)
96
+ type_name = parts[0].strip
97
+ parent_refs = parts[1].strip.split(/\s*,\s*/).map { |p| p.strip.sub(/^@/, "") }
98
+ @current_type_name = type_name
99
+ @types[type_name] ||= Types::SchemaType.new(name: type_name, parent_types: parent_refs)
100
+ elsif rest.include?(" & ")
101
+ # Intersection: @TypeA & @TypeB (this is a composed result)
102
+ # The type name is before any & — but actually the type definition is
103
+ # @SmallPositive\n= @Positive & @SmallNumber
104
+ # This is handled in field_definition when = is present
105
+ type_name = rest.strip
106
+ @current_type_name = type_name
107
+ @types[type_name] ||= Types::SchemaType.new(name: type_name)
108
+ else
109
+ type_name = rest.strip
110
+ # Handle namespace prefix &
111
+ namespace = nil
112
+ if type_name.start_with?("&")
113
+ raw = type_name[1..]
114
+ dot_idx = raw.rindex(".")
115
+ if dot_idx
116
+ namespace = raw[0...dot_idx]
117
+ end
118
+ end
119
+ @current_type_name = type_name
120
+ @types[type_name] ||= Types::SchemaType.new(name: type_name, namespace: namespace)
121
+ end
122
+
123
+ @current_header = "@#{@current_type_name}"
124
+ end
125
+
126
+ def parse_header(line)
127
+ brace_end = line.index("}")
128
+ content = line[1...brace_end].strip
129
+ after_header = line[(brace_end + 1)..].to_s.strip
130
+
131
+ if content == "$" || content == "$derivation"
132
+ @current_header = content
133
+ @current_header_kind = :metadata
134
+ @current_type_name = nil
135
+ @current_array_path = nil
136
+ elsif content.start_with?("@")
137
+ # Type definition
138
+ type_name = content[1..]
139
+ @current_header = content
140
+ @current_header_kind = :type
141
+ @current_type_name = type_name
142
+ @current_array_path = nil
143
+ @types[type_name] ||= Types::SchemaType.new(name: type_name, fields: {})
144
+ elsif content.end_with?("[]")
145
+ # Array definition
146
+ array_path = content[0...-2]
147
+ @current_header = array_path
148
+ @current_header_kind = :array
149
+ @current_type_name = nil
150
+ @current_array_path = array_path
151
+
152
+ min_items = nil
153
+ max_items = nil
154
+ unique = false
155
+
156
+ if after_header && !after_header.empty?
157
+ unique, min_items, max_items = parse_array_constraint_text(after_header)
158
+ end
159
+
160
+ @arrays[array_path] = Types::SchemaArray.new(
161
+ path: array_path,
162
+ item_fields: {},
163
+ min_items: min_items,
164
+ max_items: max_items,
165
+ unique: unique
166
+ )
167
+ else
168
+ # Regular object header
169
+ @current_header = content
170
+ @current_header_kind = :object
171
+ @current_type_name = nil
172
+ @current_array_path = nil
173
+ end
174
+ end
175
+
176
+ def parse_array_constraint_text(text)
177
+ unique = false
178
+ min_items = nil
179
+ max_items = nil
180
+
181
+ if text.include?(":unique")
182
+ unique = true
183
+ text = text.gsub(":unique", "").strip
184
+ end
185
+
186
+ bounds_match = text.match(/:\((\d*)\.\.(\d*)\)/)
187
+ if bounds_match
188
+ min_items = bounds_match[1].to_i unless bounds_match[1].empty?
189
+ max_items = bounds_match[2].to_i unless bounds_match[2].empty?
190
+ end
191
+
192
+ [unique, min_items, max_items]
193
+ end
194
+
195
+ def parse_object_constraint(line)
196
+ scope = @current_header || ""
197
+ @object_constraints[scope] ||= []
198
+
199
+ if line.start_with?(":invariant ")
200
+ expr = line[11..].strip
201
+ @object_constraints[scope] << Types::SchemaInvariant.new(expression: expr)
202
+ elsif line.start_with?(":one_of ")
203
+ fields = parse_field_list(line[8..])
204
+ @object_constraints[scope] << Types::SchemaCardinality.new(
205
+ cardinality_type: "one_of", fields: fields, min: 1
206
+ )
207
+ elsif line.start_with?(":exactly_one ")
208
+ fields = parse_field_list(line[13..])
209
+ @object_constraints[scope] << Types::SchemaCardinality.new(
210
+ cardinality_type: "exactly_one", fields: fields, min: 1, max: 1
211
+ )
212
+ elsif line.start_with?(":at_most_one ")
213
+ fields = parse_field_list(line[13..])
214
+ @object_constraints[scope] << Types::SchemaCardinality.new(
215
+ cardinality_type: "at_most_one", fields: fields, max: 1
216
+ )
217
+ elsif line.start_with?(":of ")
218
+ parse_of_constraint(line[4..], scope)
219
+ elsif line.start_with?(":(")
220
+ # Array bounds constraint: :(min..max) — applies to current array
221
+ if @current_array_path && @arrays[@current_array_path]
222
+ unique, min_items, max_items = parse_array_constraint_text(line)
223
+ old = @arrays[@current_array_path]
224
+ @arrays[@current_array_path] = Types::SchemaArray.new(
225
+ path: old.path,
226
+ item_fields: old.item_fields,
227
+ min_items: min_items || old.min_items,
228
+ max_items: max_items || old.max_items,
229
+ unique: unique || old.unique,
230
+ columns: old.columns
231
+ )
232
+ end
233
+ elsif line.start_with?(":unique")
234
+ if @current_array_path && @arrays[@current_array_path]
235
+ # Rebuild array with unique flag
236
+ old = @arrays[@current_array_path]
237
+ @arrays[@current_array_path] = Types::SchemaArray.new(
238
+ path: old.path,
239
+ item_fields: old.item_fields,
240
+ min_items: old.min_items,
241
+ max_items: old.max_items,
242
+ unique: true,
243
+ columns: old.columns
244
+ )
245
+ end
246
+ end
247
+ end
248
+
249
+ def parse_of_constraint(text, scope)
250
+ bounds_match = text.strip.match(/\A\((\d*)\.\.(\d*)\)\s*(.*)\z/)
251
+ return unless bounds_match
252
+
253
+ min_val = bounds_match[1].empty? ? nil : bounds_match[1].to_i
254
+ max_val = bounds_match[2].empty? ? nil : bounds_match[2].to_i
255
+ fields = parse_field_list(bounds_match[3])
256
+ @object_constraints[scope] << Types::SchemaCardinality.new(
257
+ cardinality_type: "of", fields: fields, min: min_val, max: max_val
258
+ )
259
+ end
260
+
261
+ def parse_field_list(text)
262
+ text.split(",").map(&:strip).reject(&:empty?)
263
+ end
264
+
265
+ # Parse type-level constraint line for bare @TypeName definitions
266
+ # e.g., = :(10..15) :pattern "..." or = @TypeA & @TypeB or = ##:(1..)
267
+ def parse_type_constraint_line(spec)
268
+ return unless @current_type_name
269
+
270
+ # Check for intersection: @TypeA & @TypeB
271
+ if spec.include?(" & ")
272
+ refs = spec.split("&").map { |p| p.strip.sub(/^@/, "") }
273
+ old = @types[@current_type_name]
274
+ @types[@current_type_name] = Types::SchemaType.new(
275
+ name: old.name, fields: old.fields, namespace: old.namespace,
276
+ intersection_types: refs, parent_types: old.parent_types
277
+ )
278
+ return
279
+ end
280
+
281
+ # Check for :deprecated directive
282
+ deprecated = false
283
+ deprecation_msg = nil
284
+ if spec.include?(":deprecated")
285
+ deprecated = true
286
+ dep_match = spec.match(/:deprecated\s+"([^"]*)"/)
287
+ if dep_match
288
+ deprecation_msg = dep_match[1]
289
+ spec = spec.sub(/:deprecated\s+"[^"]*"/, "").strip
290
+ else
291
+ spec = spec.sub(/:deprecated/, "").strip
292
+ end
293
+ end
294
+
295
+ # Parse as a field spec to extract type and constraints
296
+ old = @types[@current_type_name]
297
+ existing_constraints = old.constraints.dup
298
+
299
+ unless spec.empty?
300
+ dummy_field = parse_field_spec(@current_type_name, spec)
301
+ base = field_type_to_string(dummy_field.field_type)
302
+ new_constraints = build_constraint_hash(dummy_field.constraints, dummy_field.field_type)
303
+ existing_constraints.merge!(new_constraints)
304
+ end
305
+
306
+ @types[@current_type_name] = Types::SchemaType.new(
307
+ name: old.name, fields: old.fields, namespace: old.namespace,
308
+ base_type: old.base_type || (spec.empty? ? nil : base),
309
+ constraints: existing_constraints,
310
+ intersection_types: old.intersection_types, parent_types: old.parent_types
311
+ )
312
+ end
313
+
314
+ def field_type_to_string(ft)
315
+ case ft
316
+ when :string then "string"
317
+ when :integer then "integer"
318
+ when :number then "number"
319
+ when :boolean then "boolean"
320
+ when :currency then "currency"
321
+ when :percent then "percent"
322
+ when :date then "date"
323
+ when :timestamp then "timestamp"
324
+ when :time then "time"
325
+ when :duration then "duration"
326
+ when :reference then "reference"
327
+ when :binary then "binary"
328
+ when :null then "null"
329
+ else ft.to_s
330
+ end
331
+ end
332
+
333
+ def build_constraint_hash(constraints, field_type)
334
+ h = {}
335
+ constraints.each do |c|
336
+ case c
337
+ when Types::BoundsConstraint
338
+ if field_type == :string || field_type == Types::SchemaFieldType::STRING
339
+ if c.min == c.max && c.min
340
+ h["length"] = c.min
341
+ else
342
+ h["minLength"] = c.min if c.min
343
+ h["maxLength"] = c.max if c.max
344
+ end
345
+ else
346
+ h["min"] = c.min if c.min
347
+ h["max"] = c.max if c.max
348
+ end
349
+ when Types::PatternConstraint
350
+ h["pattern"] = c.pattern
351
+ when Types::FormatConstraint
352
+ h["format"] = c.format_name
353
+ when Types::EnumConstraint
354
+ h["enum"] = c.values
355
+ when Types::UniqueConstraint
356
+ h["unique"] = true
357
+ end
358
+ end
359
+ h
360
+ end
361
+
362
+ def parse_field_definition(line)
363
+ # Handle comment at end of line
364
+ comment_idx = find_comment(line)
365
+ line = line[0...comment_idx].rstrip if comment_idx >= 0
366
+
367
+ eq_idx = line.index("=")
368
+ return unless eq_idx
369
+
370
+ left = line[0...eq_idx].strip
371
+ right = line[(eq_idx + 1)..].strip
372
+
373
+ # Handle lines with empty left side: = constraint_spec
374
+ if left.empty?
375
+ if @current_header_kind == :bare_type && @current_type_name
376
+ # Type-level constraint: = :(10..15) :pattern "..." or = @TypeA & @TypeB
377
+ parse_type_constraint_line(right)
378
+ return
379
+ elsif right.start_with?(":")
380
+ if @current_array_path && @arrays[@current_array_path]
381
+ unique, min_items, max_items = parse_array_constraint_text(right)
382
+ old = @arrays[@current_array_path]
383
+ @arrays[@current_array_path] = Types::SchemaArray.new(
384
+ path: old.path,
385
+ item_fields: old.item_fields,
386
+ min_items: min_items || old.min_items,
387
+ max_items: max_items || old.max_items,
388
+ unique: unique || old.unique,
389
+ columns: old.columns
390
+ )
391
+ end
392
+ return
393
+ end
394
+ return
395
+ end
396
+
397
+ field_name = left
398
+ # Strip array indicator from field names
399
+ is_array_field = field_name.end_with?("[]")
400
+ field_name = field_name[0...-2] if is_array_field
401
+
402
+ # Unquote the value (Java does this before metadata check)
403
+ if right.length >= 2 && right[0] == '"' && right[-1] == '"'
404
+ right = right[1...-1]
405
+ end
406
+
407
+ # Store metadata
408
+ if @current_header_kind == :metadata
409
+ @metadata[field_name] = right
410
+ return
411
+ end
412
+
413
+ # Build full path based on context
414
+ full_path = case @current_header_kind
415
+ when :type then field_name
416
+ when :array then field_name
417
+ when :object
418
+ @current_header ? "#{@current_header}.#{field_name}" : field_name
419
+ else
420
+ field_name
421
+ end
422
+
423
+ # Parse the field spec from the right side
424
+ schema_field = parse_field_spec(full_path, right)
425
+
426
+ # Override type_ref for array fields
427
+ if is_array_field
428
+ schema_field = Types::SchemaField.new(
429
+ name: schema_field.name, field_type: schema_field.field_type,
430
+ required: schema_field.required, nullable: schema_field.nullable,
431
+ redacted: schema_field.redacted, deprecated: schema_field.deprecated,
432
+ constraints: schema_field.constraints, conditionals: schema_field.conditionals,
433
+ computed: schema_field.computed, immutable: schema_field.immutable,
434
+ type_ref: "array"
435
+ )
436
+ end
437
+
438
+ # Store the field
439
+ case @current_header_kind
440
+ when :type, :bare_type
441
+ if @current_type_name && @types[@current_type_name]
442
+ old_type = @types[@current_type_name]
443
+ new_fields = old_type.fields.dup
444
+ new_fields[field_name] = schema_field
445
+ @types[@current_type_name] = Types::SchemaType.new(
446
+ name: old_type.name,
447
+ fields: new_fields,
448
+ namespace: old_type.namespace,
449
+ composition: old_type.composition,
450
+ base_type: old_type.base_type,
451
+ constraints: old_type.constraints,
452
+ intersection_types: old_type.intersection_types,
453
+ parent_types: old_type.parent_types
454
+ )
455
+ end
456
+ when :array
457
+ if @current_array_path && @arrays[@current_array_path]
458
+ old_arr = @arrays[@current_array_path]
459
+ new_fields = old_arr.item_fields.dup
460
+ new_fields[field_name] = schema_field
461
+ @arrays[@current_array_path] = Types::SchemaArray.new(
462
+ path: old_arr.path,
463
+ item_fields: new_fields,
464
+ min_items: old_arr.min_items,
465
+ max_items: old_arr.max_items,
466
+ unique: old_arr.unique,
467
+ columns: old_arr.columns
468
+ )
469
+ end
470
+ # Don't add array item fields to root @fields — they are only in item_fields
471
+ else
472
+ @fields[full_path] = schema_field
473
+ end
474
+ end
475
+
476
+ def parse_field_spec(path, spec)
477
+ field_type = Types::SchemaFieldType::STRING
478
+ required = false
479
+ nullable = false
480
+ redacted = false
481
+ deprecated = false
482
+ computed = false
483
+ immutable = false
484
+ constraints = []
485
+ conditionals = []
486
+ type_ref = nil
487
+
488
+ return Types::SchemaField.new(
489
+ name: path, field_type: field_type, required: required,
490
+ nullable: nullable, redacted: redacted, deprecated: deprecated,
491
+ constraints: constraints, conditionals: conditionals,
492
+ computed: computed, immutable: immutable, type_ref: type_ref
493
+ ) if spec.nil? || spec.empty?
494
+
495
+ pos = 0
496
+ # Parse modifiers: ! ~ * -
497
+ while pos < spec.length
498
+ case spec[pos]
499
+ when "!" then required = true; pos += 1
500
+ when "~" then nullable = true; pos += 1
501
+ when "*" then redacted = true; pos += 1
502
+ when "-" then deprecated = true; pos += 1
503
+ else break
504
+ end
505
+ end
506
+
507
+ remaining = spec[pos..].to_s.strip
508
+
509
+ # Parse type
510
+ type_result = parse_type_spec(remaining)
511
+ field_type = type_result[0]
512
+ remaining = type_result[1]
513
+ enum_values = type_result[2] # may be nil
514
+ type_ref = type_result[3] # may be nil
515
+
516
+ # If enum values were returned, add as constraint
517
+ if enum_values
518
+ constraints << Types::EnumConstraint.new(values: enum_values)
519
+ end
520
+
521
+ # Parse constraints (append to existing, don't replace)
522
+ more_constraints, remaining = parse_constraints(remaining)
523
+ constraints.concat(more_constraints)
524
+
525
+ # Parse conditionals
526
+ conditionals, remaining = parse_conditionals(remaining)
527
+
528
+ # Parse directives
529
+ while remaining && !remaining.empty?
530
+ if remaining.start_with?(":computed")
531
+ computed = true
532
+ remaining = remaining[9..].to_s.strip
533
+ elsif remaining.start_with?(":immutable")
534
+ immutable = true
535
+ remaining = remaining[10..].to_s.strip
536
+ else
537
+ break
538
+ end
539
+ end
540
+
541
+ Types::SchemaField.new(
542
+ name: path, field_type: field_type, required: required,
543
+ nullable: nullable, redacted: redacted, deprecated: deprecated,
544
+ constraints: constraints, conditionals: conditionals,
545
+ computed: computed, immutable: immutable, type_ref: type_ref
546
+ )
547
+ end
548
+
549
+ # Returns [type, remaining, enum_values_or_nil, type_ref_or_nil]
550
+ def parse_type_spec(text)
551
+ text = text.to_s.strip
552
+ return [Types::SchemaFieldType::STRING, "", nil, nil] if text.empty?
553
+
554
+ # Check for enum: (val1, val2, ...)
555
+ if text.start_with?("(")
556
+ return parse_enum_type(text)
557
+ end
558
+
559
+ # Type prefixes
560
+ if text.start_with?("##")
561
+ return [Types::SchemaFieldType::INTEGER, text[2..].to_s.strip, nil, nil]
562
+ elsif text.start_with?("#" + "$")
563
+ rest = text[2..].to_s.strip
564
+ if rest.start_with?(".") && rest.length > 1 && rest[1]&.match?(/\d/)
565
+ return [Types::SchemaFieldType::CURRENCY, rest[2..].to_s.strip, nil, nil]
566
+ end
567
+ return [Types::SchemaFieldType::CURRENCY, rest, nil, nil]
568
+ elsif text.start_with?("#" + "%")
569
+ return [Types::SchemaFieldType::PERCENT, text[2..].to_s.strip, nil, nil]
570
+ elsif text.start_with?("#")
571
+ rest = text[1..].to_s.strip
572
+ if rest.start_with?(".") && rest.length > 1 && rest[1]&.match?(/\d/)
573
+ return [Types::SchemaFieldType::NUMBER, rest[2..].to_s.strip, nil, nil]
574
+ end
575
+ return [Types::SchemaFieldType::NUMBER, rest, nil, nil]
576
+ elsif text.start_with?("?")
577
+ return [Types::SchemaFieldType::BOOLEAN, text[1..].to_s.strip, nil, nil]
578
+ elsif text.start_with?("@")
579
+ rest = text[1..]
580
+ name = ""
581
+ i = 0
582
+ while i < rest.length && !(" \t:,)".include?(rest[i]))
583
+ name += rest[i]
584
+ i += 1
585
+ end
586
+ remaining = rest[i..].to_s.strip
587
+ ref = name.empty? ? nil : "@#{name}"
588
+ return [Types::SchemaFieldType::REFERENCE, remaining, nil, ref]
589
+ elsif text.start_with?("^")
590
+ return [Types::SchemaFieldType::BINARY, text[1..].to_s.strip, nil, nil]
591
+ elsif text.start_with?("~")
592
+ return [Types::SchemaFieldType::NULL, text[1..].to_s.strip, nil, nil]
593
+ elsif text.start_with?('"')
594
+ return [Types::SchemaFieldType::STRING, text, nil, nil]
595
+ end
596
+
597
+ # Keyword types
598
+ KEYWORD_TYPES.each do |keyword, type_val|
599
+ if text.start_with?(keyword)
600
+ after = text[keyword.length..]
601
+ if after.nil? || after.empty? || " \t:,)".include?(after[0])
602
+ return [type_val, after.to_s.strip, nil, nil]
603
+ end
604
+ end
605
+ end
606
+
607
+ # Default: string
608
+ [Types::SchemaFieldType::STRING, text, nil, nil]
609
+ end
610
+
611
+ def parse_enum_type(text)
612
+ depth = 0
613
+ close_idx = 0
614
+ text.each_char.with_index do |ch, i|
615
+ if ch == "("
616
+ depth += 1
617
+ elsif ch == ")"
618
+ depth -= 1
619
+ if depth == 0
620
+ close_idx = i
621
+ break
622
+ end
623
+ end
624
+ end
625
+
626
+ enum_content = text[1...close_idx]
627
+ values = enum_content.split(",").map { |v| v.strip.gsub(/\A["']|["']\z/, "") }
628
+ remaining = text[(close_idx + 1)..].to_s.strip
629
+ # Store as STRING type with an enum constraint - enum is handled as a constraint
630
+ [Types::SchemaFieldType::STRING, remaining, values, nil]
631
+ end
632
+
633
+ def parse_constraints(text)
634
+ constraints = []
635
+ text = text.to_s.strip
636
+
637
+ # Handle enum values returned from parse_type_spec
638
+ # (This is handled via the 3-element return from parse_enum_type)
639
+
640
+ while text.start_with?(":")
641
+ if text.start_with?(":(")
642
+ # Bounds constraint
643
+ constraint, text = parse_bounds_constraint(text[1..])
644
+ constraints << constraint if constraint
645
+ elsif text.start_with?(":/")
646
+ # Pattern constraint
647
+ constraint, text = parse_pattern_constraint(text[1..])
648
+ constraints << constraint if constraint
649
+ elsif text.start_with?(":format ")
650
+ rest = text[8..].strip
651
+ fmt_name = ""
652
+ i = 0
653
+ while i < rest.length && !" \t:".include?(rest[i])
654
+ fmt_name += rest[i]
655
+ i += 1
656
+ end
657
+ constraints << Types::FormatConstraint.new(format_name: fmt_name) unless fmt_name.empty?
658
+ text = rest[i..].to_s.strip
659
+ elsif text.start_with?(":unique")
660
+ constraints << Types::UniqueConstraint.new
661
+ text = text[7..].to_s.strip
662
+ elsif text.start_with?(":pattern ")
663
+ rest = text[9..].strip
664
+ if rest.start_with?('"')
665
+ end_idx = rest.index('"', 1)
666
+ if end_idx
667
+ pat = rest[1...end_idx]
668
+ constraints << Types::PatternConstraint.new(pattern: pat)
669
+ text = rest[(end_idx + 1)..].to_s.strip
670
+ else
671
+ text = rest
672
+ end
673
+ else
674
+ text = rest
675
+ end
676
+ elsif text.start_with?(":if ") || text.start_with?(":unless ")
677
+ break # Conditionals handled separately
678
+ elsif text.start_with?(":computed") || text.start_with?(":immutable")
679
+ break # Directives handled separately
680
+ else
681
+ break
682
+ end
683
+ end
684
+
685
+ [constraints, text]
686
+ end
687
+
688
+ def parse_bounds_constraint(text)
689
+ return [nil, text] unless text.start_with?("(")
690
+
691
+ depth = 0
692
+ close_idx = 0
693
+ text.each_char.with_index do |ch, i|
694
+ if ch == "("
695
+ depth += 1
696
+ elsif ch == ")"
697
+ depth -= 1
698
+ if depth == 0
699
+ close_idx = i
700
+ break
701
+ end
702
+ end
703
+ end
704
+
705
+ content = text[1...close_idx]
706
+ remaining = text[(close_idx + 1)..].to_s.strip
707
+
708
+ if content.include?("..")
709
+ parts = content.split("..", 2)
710
+ min_val = parts[0].strip.empty? ? nil : parse_bound_value(parts[0].strip)
711
+ max_val = parts[1].strip.empty? ? nil : parse_bound_value(parts[1].strip)
712
+ else
713
+ val = parse_bound_value(content.strip)
714
+ min_val = val
715
+ max_val = val
716
+ end
717
+
718
+ [Types::BoundsConstraint.new(min: min_val, max: max_val), remaining]
719
+ end
720
+
721
+ def parse_pattern_constraint(text)
722
+ return [nil, text] unless text.start_with?("/")
723
+
724
+ end_idx = text.index("/", 1)
725
+ return [nil, text] unless end_idx
726
+
727
+ pattern = text[1...end_idx]
728
+ remaining = text[(end_idx + 1)..].to_s.strip
729
+ [Types::PatternConstraint.new(pattern: pattern), remaining]
730
+ end
731
+
732
+ def parse_conditionals(text)
733
+ conditionals = []
734
+ text = text.to_s.strip
735
+
736
+ while !text.empty?
737
+ if text.start_with?(":if ")
738
+ cond, text = parse_single_conditional(text[4..], false)
739
+ conditionals << cond if cond
740
+ elsif text.start_with?(":unless ")
741
+ cond, text = parse_single_conditional(text[8..], true)
742
+ conditionals << cond if cond
743
+ else
744
+ break
745
+ end
746
+ end
747
+
748
+ [conditionals, text]
749
+ end
750
+
751
+ def parse_single_conditional(text, is_unless)
752
+ text = text.to_s.strip
753
+
754
+ # Try operator match first: field op value
755
+ match = text.match(/\A(\w[\w.]*)\s*(>=|<=|!=|>|<|=)\s*(\S+)(.*)\z/)
756
+ if match
757
+ field_name = match[1]
758
+ operator = match[2]
759
+ raw_value = match[3].gsub(/\A["']|["']\z/, "")
760
+ remaining = match[4].strip
761
+ else
762
+ # Shorthand boolean: :if field_name (implies field = true)
763
+ match = text.match(/\A(\w[\w.]*)(.*)\z/)
764
+ return [nil, text] unless match
765
+ field_name = match[1]
766
+ operator = "="
767
+ raw_value = "true"
768
+ remaining = match[2].strip
769
+ end
770
+
771
+ cond_field = field_name
772
+
773
+ [Types::SchemaConditional.new(
774
+ field: cond_field, operator: operator, value: raw_value, unless_cond: is_unless
775
+ ), remaining]
776
+ end
777
+
778
+ def parse_bound_value(s)
779
+ return nil if s.nil? || s.empty?
780
+ if s.match?(/\A-?\d+\z/)
781
+ s.to_i
782
+ elsif s.match?(/\A-?\d+\.\d+\z/)
783
+ s.to_f
784
+ else
785
+ s
786
+ end
787
+ end
788
+
789
+ def find_comment(line)
790
+ in_string = false
791
+ line.each_char.with_index do |ch, i|
792
+ if ch == '"' && (i == 0 || line[i - 1] != "\\")
793
+ in_string = !in_string
794
+ elsif ch == ";" && !in_string
795
+ return i
796
+ end
797
+ end
798
+ -1
799
+ end
800
+
801
+ def unquote(s)
802
+ s = s.strip
803
+ if s.length >= 2 && s[0] == '"' && s[-1] == '"'
804
+ s[1...-1]
805
+ elsif s.length >= 2 && s[0] == "'" && s[-1] == "'"
806
+ s[1...-1]
807
+ else
808
+ s
809
+ end
810
+ end
811
+ end
812
+ end
813
+ end