synthra 0.1.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 (302) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +4 -0
  3. data/.rubocop.yml +40 -0
  4. data/.yardopts +18 -0
  5. data/ACCESS_DOCS.md +116 -0
  6. data/ADVANCED_FEATURES_SUMMARY.md +245 -0
  7. data/CHANGELOG.md +498 -0
  8. data/CODE_OF_CONDUCT.md +122 -0
  9. data/CONTRIBUTING.md +307 -0
  10. data/DOCUMENTATION.md +204 -0
  11. data/DOCUMENTATION_GUIDE.md +194 -0
  12. data/LICENSE +22 -0
  13. data/README.md +1458 -0
  14. data/Rakefile +128 -0
  15. data/benchmark/README.md +591 -0
  16. data/benchmark/batch_generation.rb +63 -0
  17. data/benchmark/engine_comparison.rb +295 -0
  18. data/benchmark/single_record.rb +108 -0
  19. data/benchmark/streaming.rb +74 -0
  20. data/docs/ACTIVERECORD_INFERENCE.md +524 -0
  21. data/docs/API_SERVER.md +278 -0
  22. data/docs/CONFIG_FILE.md +315 -0
  23. data/docs/DATA_CONTRACTS.md +312 -0
  24. data/docs/ENHANCED_REPL.md +304 -0
  25. data/docs/FACTORY_BOT.md +271 -0
  26. data/docs/GITHUB_ACTION.md +420 -0
  27. data/docs/GRAPHQL_EXPORT.md +303 -0
  28. data/docs/GRAPHQL_FEDERATION.md +651 -0
  29. data/docs/HOW_TO_GENERATE_DOCS.md +252 -0
  30. data/docs/LSP.md +189 -0
  31. data/docs/MIGRATION_GENERATOR.md +547 -0
  32. data/docs/MOCK_SERVER.md +280 -0
  33. data/docs/NATIVE_ENGINE.md +298 -0
  34. data/docs/OPENAPI_EXPORT.md +252 -0
  35. data/docs/PERFORMANCE_MODE.md +228 -0
  36. data/docs/PERSONAS.md +547 -0
  37. data/docs/PROPERTY_TESTING.md +284 -0
  38. data/docs/PROTOBUF_EXPORT.md +276 -0
  39. data/docs/QUALITY_METRICS.md +555 -0
  40. data/docs/QUICK_REFERENCE.md +88 -0
  41. data/docs/RAILS_ENGINE.md +404 -0
  42. data/docs/RAILS_INTEGRATION.md +454 -0
  43. data/docs/README.md +175 -0
  44. data/docs/REPL_QUICK_REFERENCE.md +149 -0
  45. data/docs/RUST_INTEGRATION_GUIDE.md +1376 -0
  46. data/docs/SCENARIOS.md +576 -0
  47. data/docs/SECURITY_TESTING.md +318 -0
  48. data/docs/SNAPSHOT_TESTING.md +308 -0
  49. data/docs/STRING_TEXT_TYPES_REFERENCE.md +512 -0
  50. data/docs/TERRAFORM_EXPORT.md +447 -0
  51. data/docs/TIME_TRAVEL.md +507 -0
  52. data/docs/USAGE_GUIDE.md +980 -0
  53. data/docs/WEBHOOK_SIMULATOR.md +579 -0
  54. data/docs/advanced_features.md +183 -0
  55. data/docs/api_reference.md +153 -0
  56. data/docs/behaviors.md +138 -0
  57. data/docs/best_practices.md +191 -0
  58. data/docs/dsl_reference.md +172 -0
  59. data/docs/examples.md +162 -0
  60. data/docs/generation_modes.md +125 -0
  61. data/docs/getting_started.md +92 -0
  62. data/docs/index.html +177 -0
  63. data/docs/strategic/CLOUD_REGISTRY_ARCHITECTURE.md +268 -0
  64. data/docs/strategic/SIMD_OPTIMIZATION_STRATEGY.md +153 -0
  65. data/docs/troubleshooting.md +215 -0
  66. data/docs/type_reference.md +224 -0
  67. data/examples/hostile_mode_demo.rb +64 -0
  68. data/exe/synthra +6 -0
  69. data/ext/synthra_native/Cargo.lock +972 -0
  70. data/ext/synthra_native/Cargo.toml +46 -0
  71. data/ext/synthra_native/extconf.rb +41 -0
  72. data/ext/synthra_native/src/generator.rs +397 -0
  73. data/ext/synthra_native/src/lib.rs +229 -0
  74. data/ext/synthra_native/src/types.rs +1485 -0
  75. data/lib/generators/synthra/install_generator.rb +86 -0
  76. data/lib/generators/synthra/templates/api_response.dsl +15 -0
  77. data/lib/generators/synthra/templates/fake_data.rake +116 -0
  78. data/lib/generators/synthra/templates/synthra.yml +31 -0
  79. data/lib/generators/synthra/templates/synthra_support.rb +19 -0
  80. data/lib/generators/synthra/templates/user.dsl +11 -0
  81. data/lib/synthra/activerecord_inference.rb +394 -0
  82. data/lib/synthra/api.rb +381 -0
  83. data/lib/synthra/api_server.rb +718 -0
  84. data/lib/synthra/behaviors/applicator.rb +165 -0
  85. data/lib/synthra/behaviors/base.rb +155 -0
  86. data/lib/synthra/behaviors/close_connection.rb +60 -0
  87. data/lib/synthra/behaviors/deprecated.rb +100 -0
  88. data/lib/synthra/behaviors/failure.rb +69 -0
  89. data/lib/synthra/behaviors/latency.rb +103 -0
  90. data/lib/synthra/behaviors/partial_data.rb +83 -0
  91. data/lib/synthra/behaviors/randomize_order.rb +74 -0
  92. data/lib/synthra/behaviors/registry.rb +238 -0
  93. data/lib/synthra/behaviors/simulate_error.rb +79 -0
  94. data/lib/synthra/cli/commands/base.rb +105 -0
  95. data/lib/synthra/cli/commands/diff.rb +227 -0
  96. data/lib/synthra/cli/commands/docs.rb +67 -0
  97. data/lib/synthra/cli/commands/export.rb +150 -0
  98. data/lib/synthra/cli/commands/generate.rb +72 -0
  99. data/lib/synthra/cli/commands/import.rb +80 -0
  100. data/lib/synthra/cli/commands/lint.rb +53 -0
  101. data/lib/synthra/cli/commands/live.rb +57 -0
  102. data/lib/synthra/cli/commands/seed.rb +99 -0
  103. data/lib/synthra/cli/commands/validate.rb +28 -0
  104. data/lib/synthra/cli.rb +2471 -0
  105. data/lib/synthra/config_file.rb +129 -0
  106. data/lib/synthra/configuration.rb +281 -0
  107. data/lib/synthra/contracts_registry.rb +408 -0
  108. data/lib/synthra/database_seeder.rb +268 -0
  109. data/lib/synthra/deterministic_ids.rb +218 -0
  110. data/lib/synthra/documentation_generator.rb +414 -0
  111. data/lib/synthra/engine.rb +251 -0
  112. data/lib/synthra/errors.rb +1169 -0
  113. data/lib/synthra/export/base.rb +85 -0
  114. data/lib/synthra/export/csv.rb +101 -0
  115. data/lib/synthra/export/graphql.rb +266 -0
  116. data/lib/synthra/export/graphql_federation.rb +377 -0
  117. data/lib/synthra/export/graphviz.rb +258 -0
  118. data/lib/synthra/export/javascript.rb +327 -0
  119. data/lib/synthra/export/json_data.rb +61 -0
  120. data/lib/synthra/export/json_schema.rb +290 -0
  121. data/lib/synthra/export/openapi.rb +514 -0
  122. data/lib/synthra/export/protobuf.rb +483 -0
  123. data/lib/synthra/export/python.rb +560 -0
  124. data/lib/synthra/export/sql.rb +381 -0
  125. data/lib/synthra/export/sql_insert.rb +152 -0
  126. data/lib/synthra/export/terraform.rb +501 -0
  127. data/lib/synthra/export/type_mapping.rb +389 -0
  128. data/lib/synthra/export/typescript.rb +300 -0
  129. data/lib/synthra/export/xml_data.rb +104 -0
  130. data/lib/synthra/export/yaml_data.rb +56 -0
  131. data/lib/synthra/export.rb +404 -0
  132. data/lib/synthra/factory_bot_integration.rb +157 -0
  133. data/lib/synthra/field.rb +440 -0
  134. data/lib/synthra/functions/registry.rb +104 -0
  135. data/lib/synthra/generator/context.rb +372 -0
  136. data/lib/synthra/generator/engine.rb +336 -0
  137. data/lib/synthra/generator/faker_adapter.rb +425 -0
  138. data/lib/synthra/generator/modes.rb +444 -0
  139. data/lib/synthra/generator/resolver.rb +256 -0
  140. data/lib/synthra/generator/rng.rb +273 -0
  141. data/lib/synthra/generator/streamer.rb +63 -0
  142. data/lib/synthra/generator/uniqueness.rb +118 -0
  143. data/lib/synthra/initializer.rb +97 -0
  144. data/lib/synthra/limits.rb +279 -0
  145. data/lib/synthra/live_preview.rb +518 -0
  146. data/lib/synthra/loader_config.rb +78 -0
  147. data/lib/synthra/lsp/server.rb +689 -0
  148. data/lib/synthra/migration_generator.rb +301 -0
  149. data/lib/synthra/mixin.rb +174 -0
  150. data/lib/synthra/mock_server.rb +458 -0
  151. data/lib/synthra/native_engine.rb +304 -0
  152. data/lib/synthra/openapi_importer.rb +228 -0
  153. data/lib/synthra/output/json_formatter.rb +40 -0
  154. data/lib/synthra/output/ndjson_formatter.rb +55 -0
  155. data/lib/synthra/parser/ast.rb +1287 -0
  156. data/lib/synthra/parser/lexer.rb +1152 -0
  157. data/lib/synthra/parser/parser.rb +1664 -0
  158. data/lib/synthra/parser/tokens.rb +461 -0
  159. data/lib/synthra/performance_mode.rb +364 -0
  160. data/lib/synthra/personas.rb +397 -0
  161. data/lib/synthra/property_testing.rb +172 -0
  162. data/lib/synthra/quality_metrics.rb +405 -0
  163. data/lib/synthra/rails_test_helper.rb +248 -0
  164. data/lib/synthra/registry.rb +533 -0
  165. data/lib/synthra/relationships.rb +193 -0
  166. data/lib/synthra/repl/enhanced_repl.rb +607 -0
  167. data/lib/synthra/repl/formatter.rb +191 -0
  168. data/lib/synthra/scenarios.rb +423 -0
  169. data/lib/synthra/schema.rb +605 -0
  170. data/lib/synthra/schema_inheritance.rb +104 -0
  171. data/lib/synthra/schema_versioning.rb +212 -0
  172. data/lib/synthra/snapshot_testing.rb +199 -0
  173. data/lib/synthra/time_travel.rb +338 -0
  174. data/lib/synthra/type_definitions.rb +274 -0
  175. data/lib/synthra/types/address_location/addresses.rb +125 -0
  176. data/lib/synthra/types/address_location/airports.rb +200 -0
  177. data/lib/synthra/types/address_location/banks_hospitals.rb +207 -0
  178. data/lib/synthra/types/address_location/locations.rb +406 -0
  179. data/lib/synthra/types/base.rb +48 -0
  180. data/lib/synthra/types/commerce_products/commerce.rb +119 -0
  181. data/lib/synthra/types/commerce_products/companies.rb +71 -0
  182. data/lib/synthra/types/commerce_products/construction.rb +126 -0
  183. data/lib/synthra/types/commerce_products/products.rb +205 -0
  184. data/lib/synthra/types/core/collections.rb +513 -0
  185. data/lib/synthra/types/core/defaults.rb +76 -0
  186. data/lib/synthra/types/core/enums.rb +102 -0
  187. data/lib/synthra/types/core/identifiers.rb +445 -0
  188. data/lib/synthra/types/core/primitives.rb +586 -0
  189. data/lib/synthra/types/core/references.rb +466 -0
  190. data/lib/synthra/types/core/sequences.rb +304 -0
  191. data/lib/synthra/types/crypto/crypto.rb +162 -0
  192. data/lib/synthra/types/date_time/dates.rb +387 -0
  193. data/lib/synthra/types/finance_banking/banking.rb +424 -0
  194. data/lib/synthra/types/finance_banking/credit_cards.rb +24 -0
  195. data/lib/synthra/types/finance_banking/identifiers.rb +93 -0
  196. data/lib/synthra/types/formula.rb +184 -0
  197. data/lib/synthra/types/health_medical/medical.rb +150 -0
  198. data/lib/synthra/types/hostile_payloads.rb +122 -0
  199. data/lib/synthra/types/json_array.rb +51 -0
  200. data/lib/synthra/types/media_entertainment/media.rb +62 -0
  201. data/lib/synthra/types/naughty_string.rb +30 -0
  202. data/lib/synthra/types/personal_names/chinese.rb +41 -0
  203. data/lib/synthra/types/personal_names/identifiers.rb +187 -0
  204. data/lib/synthra/types/personal_names/names.rb +246 -0
  205. data/lib/synthra/types/personal_names/national_id.rb +89 -0
  206. data/lib/synthra/types/personal_names/titles_suffixes.rb +41 -0
  207. data/lib/synthra/types/regex.rb +248 -0
  208. data/lib/synthra/types/registry.rb +182 -0
  209. data/lib/synthra/types/repeating_element.rb +50 -0
  210. data/lib/synthra/types/scenario.rb +29 -0
  211. data/lib/synthra/types/technology_internet/apps.rb +67 -0
  212. data/lib/synthra/types/technology_internet/communication.rb +162 -0
  213. data/lib/synthra/types/technology_internet/devices.rb +80 -0
  214. data/lib/synthra/types/technology_internet/formats.rb +139 -0
  215. data/lib/synthra/types/technology_internet/networking.rb +143 -0
  216. data/lib/synthra/types/template.rb +92 -0
  217. data/lib/synthra/types/text_content/business.rb +128 -0
  218. data/lib/synthra/types/text_content/colors.rb +70 -0
  219. data/lib/synthra/types/text_content/misc.rb +237 -0
  220. data/lib/synthra/types/text_content/security.rb +130 -0
  221. data/lib/synthra/types/text_content/text_generation.rb +532 -0
  222. data/lib/synthra/types/travel/travel.rb +150 -0
  223. data/lib/synthra/utils/string_distance.rb +133 -0
  224. data/lib/synthra/validator/dsl_validator.rb +339 -0
  225. data/lib/synthra/validator/path_validator.rb +615 -0
  226. data/lib/synthra/version.rb +35 -0
  227. data/lib/synthra/webhook_simulator.rb +341 -0
  228. data/lib/synthra.rb +259 -0
  229. data/schemas/address.dsl +16 -0
  230. data/schemas/api_response.dsl +8 -0
  231. data/schemas/error_payload.dsl +9 -0
  232. data/schemas/order.dsl +10 -0
  233. data/schemas/order_item.dsl +8 -0
  234. data/schemas/payment.dsl +11 -0
  235. data/schemas/social_post.dsl +13 -0
  236. data/schemas/user.dsl +10 -0
  237. data/scripts/batch_fix_all.rb +96 -0
  238. data/scripts/delete_old_files.rb +32 -0
  239. data/scripts/fix_all_grouped_files.rb +137 -0
  240. data/scripts/fix_all_indentation.rb +48 -0
  241. data/scripts/fix_all_syntax.rb +124 -0
  242. data/scripts/fix_grouped_files.rb +184 -0
  243. data/scripts/fix_syntax_errors.rb +108 -0
  244. data/scripts/group_domain_types.rb +112 -0
  245. data/scripts/group_domain_types_fixed.rb +150 -0
  246. data/scripts/merge_domains_to_one_file.rb +68 -0
  247. data/scripts/move_existing_types.rb +130 -0
  248. data/scripts/split_grouped_types.rb +142 -0
  249. data/tech_docs/README.md +134 -0
  250. data/tech_docs/advanced/streaming.md +405 -0
  251. data/tech_docs/advanced/thread_safety.md +253 -0
  252. data/tech_docs/api/overview.md +485 -0
  253. data/tech_docs/appendices/type_chart.md +193 -0
  254. data/tech_docs/basic_concepts.md +383 -0
  255. data/tech_docs/behaviors/overview.md +346 -0
  256. data/tech_docs/dsl/complex_types.md +790 -0
  257. data/tech_docs/dsl/core_types.md +464 -0
  258. data/tech_docs/dsl/datetime_types.md +325 -0
  259. data/tech_docs/dsl/field_modifiers.md +414 -0
  260. data/tech_docs/dsl/grammar.md +431 -0
  261. data/tech_docs/dsl/schema_definition.md +399 -0
  262. data/tech_docs/export/README.md +276 -0
  263. data/tech_docs/installation.md +273 -0
  264. data/tech_docs/integration/ci_cd.md +707 -0
  265. data/tech_docs/integration/ci_cd_guide.md +579 -0
  266. data/tech_docs/integration/factory_bot.md +485 -0
  267. data/tech_docs/integration/rails.md +630 -0
  268. data/tech_docs/integration/rspec.md +449 -0
  269. data/tech_docs/modes/overview.md +350 -0
  270. data/tech_docs/performance/NATIVE_RUST_EXTENSION.md +1270 -0
  271. data/tech_docs/performance/OPTIMIZATION_GUIDE.md +901 -0
  272. data/tech_docs/quick_start.md +256 -0
  273. data/tech_docs/templates/README.md +805 -0
  274. data/tech_docs/tutorials/advanced.md +371 -0
  275. data/tech_docs/tutorials/getting_started.md +189 -0
  276. data/tech_docs/tutorials/intermediate.md +231 -0
  277. data/tech_docs/tutorials/template_gallery.md +569 -0
  278. data/tech_example/01_basic_usage.rb +238 -0
  279. data/tech_example/02_types_demo.rb +336 -0
  280. data/tech_example/04_cli_usage.md +429 -0
  281. data/tech_example/05_database_seeding.rb +283 -0
  282. data/tech_example/07_rspec_integration.rb +359 -0
  283. data/tech_example/10_custom_types.rb +387 -0
  284. data/tech_example/12_behaviors.rb +372 -0
  285. data/tech_example/13_twitter_dm_example.rb +120 -0
  286. data/tech_example/14_exact_json_structure.rb +253 -0
  287. data/tech_example/16_lsp_server.rb +56 -0
  288. data/tech_example/17_property_testing.rb +92 -0
  289. data/tech_example/18_enhanced_repl.rb +120 -0
  290. data/tech_example/19_security_fuzzing.rb +145 -0
  291. data/tech_example/NEW_FEATURES.md +169 -0
  292. data/tech_example/README.md +118 -0
  293. data/tech_example/schemas/api_response.dsl +123 -0
  294. data/tech_example/schemas/ecommerce.dsl +121 -0
  295. data/tech_example/schemas/twitter_dm.dsl +109 -0
  296. data/tech_example/schemas/user.dsl +36 -0
  297. data/vscode-extension/README.md +246 -0
  298. data/vscode-extension/language-configuration.json +31 -0
  299. data/vscode-extension/package.json +55 -0
  300. data/vscode-extension/snippets/fakedatadsl.json +198 -0
  301. data/vscode-extension/syntaxes/fakedatadsl.tmLanguage.json +128 -0
  302. metadata +478 -0
@@ -0,0 +1,1664 @@
1
+ # frozen_string_literal: true
2
+
3
+ # =============================================================================
4
+
5
+ # Synthra Parser
6
+ # =============================================================================
7
+ #
8
+ # The Parser is a recursive descent parser that converts a stream of tokens
9
+ # into an Abstract Syntax Tree (AST). It's the second stage of DSL processing
10
+ # after lexing.
11
+ #
12
+ # The parser handles:
13
+ # - Schema definitions
14
+ # - Field declarations with types and modifiers
15
+ # - Type arguments (ranges, enums, custom functions)
16
+ # - Behaviors at schema and field levels
17
+ # - Conditional fields
18
+ # - Cross-schema references
19
+ #
20
+ # Grammar (simplified):
21
+ # dsl := schema*
22
+ # schema := SCHEMA_NAME COLON NEWLINE INDENT (behavior | field)* DEDENT
23
+ # field := FIELD_NAME COLON type behavior* condition? NEWLINE
24
+ # type := TYPE arguments?
25
+ # arguments := LPAREN arg_list RPAREN
26
+ # behavior := AT_BEHAVIOR value?
27
+ # condition := IF IDENTIFIER
28
+ #
29
+ # @example Parse a DSL file
30
+ # parser = Synthra::Parser::Parser.new
31
+ # ast = parser.parse(File.read("user.dsl"))
32
+ # # => [SchemaNode(User, fields: [...])]
33
+ #
34
+ # =============================================================================
35
+
36
+
37
+ module Synthra
38
+ module Parser
39
+
40
+ # Recursive descent parser for the Synthra language
41
+ #
42
+ # Converts a token stream from the Lexer into an AST that represents
43
+ # the structure of the DSL. The AST can then be converted to Schema
44
+ # objects for data generation.
45
+ #
46
+ # @example Basic usage
47
+ # parser = Parser.new
48
+ # schemas = parser.parse(dsl_source)
49
+ # schemas.first.to_schema # => Synthra::Schema
50
+ #
51
+ # @example Handle parse errors
52
+ # begin
53
+ # schemas = parser.parse(source)
54
+ # rescue Synthra::SyntaxError => e
55
+ # puts "Syntax error at line #{e.line}: #{e.message}"
56
+ # end
57
+ #
58
+
59
+ class Parser
60
+
61
+ # Include token type constants
62
+ include Tokens
63
+
64
+ # Create a new Parser instance
65
+ #
66
+ # @return [Parser] a new parser
67
+ #
68
+
69
+ def initialize
70
+ @tokens = [] # Token array from lexer
71
+ @pos = 0 # Current position in token array
72
+ end
73
+
74
+ # Parse DSL source code into AST
75
+ #
76
+ # This is the main entry point for parsing. It tokenizes the source
77
+ # using the Lexer, then parses the tokens into schema nodes.
78
+ #
79
+ # Supports both legacy syntax (SchemaName:) and new Simulyra syntax
80
+ # (schema Name:, api METHOD /path:, scenario Name:).
81
+ #
82
+ # @param source [String] DSL source code
83
+ # @return [Array<AST::SchemaNode>, AST::ProgramNode] array of schema AST nodes (legacy)
84
+ # or a ProgramNode (new syntax)
85
+ # @raise [SyntaxError] if the source has syntax errors
86
+ #
87
+ # @example Legacy syntax
88
+ # parser = Parser.new
89
+ # schemas = parser.parse("User:\n name: text")
90
+ # schemas.first.name # => "User"
91
+ #
92
+ # @example New Simulyra syntax
93
+ # parser = Parser.new
94
+ # program = parser.parse("schema User:\n id: uuid\n\napi GET /users:\n scenario Success:\n @status 200")
95
+ # program.schemas.first.name # => "User"
96
+ #
97
+
98
+ def parse(source)
99
+ @tokens = Lexer.new(source).tokenize
100
+ @pos = 0
101
+
102
+ # Detect if this is new Simulyra syntax or legacy syntax
103
+ if uses_simulyra_syntax?
104
+ parse_program
105
+ else
106
+ parse_legacy
107
+ end
108
+ end
109
+
110
+ # Check if the source uses new Simulyra syntax
111
+ #
112
+ # @return [Boolean] true if uses schema/api/scenario keywords
113
+ #
114
+
115
+ def uses_simulyra_syntax?
116
+ @tokens.any? { |t| t.type?(SCHEMA_KEYWORD) || t.type?(API_KEYWORD) || t.type?(SCENARIO_KEYWORD) }
117
+ end
118
+
119
+ # Parse legacy DSL format (SchemaName: ...)
120
+ #
121
+ # @return [Array<AST::SchemaNode>] array of schema nodes
122
+ #
123
+
124
+ def parse_legacy
125
+ schemas = []
126
+ while !eof? && !current_token.type?(EOF)
127
+ skip_newlines
128
+ # :nocov: defensive guard — the enclosing `while` already exits on EOF, so this
129
+ # break's then-arm is unreachable.
130
+ break if eof? || current_token.type?(EOF)
131
+ # :nocov:
132
+
133
+ schemas << parse_schema
134
+ skip_newlines
135
+ end
136
+ schemas
137
+ end
138
+
139
+ # Parse new Simulyra DSL format into ProgramNode
140
+ #
141
+ # @return [AST::ProgramNode] program node containing schemas and APIs
142
+ #
143
+
144
+ def parse_program
145
+ schemas = []
146
+ apis = []
147
+ legacy_schemas = []
148
+
149
+ while !eof? && !current_token.type?(EOF)
150
+ skip_newlines
151
+ # :nocov: defensive guard — the enclosing `while` already exits on EOF, so this
152
+ # break's then-arm is unreachable.
153
+ break if eof? || current_token.type?(EOF)
154
+ # :nocov:
155
+
156
+ case current_token.type
157
+ when SCHEMA_KEYWORD
158
+ schemas << parse_schema_definition
159
+ when API_KEYWORD
160
+ apis << parse_api_definition
161
+ when SCHEMA_NAME
162
+ # Legacy schema definition in mixed content
163
+ legacy_schemas << parse_schema
164
+ else
165
+ error("Expected 'schema', 'api', or schema name", current_token)
166
+ end
167
+ skip_newlines
168
+ end
169
+
170
+ AST::ProgramNode.new(
171
+ schemas: schemas,
172
+ apis: apis,
173
+ legacy_schemas: legacy_schemas,
174
+ line: 1
175
+ )
176
+ end
177
+
178
+ private
179
+
180
+ # ==========================================================================
181
+
182
+ # Token Access Helpers
183
+ # ==========================================================================
184
+
185
+ # Get the current token
186
+ #
187
+ # @return [Token] current token or EOF token if at end
188
+ #
189
+
190
+ def current_token
191
+ @tokens[@pos] || Token.new(EOF, nil, line: 0, column: 0)
192
+ end
193
+
194
+ # Peek at a token ahead of current position
195
+ #
196
+ # @param offset [Integer] how many tokens ahead (default: 1)
197
+ # @return [Token] token at offset or EOF token
198
+ #
199
+
200
+ def peek_token(offset = 1)
201
+ @tokens[@pos + offset] || Token.new(EOF, nil, line: 0, column: 0)
202
+ end
203
+
204
+ # Advance to the next token
205
+ #
206
+ # @return [Token] the token that was consumed
207
+ #
208
+
209
+ def advance
210
+ token = current_token
211
+ @pos += 1
212
+ token
213
+ end
214
+
215
+ # Check if we've reached the end of tokens
216
+ #
217
+ # @return [Boolean] true if at or past end of token array
218
+ #
219
+
220
+ def eof?
221
+ @pos >= @tokens.length
222
+ end
223
+
224
+ # Expect and consume a specific token type
225
+ #
226
+ # @param type [Symbol] expected token type
227
+ # @return [Token] the consumed token
228
+ # @raise [SyntaxError] if current token doesn't match
229
+ #
230
+
231
+ def expect(type)
232
+ token = current_token
233
+ unless token.type?(type)
234
+ error("Expected #{type}, got #{token.type}", token)
235
+ end
236
+ advance
237
+ end
238
+
239
+ # Raise a syntax error
240
+ #
241
+ # @param message [String] error message
242
+ # @param token [Token] token where error occurred
243
+ # @raise [SyntaxError] always raises
244
+ #
245
+
246
+ def error(message, token = current_token)
247
+ raise SyntaxError.new(
248
+ message,
249
+ line: token.line,
250
+ column: token.column
251
+ )
252
+ end
253
+
254
+ # Skip any newline tokens
255
+ #
256
+ # @return [void]
257
+ #
258
+
259
+ def skip_newlines
260
+ advance while current_token.type?(NEWLINE)
261
+ end
262
+
263
+ # ==========================================================================
264
+
265
+ # Schema Parsing
266
+ # ==========================================================================
267
+
268
+ # Parse a schema definition
269
+ #
270
+ # Grammar: SCHEMA_NAME COLON NEWLINE INDENT (behavior | field)* DEDENT
271
+ #
272
+ # @return [AST::SchemaNode] the schema AST node
273
+ #
274
+
275
+ def parse_schema
276
+ token = expect(SCHEMA_NAME)
277
+ name = token.value
278
+ expect(COLON)
279
+ skip_newlines
280
+
281
+ fields = []
282
+ behaviors = []
283
+
284
+ # Parse indented block of fields and behaviors
285
+ if current_token.type?(INDENT)
286
+ advance
287
+ skip_newlines
288
+
289
+ while !eof? && !current_token.type?(DEDENT) && !current_token.type?(EOF)
290
+ skip_newlines
291
+ # :nocov: defensive guard — the enclosing `while` already exits on DEDENT/EOF,
292
+ # so this break's then-arm is unreachable.
293
+ break if current_token.type?(DEDENT) || current_token.type?(EOF)
294
+ # :nocov:
295
+
296
+ if current_token.type?(AT_BEHAVIOR)
297
+ behaviors << parse_behavior
298
+ elsif current_token.type?(FIELD_NAME)
299
+ fields << parse_field
300
+ else
301
+ break
302
+ end
303
+ skip_newlines
304
+ end
305
+
306
+ advance if current_token.type?(DEDENT)
307
+ end
308
+
309
+ AST::SchemaNode.new(
310
+ name: name,
311
+ fields: fields,
312
+ behaviors: behaviors,
313
+ line: token.line
314
+ )
315
+ end
316
+
317
+ # ==========================================================================
318
+
319
+ # Simulyra DSL - New Syntax Parsing
320
+ # ==========================================================================
321
+
322
+ # Parse a schema definition with new syntax: schema Name:
323
+ #
324
+ # Grammar: SCHEMA_KEYWORD SCHEMA_NAME COLON NEWLINE INDENT field* DEDENT
325
+ #
326
+ # @return [AST::SchemaDefinitionNode] the schema definition node
327
+ #
328
+
329
+ def parse_schema_definition
330
+ schema_kw_token = expect(SCHEMA_KEYWORD)
331
+ name_token = expect(SCHEMA_NAME)
332
+ name = name_token.value
333
+ expect(COLON)
334
+ skip_newlines
335
+
336
+ fields = []
337
+ behaviors = []
338
+
339
+ # Parse indented block of fields and behaviors
340
+ if current_token.type?(INDENT)
341
+ advance
342
+ skip_newlines
343
+
344
+ while !eof? && !current_token.type?(DEDENT) && !current_token.type?(EOF)
345
+ skip_newlines
346
+ # :nocov: defensive guard — the enclosing `while` already exits on DEDENT/EOF,
347
+ # so this break's then-arm is unreachable.
348
+ break if current_token.type?(DEDENT) || current_token.type?(EOF)
349
+ # :nocov:
350
+
351
+ if current_token.type?(AT_BEHAVIOR)
352
+ behaviors << parse_behavior
353
+ elsif current_token.type?(FIELD_NAME)
354
+ fields << parse_field
355
+ else
356
+ break
357
+ end
358
+ skip_newlines
359
+ end
360
+
361
+ advance if current_token.type?(DEDENT)
362
+ end
363
+
364
+ AST::SchemaDefinitionNode.new(
365
+ name: name,
366
+ fields: fields,
367
+ behaviors: behaviors,
368
+ line: schema_kw_token.line
369
+ )
370
+ end
371
+
372
+ # Parse an API definition: api METHOD /path:
373
+ #
374
+ # Grammar: API_KEYWORD HTTP_METHOD PATH COLON NEWLINE INDENT scenario* DEDENT
375
+ #
376
+ # @return [AST::ApiDefinitionNode] the API definition node
377
+ #
378
+
379
+ def parse_api_definition
380
+ api_kw_token = expect(API_KEYWORD)
381
+ method_token = expect(HTTP_METHOD)
382
+ http_method = method_token.value
383
+ path_token = expect(PATH)
384
+ path = path_token.value
385
+ expect(COLON)
386
+ skip_newlines
387
+
388
+ scenarios = []
389
+
390
+ # Parse indented block of scenarios
391
+ if current_token.type?(INDENT)
392
+ api_indent_level = current_token.value
393
+ advance
394
+ skip_newlines
395
+
396
+ loop do
397
+ skip_newlines
398
+ # :nocov: defensive guard — the loop exits via the non-scenario `else break`
399
+ # below before EOF is reached, so this break's then-arm is unreachable.
400
+ break if eof? || current_token.type?(EOF)
401
+ # :nocov:
402
+
403
+ # Check if we've returned to API's indent level (DEDENT)
404
+ # DEDENT token value indicates the new indent level
405
+ if current_token.type?(DEDENT)
406
+ # Only break if this is the final DEDENT for the API block
407
+ # (returning to level 0 or lower than API's content level)
408
+ # :nocov: a DEDENT inside the scenario loop always returns below the API
409
+ # content level, so the value-not-less-than else-arm is unreachable.
410
+ if current_token.value < api_indent_level
411
+ advance
412
+ break
413
+ end
414
+ # :nocov:
415
+ end
416
+
417
+ if current_token.type?(SCENARIO_KEYWORD)
418
+ scenarios << parse_scenario
419
+ else
420
+ break
421
+ end
422
+ end
423
+ end
424
+
425
+ AST::ApiDefinitionNode.new(
426
+ method: http_method,
427
+ path: path,
428
+ scenarios: scenarios,
429
+ line: api_kw_token.line
430
+ )
431
+ end
432
+
433
+ # Parse a scenario definition: scenario Name:
434
+ #
435
+ # Grammar: SCENARIO_KEYWORD IDENTIFIER COLON NEWLINE INDENT (directive | field)* DEDENT
436
+ #
437
+ # @return [AST::ScenarioNode] the scenario node
438
+ #
439
+
440
+ def parse_scenario
441
+ scenario_kw_token = expect(SCENARIO_KEYWORD)
442
+ name_token = expect(IDENTIFIER)
443
+ name = name_token.value
444
+ expect(COLON)
445
+ skip_newlines
446
+
447
+ directives = []
448
+ response_fields = []
449
+
450
+ # Parse indented block of directives and fields
451
+ if current_token.type?(INDENT)
452
+ advance
453
+ skip_newlines
454
+
455
+ while !eof? && !current_token.type?(DEDENT) && !current_token.type?(EOF)
456
+ skip_newlines
457
+ # :nocov: defensive guard — the enclosing `while` already exits on DEDENT/EOF,
458
+ # so this break's then-arm is unreachable.
459
+ break if current_token.type?(DEDENT) || current_token.type?(EOF)
460
+ # :nocov:
461
+
462
+ if current_token.type?(AT_BEHAVIOR)
463
+ directives << parse_scenario_directive
464
+ elsif current_token.type?(FIELD_NAME)
465
+ response_fields << parse_response_field
466
+ else
467
+ break
468
+ end
469
+ skip_newlines
470
+ end
471
+
472
+ advance if current_token.type?(DEDENT)
473
+ end
474
+
475
+ AST::ScenarioNode.new(
476
+ name: name,
477
+ directives: directives,
478
+ response_fields: response_fields,
479
+ line: scenario_kw_token.line
480
+ )
481
+ end
482
+
483
+ # Parse a scenario directive (@status, @probability, @header, @latency, @method)
484
+ #
485
+ # Grammar: AT_BEHAVIOR value
486
+ #
487
+ # @return [AST::DirectiveNode] the directive node
488
+ #
489
+
490
+ def parse_scenario_directive
491
+ token = expect(AT_BEHAVIOR)
492
+ directive_name = token.value.to_s
493
+
494
+ case directive_name
495
+ when "status"
496
+ status_code = parse_directive_value
497
+ AST::StatusDirective.new(status_code: status_code.to_i, line: token.line)
498
+ when "probability"
499
+ percentage = parse_directive_value
500
+ # Handle percentage format (e.g., "80" from "80%")
501
+ AST::ProbabilityDirective.new(percentage: percentage.to_i, line: token.line)
502
+ when "latency"
503
+ latency_value = parse_behavior_value
504
+ if latency_value.is_a?(Hash)
505
+ AST::LatencyDirective.new(
506
+ min_ms: latency_value[:min],
507
+ max_ms: latency_value[:max],
508
+ line: token.line
509
+ )
510
+ else
511
+ AST::LatencyDirective.new(min_ms: latency_value, max_ms: latency_value, line: token.line)
512
+ end
513
+ when "header"
514
+ key = expect(IDENTIFIER).value
515
+ expect(COLON)
516
+ value = parse_directive_string_value
517
+ AST::HeaderDirective.new(key: key, value: value, line: token.line)
518
+ when "method"
519
+ method_value = expect(IDENTIFIER).value.downcase.to_sym
520
+ AST::MethodDirective.new(http_method: method_value, line: token.line)
521
+ else
522
+ # Generic behavior - convert to behavior node for compatibility
523
+ value = parse_behavior_value
524
+ skip_newlines
525
+ AST::BehaviorNode.new(name: directive_name, value: value, line: token.line)
526
+ end
527
+ end
528
+
529
+ # Parse a directive value (number, identifier)
530
+ #
531
+ # @return [Object] the parsed value
532
+ #
533
+
534
+ def parse_directive_value
535
+ if current_token.type?(NUMBER)
536
+ value = current_token.value
537
+ advance
538
+ # Skip percentage sign if present
539
+ if current_token.type?(PERCENT)
540
+ advance
541
+ end
542
+ value
543
+ elsif current_token.type?(IDENTIFIER)
544
+ value = current_token.value
545
+ advance
546
+ value
547
+ else
548
+ nil
549
+ end
550
+ end
551
+
552
+ # Parse a directive string value (for headers)
553
+ #
554
+ # @return [String] the string value
555
+ #
556
+
557
+ def parse_directive_string_value
558
+ if current_token.type?(STRING)
559
+ value = current_token.value
560
+ advance
561
+ value
562
+ elsif current_token.type?(IDENTIFIER) || current_token.type?(TYPE)
563
+ value = current_token.value
564
+ advance
565
+ value.to_s
566
+ else
567
+ ""
568
+ end
569
+ end
570
+
571
+ # Parse a response field in a scenario
572
+ #
573
+ # Grammar: FIELD_NAME COLON type NEWLINE
574
+ #
575
+ # @return [AST::ResponseFieldNode] the response field node
576
+ #
577
+
578
+ def parse_response_field
579
+ field_token = expect(FIELD_NAME)
580
+ field_name = field_token.value.delete_suffix("?")
581
+ expect(COLON)
582
+ type_node = parse_type
583
+ skip_newlines
584
+
585
+ AST::ResponseFieldNode.new(
586
+ name: field_name,
587
+ type_node: type_node,
588
+ line: field_token.line
589
+ )
590
+ end
591
+
592
+ # ==========================================================================
593
+
594
+ # Field Parsing
595
+ # ==========================================================================
596
+
597
+ # Parse a field definition
598
+ #
599
+ # Grammar: FIELD_NAME COLON type behavior* condition? NEWLINE
600
+ #
601
+ # @return [AST::FieldNode] the field AST node
602
+ #
603
+
604
+ def parse_field
605
+ field_token = expect(FIELD_NAME)
606
+ field_name = field_token.value
607
+ optional = field_name.end_with?("?")
608
+ clean_name = field_name.delete_suffix("?")
609
+
610
+ expect(COLON)
611
+ type_node = parse_type
612
+
613
+ # Parse field-level behaviors (@partial_data, @latency, etc.)
614
+ behaviors = []
615
+ while current_token.type?(AT_BEHAVIOR)
616
+ behaviors << parse_field_behavior
617
+ end
618
+
619
+ # Parse condition (if field_name)
620
+ condition = nil
621
+ if current_token.type?(IF)
622
+ advance
623
+ cond_token = expect(IDENTIFIER)
624
+ condition = AST::ConditionNode.new(field_name: cond_token.value, line: cond_token.line)
625
+ end
626
+
627
+ skip_newlines
628
+
629
+ AST::FieldNode.new(
630
+ name: clean_name,
631
+ type_node: type_node,
632
+ optional: optional,
633
+ condition: condition,
634
+ behaviors: behaviors,
635
+ line: field_token.line
636
+ )
637
+ end
638
+
639
+ # ==========================================================================
640
+
641
+ # Type Parsing
642
+ # ==========================================================================
643
+
644
+ # Parse a type expression
645
+ #
646
+ # Handles:
647
+ # - Simple types: uuid, text, number
648
+ # - Nullable types: User?
649
+ # - Types with arguments: number(18..80), enum(a, b, c)
650
+ # - References: Ref(User.id)
651
+ # - Schema references: schema(:User), schema(:User).array
652
+ # - Arrays: array(text, 1..5)
653
+ # - Custom functions: custom(:my_function)
654
+ #
655
+ # @return [AST::TypeNode] the type AST node (or subclass)
656
+ #
657
+
658
+ def parse_type
659
+ # Handle schema(:Name) reference
660
+ if current_token.type?(SCHEMA_KEYWORD)
661
+ return parse_schema_reference
662
+ end
663
+
664
+ type_token = advance # TYPE or schema reference
665
+
666
+ type_name = type_token.value
667
+ nullable = type_name.to_s.end_with?("?")
668
+ clean_type = type_name.to_s.delete_suffix("?")
669
+
670
+ arguments = {}
671
+
672
+ # Handle Ref(Schema.field) - cross-schema reference
673
+ if type_token.type?(REF)
674
+ return parse_reference(type_token.line)
675
+ end
676
+
677
+ # Handle type arguments in parentheses
678
+ if current_token.type?(LPAREN)
679
+ advance
680
+ arguments = parse_type_arguments(clean_type)
681
+ expect(RPAREN)
682
+ end
683
+
684
+ # Check for ? suffix after arguments (e.g., text(20..100)? or User?)
685
+ # The QUESTION token comes after RPAREN when type has arguments
686
+ if current_token.type?(QUESTION)
687
+ nullable = true
688
+ advance
689
+ end
690
+
691
+ # Special handling for array type
692
+ if clean_type == "array"
693
+ return parse_array_type(arguments, type_token.line, nullable)
694
+ end
695
+
696
+ # Special handling for custom function type
697
+ if clean_type == "custom"
698
+ func_name = arguments[:function] || arguments.values.first
699
+ return AST::CustomNode.new(function_name: func_name, line: type_token.line)
700
+ end
701
+
702
+ AST::TypeNode.new(
703
+ name: clean_type,
704
+ arguments: arguments,
705
+ nullable: nullable,
706
+ line: type_token.line
707
+ )
708
+ end
709
+
710
+ # Parse a schema reference: schema(:Name) or schema(:Name).array
711
+ #
712
+ # Grammar: SCHEMA_KEYWORD LPAREN SCHEMA_REF RPAREN (DOT array_modifier)?
713
+ #
714
+ # @return [AST::SchemaReferenceNode] the schema reference node
715
+ #
716
+
717
+ def parse_schema_reference
718
+ token = expect(SCHEMA_KEYWORD)
719
+ expect(LPAREN)
720
+ schema_ref_token = expect(SCHEMA_REF)
721
+ schema_name = schema_ref_token.value
722
+ expect(RPAREN)
723
+
724
+ is_array = false
725
+ array_range = nil
726
+ is_optional = false
727
+
728
+ # Check for modifiers: .array, .array(1..10), .optional
729
+ while current_token.type?(DOT)
730
+ advance
731
+ # :nocov: the lexer always emits an IDENTIFIER token after a DOT (even for digits
732
+ # or symbols), so the else-arm of this guard is unreachable.
733
+ if current_token.type?(IDENTIFIER)
734
+ # :nocov:
735
+ modifier = current_token.value
736
+ advance
737
+
738
+ case modifier
739
+ when "array"
740
+ is_array = true
741
+ # Check for array range: .array(1..10)
742
+ if current_token.type?(LPAREN)
743
+ advance
744
+ if current_token.type?(RANGE)
745
+ range = current_token.value
746
+ array_range = range[:min]..range[:max]
747
+ advance
748
+ elsif current_token.type?(NUMBER)
749
+ num = current_token.value
750
+ array_range = num..num
751
+ advance
752
+ end
753
+ expect(RPAREN)
754
+ end
755
+ when "optional"
756
+ is_optional = true
757
+ end
758
+ end
759
+ end
760
+
761
+ # Check for ? suffix
762
+ if current_token.type?(QUESTION)
763
+ is_optional = true
764
+ advance
765
+ end
766
+
767
+ AST::SchemaReferenceNode.new(
768
+ schema_name: schema_name,
769
+ array: is_array,
770
+ array_range: array_range,
771
+ optional: is_optional,
772
+ line: token.line
773
+ )
774
+ end
775
+
776
+ # Parse a Ref() expression
777
+ #
778
+ # Grammar: REF LPAREN IDENTIFIER DOT path RPAREN
779
+ # path := IDENTIFIER (DOT IDENTIFIER)*
780
+ #
781
+ # @param line [Integer] line number for error reporting
782
+ # @return [AST::TypeNode] type node wrapping a ReferenceNode
783
+ #
784
+
785
+ def parse_reference(line)
786
+ expect(LPAREN)
787
+ schema_token = advance
788
+ schema_name = schema_token.value
789
+ unless current_token.type?(DOT)
790
+ error("ref(#{schema_name}) needs a field — write ref(#{schema_name}.<field>), e.g. ref(#{schema_name}.id)")
791
+ end
792
+ expect(DOT)
793
+ field_token = advance
794
+ field_path = field_token.value
795
+
796
+ # Handle nested paths like address.city
797
+ while current_token.type?(DOT)
798
+ advance
799
+ next_token = advance
800
+ field_path = "#{field_path}.#{next_token.value}"
801
+ end
802
+
803
+ expect(RPAREN)
804
+
805
+ ref_node = AST::ReferenceNode.new(
806
+ schema_name: schema_name,
807
+ field_path: field_path,
808
+ line: line
809
+ )
810
+
811
+ # Wrap in TypeNode with name "reference"
812
+ AST::TypeNode.new(
813
+ name: "reference",
814
+ arguments: { ref: ref_node },
815
+ line: line
816
+ )
817
+ end
818
+
819
+ # Parse an array type from arguments
820
+ #
821
+ # @param arguments [Hash] parsed arguments (element, size)
822
+ # @param line [Integer] line number
823
+ # @param nullable [Boolean] whether the array can be null
824
+ # @return [AST::ArrayTypeNode] the array type node
825
+ #
826
+ # Parse an array type from arguments
827
+ #
828
+ # Creates an ArrayTypeNode from parsed arguments containing element
829
+ # type and size specification.
830
+ #
831
+ # @param arguments [Hash] parsed arguments (element, size)
832
+ # @param line [Integer] line number
833
+ # @param nullable [Boolean] whether the array can be null
834
+ # @return [AST::ArrayTypeNode] the array type node
835
+ #
836
+
837
+ def parse_array_type(arguments, line, nullable = false)
838
+ element_type = arguments[:element] || arguments.values.first
839
+ size_range = arguments[:size] || arguments[:range] || { min: 0, max: 10 }
840
+
841
+ AST::ArrayTypeNode.new(
842
+ element_type: element_type,
843
+ size_range: size_range,
844
+ nullable: nullable,
845
+ line: line
846
+ )
847
+ end
848
+
849
+ # ==========================================================================
850
+
851
+ # Argument Parsing
852
+ # ==========================================================================
853
+
854
+ # Parse type arguments based on type name
855
+ #
856
+ # Different types have different argument formats:
857
+ # - enum: comma-separated values with optional probabilities
858
+ # - boolean: true/false with probabilities
859
+ # - array: element type and size
860
+ # - custom: symbol function name
861
+ # - others: generic key:value pairs or ranges
862
+ #
863
+ # @param type_name [String] the type name
864
+ # @return [Hash] parsed arguments
865
+ #
866
+
867
+ def parse_type_arguments(type_name)
868
+ args = {}
869
+
870
+ case type_name
871
+ when "enum"
872
+ args[:values] = parse_enum_values
873
+ when "boolean"
874
+ args.merge!(parse_boolean_args)
875
+ when "array"
876
+ args.merge!(parse_array_args)
877
+ when "map_by_field"
878
+ args.merge!(parse_map_by_field_args)
879
+ when "shared"
880
+ args.merge!(parse_shared_args)
881
+ when "one_of"
882
+ args.merge!(parse_one_of_args)
883
+ when "snowflake_id", "unix_timestamp_ms"
884
+ args.merge!(parse_as_args)
885
+ when "custom"
886
+ args[:function] = parse_symbol_arg
887
+ when "const", "default_value", "default"
888
+ args.merge!(parse_const_args)
889
+ when "copy"
890
+ args.merge!(parse_copy_args)
891
+ when "regex"
892
+ args.merge!(parse_pattern_args(:pattern))
893
+ when "template"
894
+ args.merge!(parse_pattern_args(:template))
895
+ when "formula"
896
+ args.merge!(parse_formula_args)
897
+ when "repeating_element"
898
+ args.merge!(parse_repeating_element_args)
899
+ else
900
+ args.merge!(parse_generic_args)
901
+ end
902
+
903
+ args
904
+ end
905
+
906
+ # Parse enum values with optional probabilities
907
+ #
908
+ # Grammar: value (:NUMBER%)? (COMMA value (:NUMBER%)?)*
909
+ #
910
+ # @return [Array<AST::EnumValueNode>] list of enum value nodes
911
+ # @raise [InvalidProbabilityError] if total probability > 100%
912
+ #
913
+
914
+ def parse_enum_values
915
+ values = []
916
+ total_probability = 0
917
+
918
+ loop do
919
+ break if current_token.type?(RPAREN)
920
+
921
+ value_token = advance
922
+ value = value_token.value
923
+ probability = nil
924
+
925
+ # Check for :probability% syntax
926
+ if current_token.type?(COLON)
927
+ advance
928
+ num_token = expect(NUMBER)
929
+ expect(PERCENT)
930
+ probability = num_token.value
931
+ total_probability += probability
932
+ end
933
+
934
+ values << AST::EnumValueNode.new(value: value, probability: probability)
935
+
936
+ break unless current_token.type?(COMMA)
937
+
938
+ advance # Skip comma
939
+ end
940
+
941
+ # Validate total probability doesn't exceed 100%
942
+ if total_probability > 100
943
+ raise InvalidProbabilityError.new(total_probability, line: current_token.line)
944
+ end
945
+
946
+ # Distribute remaining probability to unspecified values
947
+ unspecified = values.count { |v| v.probability.nil? }
948
+ if unspecified.positive? && total_probability < 100
949
+ remainder = (100 - total_probability) / unspecified
950
+ values.each do |v|
951
+ v.instance_variable_set(:@probability, remainder) if v.probability.nil?
952
+ end
953
+ end
954
+
955
+ values
956
+ end
957
+
958
+ # Parse boolean type arguments
959
+ #
960
+ # Handles true:X% and false:Y% probability specifications.
961
+ #
962
+ # @return [Hash] { true_probability: X, false_probability: Y }
963
+ #
964
+
965
+ def parse_boolean_args
966
+ args = {}
967
+
968
+ loop do
969
+ break if current_token.type?(RPAREN)
970
+
971
+ if current_token.type?(TRUE_KW) || current_token.type?(FALSE_KW)
972
+ bool_val = current_token.type?(TRUE_KW)
973
+ advance
974
+ if current_token.type?(COLON)
975
+ advance
976
+ num_token = expect(NUMBER)
977
+ expect(PERCENT)
978
+ args[bool_val ? :true_probability : :false_probability] = num_token.value
979
+ end
980
+ else
981
+ advance
982
+ end
983
+
984
+ break unless current_token.type?(COMMA)
985
+
986
+ advance
987
+ end
988
+
989
+ args
990
+ end
991
+
992
+ # Parse array type arguments
993
+ #
994
+ # Supports both positional and named arguments:
995
+ # - Positional: array(text, 1..5) or array(OrderItem, 3)
996
+ # - Named: array(element: text, size: 1..5)
997
+ #
998
+ # Grammar: (element_type | element: type) (COMMA (size_spec | size: spec))?
999
+ # size_spec := RANGE | NUMBER
1000
+ #
1001
+ # @return [Hash] { element: type_name, size: range }
1002
+ #
1003
+
1004
+ def parse_array_args
1005
+ args = {}
1006
+
1007
+ # Check for named arguments first (element: type, size: range)
1008
+ # :nocov: peek_token never returns nil (it falls back to a synthetic EOF token), so
1009
+ # the `&.` nil-receiver else-arm in this condition is unreachable.
1010
+ if current_token.type?(IDENTIFIER) && current_token.value.to_s == "element" && peek_token(1)&.type?(COLON)
1011
+ # :nocov:
1012
+
1013
+ # Named argument: element: type
1014
+ advance # Skip "element"
1015
+ expect(COLON)
1016
+ if current_token.type?(TYPE) || current_token.type?(IDENTIFIER)
1017
+ args[:element] = current_token.value
1018
+ advance
1019
+ end
1020
+
1021
+ # Check for size: spec
1022
+ if current_token.type?(COMMA)
1023
+ advance
1024
+ # :nocov: peek_token never returns nil (synthetic EOF fallback), so the `&.`
1025
+ # nil-receiver else-arm in this condition is unreachable.
1026
+ if current_token.type?(IDENTIFIER) && current_token.value.to_s == "size" && peek_token(1)&.type?(COLON)
1027
+ # :nocov:
1028
+ advance # Skip "size"
1029
+ expect(COLON)
1030
+
1031
+ if current_token.type?(RANGE)
1032
+ args[:size] = current_token.value
1033
+ advance
1034
+ elsif current_token.type?(NUMBER)
1035
+ min = current_token.value
1036
+ advance
1037
+ if current_token.type?(RANGE)
1038
+ args[:size] = current_token.value
1039
+ advance
1040
+ else
1041
+ args[:size] = { min: min, max: min }
1042
+ end
1043
+ end
1044
+
1045
+ end
1046
+ end
1047
+ else
1048
+
1049
+ # Positional arguments: element_type (COMMA size_spec)?
1050
+ if current_token.type?(TYPE) || current_token.type?(IDENTIFIER)
1051
+ args[:element] = current_token.value
1052
+ advance
1053
+ end
1054
+
1055
+ if current_token.type?(COMMA)
1056
+ advance
1057
+
1058
+ # Second arg is size range
1059
+ if current_token.type?(RANGE)
1060
+ range = current_token.value
1061
+ args[:size] = range
1062
+ advance
1063
+ elsif current_token.type?(NUMBER)
1064
+ min = current_token.value
1065
+ advance
1066
+ if current_token.type?(RANGE)
1067
+ range = current_token.value
1068
+ args[:size] = range
1069
+ advance
1070
+ else
1071
+ args[:size] = { min: min, max: min }
1072
+ end
1073
+ end
1074
+ end
1075
+ end
1076
+
1077
+ args
1078
+ end
1079
+
1080
+ # Parse map_by_field arguments
1081
+ #
1082
+ # Grammar: STRING COMMA entry (COMMA entry)*
1083
+ # entry := IDENTIFIER COLON IDENTIFIER
1084
+ #
1085
+ # @example map_by_field("id", sender: TwitterUser, recipient: TwitterUser)
1086
+ # @return [Hash] { key_field: "id", entries: { sender: "TwitterUser", ... } }
1087
+ #
1088
+
1089
+ def parse_map_by_field_args
1090
+ args = { entries: {} }
1091
+
1092
+ # First argument: key field name (string)
1093
+ if current_token.type?(STRING)
1094
+ args[:key_field] = current_token.value
1095
+ advance
1096
+ else
1097
+ args[:key_field] = "id" # Default
1098
+ end
1099
+
1100
+ # Remaining arguments: name: SchemaType or name: shared(SchemaType) pairs
1101
+ while current_token.type?(COMMA)
1102
+ advance # Skip comma
1103
+
1104
+ # Parse entry name
1105
+ if current_token.type?(IDENTIFIER)
1106
+ entry_name = current_token.value.to_sym
1107
+ advance
1108
+
1109
+ # Expect colon
1110
+ if current_token.type?(COLON)
1111
+ advance
1112
+
1113
+ # Check if it's a shared() wrapper
1114
+ if current_token.type?(IDENTIFIER) && current_token.value.to_s == "shared"
1115
+ advance # Skip "shared"
1116
+ expect(LPAREN)
1117
+ if current_token.type?(IDENTIFIER) || current_token.type?(TYPE)
1118
+ schema_name = current_token.value
1119
+ # Mark as shared by storing a hash instead of just the name
1120
+ args[:entries][entry_name] = { schema: schema_name.to_s, shared: true }
1121
+ advance
1122
+ end
1123
+ expect(RPAREN)
1124
+ elsif current_token.type?(IDENTIFIER) || current_token.type?(TYPE)
1125
+ # Regular schema type
1126
+ schema_name = current_token.value
1127
+ args[:entries][entry_name] = schema_name.to_s
1128
+ advance
1129
+ end
1130
+ end
1131
+ end
1132
+ end
1133
+
1134
+ args
1135
+ end
1136
+
1137
+
1138
+ # Parse shared type arguments
1139
+ #
1140
+ # Grammar: TypeName (COMMA key: STRING)?
1141
+ # Example: shared(TwitterUser) or shared(TwitterUser, key: "recipient")
1142
+ #
1143
+ # @return [Hash] { type_name:, type_args:, shared_key: }
1144
+ #
1145
+
1146
+ def parse_shared_args
1147
+ args = {}
1148
+
1149
+ # First argument: type/schema name
1150
+ if current_token.type?(IDENTIFIER) || current_token.type?(TYPE)
1151
+ args[:type_name] = current_token.value.to_s
1152
+ advance
1153
+ end
1154
+
1155
+ # Optional key: argument for custom cache key
1156
+ if current_token.type?(COMMA)
1157
+ advance
1158
+ # :nocov: peek_token never returns nil (synthetic EOF fallback), so the `&.`
1159
+ # nil-receiver else-arm in this condition is unreachable.
1160
+ if current_token.type?(IDENTIFIER) && current_token.value.to_s == "key" && peek_token(1)&.type?(COLON)
1161
+ # :nocov:
1162
+ advance # Skip "key"
1163
+ expect(COLON)
1164
+ if current_token.type?(STRING)
1165
+ args[:shared_key] = current_token.value
1166
+ advance
1167
+ end
1168
+ end
1169
+ end
1170
+
1171
+ args
1172
+ end
1173
+
1174
+
1175
+ # Parse one_of type arguments
1176
+ #
1177
+ # Grammar: SchemaName (COMMA SchemaName)* | SchemaName:PERCENTAGE (COMMA SchemaName:PERCENTAGE)*
1178
+ # Example: one_of(MessageCreate, TypingIndicator, ReadReceipt)
1179
+ # Example with weights: one_of(CommonPayload:80%, RarePayload:20%)
1180
+ #
1181
+ # @return [Hash] { schemas: [...] }
1182
+ #
1183
+
1184
+ def parse_one_of_args
1185
+ args = { schemas: [] }
1186
+
1187
+ loop do
1188
+ break if current_token.type?(RPAREN)
1189
+
1190
+ if current_token.type?(IDENTIFIER) || current_token.type?(TYPE)
1191
+ schema_name = current_token.value.to_s
1192
+ advance
1193
+
1194
+ # Check for weight (percentage)
1195
+ if current_token.type?(COLON)
1196
+ advance
1197
+ if current_token.type?(NUMBER)
1198
+ weight = current_token.value
1199
+ advance
1200
+ # Skip optional % sign
1201
+ # :nocov: the lexer never emits a standalone "%" IDENTIFIER token after a
1202
+ # weight literal, so this conditional advance's then-arm is unreachable.
1203
+ advance if current_token.type?(IDENTIFIER) && current_token.value.to_s == "%"
1204
+ # :nocov:
1205
+ args[:schemas] << { schema: schema_name, weight: weight }
1206
+ else
1207
+ args[:schemas] << schema_name
1208
+ end
1209
+ else
1210
+ args[:schemas] << schema_name
1211
+ end
1212
+ end
1213
+
1214
+ # Check for comma to continue
1215
+ if current_token.type?(COMMA)
1216
+ advance
1217
+ else
1218
+ break
1219
+ end
1220
+ end
1221
+
1222
+ args
1223
+ end
1224
+
1225
+
1226
+ # Parse as: argument for types that support string/integer output
1227
+ #
1228
+ # Grammar: (as: (string | integer))?
1229
+ # Example: snowflake_id(as: string) or snowflake_id(as: integer)
1230
+ #
1231
+ # @return [Hash] { as: "string" | "integer" }
1232
+ #
1233
+
1234
+ def parse_as_args
1235
+ args = {}
1236
+
1237
+ # Check for as: argument
1238
+ # :nocov: peek_token never returns nil (synthetic EOF fallback), so the `&.`
1239
+ # nil-receiver else-arm in this condition is unreachable.
1240
+ if current_token.type?(IDENTIFIER) && current_token.value.to_s == "as" && peek_token(1)&.type?(COLON)
1241
+ # :nocov:
1242
+ advance # Skip "as"
1243
+ expect(COLON)
1244
+ if current_token.type?(IDENTIFIER) || current_token.type?(TYPE)
1245
+ args[:as] = current_token.value.to_s
1246
+ advance
1247
+ end
1248
+ end
1249
+
1250
+ args
1251
+ end
1252
+
1253
+
1254
+ # Parse const type arguments
1255
+ #
1256
+ # Grammar: STRING | NUMBER | TRUE_KW | FALSE_KW | IDENTIFIER
1257
+ # Example: const("message_create") or const(42) or const(true)
1258
+ #
1259
+ # @return [Hash] { value: the_constant_value }
1260
+ #
1261
+
1262
+ def parse_const_args
1263
+ args = {}
1264
+
1265
+ case current_token.type
1266
+ when STRING
1267
+ args[:value] = current_token.value
1268
+ advance
1269
+ when NUMBER
1270
+ args[:value] = current_token.value
1271
+ advance
1272
+ when TRUE_KW
1273
+ args[:value] = true
1274
+ advance
1275
+ when FALSE_KW
1276
+ args[:value] = false
1277
+ advance
1278
+ when IDENTIFIER
1279
+ args[:value] = current_token.value.to_s
1280
+ advance
1281
+ end
1282
+
1283
+ args
1284
+ end
1285
+
1286
+
1287
+ # Parse a copy() argument: copy("path.to.field") or copy(bare_field).
1288
+ #
1289
+ # Stores the referenced path as a String under :path so the Copy generator
1290
+ # (and PathValidator) can resolve it. The bare-identifier form previously
1291
+ # fell through to parse_generic_args and was stored as a Symbol :value,
1292
+ # which crashed the validator (Symbol#split).
1293
+ #
1294
+ # @return [Hash] { path: String }
1295
+ #
1296
+ def parse_copy_args
1297
+ args = {}
1298
+
1299
+ case current_token.type
1300
+ when STRING
1301
+ args[:path] = current_token.value.to_s
1302
+ advance
1303
+ when IDENTIFIER, TYPE
1304
+ args[:path] = current_token.value.to_s
1305
+ advance
1306
+ end
1307
+
1308
+ args
1309
+ end
1310
+
1311
+ # Parse a single quoted/bare string argument for regex() and template().
1312
+ #
1313
+ # regex("\d{3}") and template("Hello ${a}") were previously routed through
1314
+ # parse_generic_args, which stored the string under :path — so the
1315
+ # generators (reading :pattern / :template) saw nil and returned "".
1316
+ #
1317
+ # Also accepts the explicit named form regex(pattern: "...") /
1318
+ # template(template: "...") by delegating to parse_generic_args.
1319
+ #
1320
+ # @param key [Symbol] the argument key to store the string under
1321
+ # @return [Hash] { key => String }
1322
+ #
1323
+ def parse_pattern_args(key)
1324
+ # Named form (pattern:/template:/etc.) — let parse_generic_args map it.
1325
+ return parse_generic_args if current_token.type?(IDENTIFIER) && peek_token.type?(COLON)
1326
+
1327
+ args = {}
1328
+
1329
+ if current_token.type?(STRING)
1330
+ args[key] = current_token.value.to_s
1331
+ advance
1332
+ elsif current_token.type?(IDENTIFIER) || current_token.type?(TYPE)
1333
+ args[key] = current_token.value.to_s
1334
+ advance
1335
+ end
1336
+
1337
+ args
1338
+ end
1339
+
1340
+ # Parse a formula() argument list.
1341
+ #
1342
+ # Two supported forms:
1343
+ # 1. Named: formula(expression: "a * b") / formula(expr: "a * b")
1344
+ # — the documented, eval-safe form; delegated to parse_generic_args
1345
+ # which already maps `expression:`/`expr:` to the matching key.
1346
+ # 2. Bare: formula(a * b + 2)
1347
+ # — reconstructs the raw expression text from the token stream and
1348
+ # stores it under :expression. This is the form whose arithmetic
1349
+ # operators (STAR/SLASH/PLUS/MINUS) used to crash the lexer.
1350
+ #
1351
+ # Evaluation stays gated behind allow_eval in the generator either way.
1352
+ #
1353
+ # @return [Hash] { expression: String, ... }
1354
+ #
1355
+ def parse_formula_args
1356
+ # Named form: leading IDENTIFIER ':' — let the generic parser handle it
1357
+ # so formula(expression: "...") keeps mapping to args[:expression].
1358
+ return parse_generic_args if current_token.type?(IDENTIFIER) && peek_token.type?(COLON)
1359
+
1360
+ parts = []
1361
+ depth = 0
1362
+
1363
+ loop do
1364
+ tok = current_token
1365
+ break if tok.type?(EOF) || tok.type?(NEWLINE)
1366
+ # The closing ) of the formula(...) call is at depth 0; inner grouping
1367
+ # parens (e.g. formula((p + 1) * 2)) raise/lower depth so they don't
1368
+ # terminate the loop early.
1369
+ break if tok.type?(RPAREN) && depth.zero?
1370
+
1371
+ depth += 1 if tok.type?(LPAREN)
1372
+ depth -= 1 if tok.type?(RPAREN)
1373
+ parts << formula_token_text(tok)
1374
+ advance
1375
+ end
1376
+
1377
+ { expression: parts.join(" ").strip }
1378
+ end
1379
+
1380
+ # Render a single token as a fragment of a formula expression string.
1381
+ def formula_token_text(token)
1382
+ case token.type
1383
+ when STAR then "*"
1384
+ when SLASH then "/"
1385
+ when PLUS then "+"
1386
+ when MINUS then "-"
1387
+ when LPAREN then "("
1388
+ when RPAREN then ")"
1389
+ else token.value.to_s
1390
+ end
1391
+ end
1392
+
1393
+ # Parse a symbol argument (for custom functions)
1394
+ #
1395
+ # Grammar: COLON? IDENTIFIER
1396
+ #
1397
+ # @return [Symbol, nil] the symbol value
1398
+ #
1399
+
1400
+ def parse_symbol_arg
1401
+ if current_token.type?(COLON)
1402
+ advance
1403
+ end
1404
+
1405
+ if current_token.type?(IDENTIFIER) || current_token.type?(SYMBOL)
1406
+ value = current_token.value.to_sym
1407
+ advance
1408
+ value
1409
+ end
1410
+ end
1411
+
1412
+ # Parse repeating_element arguments — positional or named.
1413
+ #
1414
+ # repeating_element(uuid, 3) => { type: "uuid", count: 3 }
1415
+ # repeating_element(type: uuid, count: 3) => { type: "uuid", count: 3 }
1416
+ # repeating_element("x", 3) => { value: "x", count: 3 }
1417
+ #
1418
+ # A bare type/identifier is the element type, a bare number is the count, a
1419
+ # bare string is a literal value to repeat. Without this the positional form
1420
+ # fell through to parse_generic_args and mis-mapped both args onto :value
1421
+ # (returning the count as a string).
1422
+ #
1423
+ # @return [Hash] parsed arguments
1424
+ #
1425
+ def parse_repeating_element_args
1426
+ args = {}
1427
+
1428
+ loop do
1429
+ break if current_token.type?(RPAREN)
1430
+
1431
+ if current_token.type?(IDENTIFIER) && peek_token.type?(COLON)
1432
+ name = current_token.value.to_sym
1433
+ advance
1434
+ advance
1435
+ args[name] = parse_arg_value
1436
+ elsif current_token.type?(NUMBER)
1437
+ args[:count] = current_token.value
1438
+ advance
1439
+ elsif current_token.type?(STRING)
1440
+ args[:value] = current_token.value
1441
+ advance
1442
+ elsif current_token.type?(IDENTIFIER) || current_token.type?(TYPE)
1443
+ args[:type] = current_token.value
1444
+ advance
1445
+ else
1446
+ advance
1447
+ end
1448
+
1449
+ break unless current_token.type?(COMMA)
1450
+
1451
+ advance
1452
+ end
1453
+
1454
+ args
1455
+ end
1456
+
1457
+ # Parse generic arguments (key:value pairs, ranges, etc.)
1458
+ #
1459
+ # Handles various argument formats:
1460
+ # - Ranges: 18..80
1461
+ # - Named args: min:10, max:100
1462
+ # - Percentages: 50%
1463
+ # - Booleans: true, false
1464
+ # - Numbers: 42
1465
+ #
1466
+ # @return [Hash] parsed arguments
1467
+ #
1468
+
1469
+ def parse_generic_args
1470
+ args = {}
1471
+
1472
+ loop do
1473
+ break if current_token.type?(RPAREN)
1474
+
1475
+ # Handle range like 18..80
1476
+ if current_token.type?(RANGE)
1477
+ range = current_token.value
1478
+ args[:range] = range[:min]..range[:max]
1479
+ advance
1480
+
1481
+ # Handle named args like min:10, max:100
1482
+ elsif current_token.type?(IDENTIFIER)
1483
+ name = current_token.value.to_sym
1484
+ advance
1485
+ if current_token.type?(COLON)
1486
+ advance
1487
+ value = parse_arg_value
1488
+ args[name] = value
1489
+ else
1490
+ # Single identifier with no colon: check if it's a known locale code
1491
+ if locale_code?(name)
1492
+ args[:locale] = name
1493
+ else
1494
+ args[:value] = name
1495
+ end
1496
+ end
1497
+
1498
+ # Handle number
1499
+ elsif current_token.type?(NUMBER)
1500
+ num = current_token.value
1501
+ advance
1502
+ if current_token.type?(PERCENT)
1503
+ advance
1504
+ args[:probability] = num
1505
+ else
1506
+ args[:value] = num
1507
+ end
1508
+
1509
+ # Handle true/false
1510
+ elsif current_token.type?(TRUE_KW) || current_token.type?(FALSE_KW)
1511
+ args[:value] = current_token.type?(TRUE_KW)
1512
+ advance
1513
+
1514
+ # Handle string like "user_id" or "users.sender.id"
1515
+ elsif current_token.type?(STRING)
1516
+ args[:path] = current_token.value
1517
+ advance
1518
+ else
1519
+ advance
1520
+ end
1521
+
1522
+ break unless current_token.type?(COMMA)
1523
+
1524
+ advance
1525
+ end
1526
+
1527
+ args
1528
+ end
1529
+
1530
+ # Parse a single argument value
1531
+ #
1532
+ # @return [Object] the parsed value (number, string, boolean, etc.)
1533
+ #
1534
+
1535
+ def parse_arg_value
1536
+ token = current_token
1537
+ case token.type
1538
+ when NUMBER
1539
+ advance
1540
+ token.value
1541
+ when TRUE_KW
1542
+ advance
1543
+ true
1544
+ when FALSE_KW
1545
+ advance
1546
+ false
1547
+ when STRING
1548
+ advance
1549
+ token.value
1550
+ when SYMBOL
1551
+
1552
+ # Handle symbol literals like :ar, :en, :locale
1553
+ advance
1554
+ token.value
1555
+ when IDENTIFIER
1556
+ advance
1557
+ token.value
1558
+ when TYPE
1559
+
1560
+ # Handle uppercase identifiers like USD, HTTP, etc.
1561
+ advance
1562
+ token.value
1563
+ when RANGE
1564
+ advance
1565
+ token.value[:min]..token.value[:max]
1566
+ else
1567
+ advance
1568
+ token.value
1569
+ end
1570
+ end
1571
+
1572
+ # ==========================================================================
1573
+
1574
+ # Behavior Parsing
1575
+ # ==========================================================================
1576
+
1577
+ # Parse a schema-level behavior
1578
+ #
1579
+ # Grammar: AT_BEHAVIOR value? NEWLINE
1580
+ #
1581
+ # @return [AST::BehaviorNode] the behavior node
1582
+ #
1583
+
1584
+ def parse_behavior
1585
+ token = expect(AT_BEHAVIOR)
1586
+ name = token.value
1587
+
1588
+ value = parse_behavior_value
1589
+ skip_newlines
1590
+
1591
+ AST::BehaviorNode.new(name: name, value: value, line: token.line)
1592
+ end
1593
+
1594
+ # Parse a field-level behavior
1595
+ #
1596
+ # Grammar: AT_BEHAVIOR value?
1597
+ #
1598
+ # @return [AST::BehaviorNode] the behavior node
1599
+ #
1600
+
1601
+ def parse_field_behavior
1602
+ token = expect(AT_BEHAVIOR)
1603
+ name = token.value
1604
+ value = parse_behavior_value
1605
+
1606
+ AST::BehaviorNode.new(name: name, value: value, line: token.line)
1607
+ end
1608
+
1609
+ # Parse a behavior value
1610
+ #
1611
+ # Handles:
1612
+ # - Ranges: 100..500ms
1613
+ # - Percentages: 10%
1614
+ # - Numbers: 500
1615
+ # - Duration values: 500ms, 2s
1616
+ #
1617
+ # @return [Object, nil] the parsed value
1618
+ #
1619
+ # Parse a behavior value
1620
+ #
1621
+ # Handles various behavior value formats including ranges with
1622
+ # duration suffixes, percentages, and plain numbers.
1623
+ #
1624
+ # @return [Object, nil] the parsed value
1625
+ #
1626
+
1627
+ def parse_behavior_value
1628
+ if current_token.type?(RANGE)
1629
+ range = current_token.value
1630
+ advance
1631
+ { min: range[:min], max: range[:max], suffix: range[:suffix] }
1632
+ elsif current_token.type?(NUMBER)
1633
+ num = current_token.value
1634
+ advance
1635
+ if current_token.type?(PERCENT)
1636
+ advance
1637
+ num # Percentage as integer
1638
+ elsif num.is_a?(Hash)
1639
+ { value: num[:value], suffix: num[:suffix] }
1640
+ else
1641
+ num
1642
+ end
1643
+ else
1644
+ nil
1645
+ end
1646
+ end
1647
+
1648
+ # Check if an identifier is a known locale code
1649
+ #
1650
+ # @param name [Symbol] Identifier to check
1651
+ # @return [Boolean] true if the identifier is a known locale code
1652
+ #
1653
+ def locale_code?(name)
1654
+ LOCALE_CODES.include?(name.to_s.downcase.to_sym)
1655
+ end
1656
+
1657
+ # Known locale codes (two-letter or full locale identifiers)
1658
+ LOCALE_CODES = %i[
1659
+ en ar es fr de it pt ru zh ja ko hi
1660
+ en_us en_gb ar_sa es_es fr_fr de_de it_it pt_br ru_ru zh_cn ja_jp ko_kr hi_in
1661
+ ].freeze
1662
+ end
1663
+ end
1664
+ end