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,1152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # =============================================================================
4
+
5
+ # Synthra Lexer
6
+ # =============================================================================
7
+ #
8
+ # The Lexer (also called Scanner or Tokenizer) is the first stage of DSL
9
+ # parsing. It converts raw source text into a stream of Token objects.
10
+ #
11
+ # The lexer handles:
12
+ # - Indentation tracking (DSL uses indentation-based structure)
13
+ # - Schema and field name recognition
14
+ # - Type name recognition
15
+ # - Literal values (strings, numbers, ranges)
16
+ # - Behavior annotations (@latency, @failure, etc.)
17
+ # - Keywords (if, true, false, Ref)
18
+ # - Comments (# ...)
19
+ #
20
+ # @example Basic usage
21
+ # lexer = Lexer.new("User:\n name: text")
22
+ # tokens = lexer.tokenize
23
+ # # => [Token(:schema_name, "User"), Token(:colon, ":"), ...]
24
+ #
25
+ # @example Token stream for a simple schema
26
+ # # Input:
27
+ # # User:
28
+ # # name: text
29
+ # # age: number(18..80)
30
+ #
31
+ # # Output tokens:
32
+ # # SCHEMA_NAME("User"), COLON, NEWLINE,
33
+ # # INDENT(2), FIELD_NAME("name"), COLON, TYPE("text"), NEWLINE,
34
+ # # FIELD_NAME("age"), COLON, TYPE("number"), LPAREN, RANGE(18..80), RPAREN, NEWLINE,
35
+ # # DEDENT(0), EOF
36
+ #
37
+ # =============================================================================
38
+
39
+
40
+ module Synthra
41
+ module Parser
42
+
43
+ # Lexer for the Synthra language
44
+ #
45
+ # Converts DSL source text into a stream of tokens that the parser
46
+ # can process. The lexer is indentation-aware and tracks nesting
47
+ # depth to emit INDENT and DEDENT tokens.
48
+ #
49
+ # @example Tokenize DSL source
50
+ # lexer = Lexer.new(source)
51
+ # tokens = lexer.tokenize
52
+ #
53
+ # @example Handle syntax errors
54
+ # begin
55
+ # tokens = Lexer.new(source).tokenize
56
+ # rescue Synthra::SyntaxError => e
57
+ # puts "Error at line #{e.line}, column #{e.column}: #{e.message}"
58
+ # end
59
+ #
60
+
61
+ class Lexer
62
+
63
+ # Include token type constants for convenience
64
+ include Tokens
65
+
66
+ # Reserved keywords and their token types
67
+ #
68
+ # These words have special meaning in the DSL and cannot be used
69
+ # as identifiers.
70
+ #
71
+ # @return [Hash<String, Symbol>] keyword => token type mapping
72
+ #
73
+
74
+ KEYWORDS = {
75
+ "if" => IF, # Conditional field syntax
76
+ "true" => TRUE_KW, # Boolean true value
77
+ "false" => FALSE_KW, # Boolean false value
78
+ "Ref" => REF, # Cross-schema reference
79
+ "ref" => REF # Cross-schema reference (lowercase alias)
80
+ }.freeze
81
+
82
+ # Simulyra DSL keywords for layered architecture
83
+ #
84
+ # These keywords define the three layers of Simulyra DSL:
85
+ # - schema: Data structure definitions
86
+ # - api: HTTP endpoint definitions
87
+ # - scenario: Response scenario definitions
88
+ #
89
+ # @return [Hash<String, Symbol>] keyword => token type mapping
90
+ #
91
+
92
+ SIMULYRA_KEYWORDS = {
93
+ "schema" => SCHEMA_KEYWORD,
94
+ "api" => API_KEYWORD,
95
+ "scenario" => SCENARIO_KEYWORD
96
+ }.freeze
97
+
98
+ # HTTP methods recognized by the lexer
99
+ #
100
+ # These are the standard HTTP methods that can be used
101
+ # in API definitions.
102
+ #
103
+ # @return [Array<String>] list of valid HTTP method names
104
+ #
105
+
106
+ HTTP_METHODS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD].freeze
107
+
108
+
109
+ # Known built-in type names
110
+ #
111
+ # These are recognized as TYPE tokens when not inside parentheses.
112
+ # Custom types starting with uppercase are also recognized.
113
+ #
114
+ # @return [Array<String>] list of built-in type names
115
+ #
116
+
117
+ KNOWN_TYPES = %w[
118
+ uuid ulid id_sequence
119
+ name email text
120
+ number boolean enum
121
+ date past_date future_date now timestamp
122
+ array object custom
123
+ phone url ip ipv6
124
+ country_code currency postal_code money
125
+ ].freeze
126
+
127
+ # Create a new Lexer
128
+ #
129
+ # @param source [String] the DSL source code to tokenize
130
+ #
131
+ # @example
132
+ # lexer = Lexer.new("User:\n name: text")
133
+ #
134
+
135
+ def initialize(source)
136
+ @source = source # Full source text
137
+ @lines = source.lines # Source split into lines (for errors)
138
+ @tokens = [] # Accumulated tokens
139
+ @line = 1 # Current line number (1-based)
140
+ @column = 1 # Current column number (1-based)
141
+ @pos = 0 # Current position in source
142
+ @indent_stack = [0] # Stack of indentation levels
143
+ @at_line_start = true # Are we at the start of a line?
144
+ @paren_depth = 0 # Parenthesis nesting depth
145
+ end
146
+
147
+ # Tokenize the entire source
148
+ #
149
+ # Processes the source text and returns an array of Token objects.
150
+ # The token stream will always end with an EOF token.
151
+ #
152
+ # @return [Array<Token>] array of tokens representing the source
153
+ # @raise [SyntaxError] if the source contains invalid syntax
154
+ #
155
+ # @example
156
+ # tokens = lexer.tokenize
157
+ # tokens.last.type # => :eof
158
+ #
159
+
160
+ def tokenize
161
+
162
+ # Process source character by character
163
+ while @pos < @source.length
164
+ tokenize_line
165
+ end
166
+
167
+ # Emit DEDENT tokens for any remaining indentation
168
+ emit_remaining_dedents
169
+
170
+ # Always end with EOF token
171
+ emit(EOF, nil)
172
+
173
+ @tokens
174
+ end
175
+
176
+ private
177
+
178
+ # ==========================================================================
179
+
180
+ # Character Access Methods
181
+ # ==========================================================================
182
+
183
+ # Get the current character
184
+ #
185
+ # @return [String, nil] current character or nil if at end
186
+ #
187
+
188
+ def current_char
189
+ @source[@pos]
190
+ end
191
+
192
+ # Peek at a character ahead of current position
193
+ #
194
+ # @param offset [Integer] how far ahead to look (default: 1)
195
+ # @return [String, nil] character at offset or nil
196
+ #
197
+
198
+ def peek_char(offset = 1)
199
+ @source[@pos + offset]
200
+ end
201
+
202
+ # Advance to the next character
203
+ #
204
+ # Updates line/column tracking when crossing newlines.
205
+ #
206
+ # @return [void]
207
+ #
208
+
209
+ def advance
210
+ if current_char == "\n"
211
+ @line += 1
212
+ @column = 1
213
+ @at_line_start = true
214
+ else
215
+ @column += 1
216
+ end
217
+ @pos += 1
218
+ end
219
+
220
+ # ==========================================================================
221
+
222
+ # Token Emission
223
+ # ==========================================================================
224
+
225
+ # Emit a token with current position
226
+ #
227
+ # @param type [Symbol] token type (from Tokens module)
228
+ # @param value [Object] token value
229
+ # @return [void]
230
+ #
231
+
232
+ def emit(type, value)
233
+ @tokens << Token.new(type, value, line: @line, column: @column)
234
+ end
235
+
236
+ # ==========================================================================
237
+
238
+ # Error Handling
239
+ # ==========================================================================
240
+
241
+ # Get the content of the current source line
242
+ #
243
+ # Used for error messages to show context.
244
+ #
245
+ # @return [String] current line content (without trailing newline)
246
+ #
247
+
248
+ def current_line_content
249
+ # :nocov: errors only fire while @pos is within the source (the tokenize loop guards
250
+ # `@pos < @source.length`), so @line always indexes a real line — the `&.` nil-receiver
251
+ # else-arm (line out of range) is unreachable.
252
+ @lines[@line - 1]&.chomp || ""
253
+ # :nocov:
254
+ end
255
+
256
+ # Raise a syntax error at current position
257
+ #
258
+ # @param message [String] error description
259
+ # @raise [SyntaxError] always raises
260
+ #
261
+
262
+ def error(message)
263
+ raise SyntaxError.new(
264
+ message,
265
+ line: @line,
266
+ column: @column,
267
+ source_line: current_line_content
268
+ )
269
+ end
270
+
271
+ # ==========================================================================
272
+
273
+ # Main Tokenization Logic
274
+ # ==========================================================================
275
+
276
+ # Process tokens from current position
277
+ #
278
+ # Handles line-start vs content differently for indentation tracking.
279
+ #
280
+ # @return [void]
281
+ #
282
+
283
+ def tokenize_line
284
+ if @at_line_start
285
+ handle_line_start
286
+ else
287
+ tokenize_content
288
+ end
289
+ end
290
+
291
+ # Handle the start of a line
292
+ #
293
+ # At line start, we need to:
294
+ # 1. Skip blank lines and comments
295
+ # 2. Count indentation and emit INDENT/DEDENT
296
+ # 3. Check for Simulyra DSL keywords (schema, api, scenario)
297
+ # 4. Check for legacy schema definitions
298
+ #
299
+ # @return [void]
300
+ #
301
+
302
+ def handle_line_start
303
+
304
+ # Skip blank lines
305
+ if current_char == "\n"
306
+ advance
307
+ return
308
+ end
309
+
310
+ # Skip comment lines
311
+ if current_char == "#"
312
+ skip_comment
313
+ return
314
+ end
315
+
316
+ # Count and process indentation
317
+ indent = count_indentation
318
+ @at_line_start = false
319
+
320
+ # Check for Simulyra DSL keywords at any indentation level
321
+ if looking_at_simulyra_keyword?
322
+ process_indent(indent)
323
+ tokenize_simulyra_keyword
324
+ return
325
+ end
326
+
327
+ # Check if this is a legacy schema definition (no indent, identifier followed by :)
328
+ if indent.zero? && looking_at_schema?
329
+ process_indent(0)
330
+ tokenize_schema_name
331
+ return
332
+ end
333
+
334
+ # Process indentation and then tokenize line content
335
+ process_indent(indent)
336
+ tokenize_content
337
+ end
338
+
339
+ # Count indentation at current position
340
+ #
341
+ # Counts spaces and tabs (tabs = 2 spaces) at current position.
342
+ # Advances position past the indentation.
343
+ # Rejects mixed tabs and spaces to prevent parsing confusion.
344
+ #
345
+ # @return [Integer] indentation level in spaces
346
+ # @raise [SyntaxError] if mixed tabs and spaces are detected
347
+ #
348
+
349
+ def count_indentation
350
+ indent = 0
351
+ start_pos = @pos
352
+ # :nocov: count_indentation runs only inside the `while @pos < @source.length` tokenize
353
+ # loop, so @pos is always in bounds here — the nil (else) branch is unreachable.
354
+ start_char = @pos < @source.length ? @source[@pos] : nil
355
+ # :nocov:
356
+
357
+ # Determine indentation type (spaces or tabs)
358
+ if start_char == " "
359
+
360
+ # Only spaces allowed - count spaces
361
+ while @pos < @source.length && @source[@pos] == " "
362
+ indent += 1
363
+ @pos += 1
364
+ @column += 1
365
+ end
366
+
367
+ # Reject if tabs found after spaces
368
+ if @pos < @source.length && @source[@pos] == "\t"
369
+ error("Mixed tabs and spaces in indentation (use either tabs or spaces, not both)")
370
+ end
371
+
372
+ elsif start_char == "\t"
373
+
374
+ # Only tabs allowed - count tabs (treat as 2 spaces each)
375
+ while @pos < @source.length && @source[@pos] == "\t"
376
+ indent += 2
377
+ @pos += 1
378
+ @column += 1
379
+ end
380
+
381
+ # Reject if spaces found after tabs
382
+ if @pos < @source.length && @source[@pos] == " "
383
+ error("Mixed tabs and spaces in indentation (use either tabs or spaces, not both)")
384
+ end
385
+ end
386
+
387
+ indent
388
+ end
389
+
390
+ # Process indentation changes
391
+ #
392
+ # Compares current indentation to stack and emits INDENT or DEDENT tokens.
393
+ #
394
+ # @param indent [Integer] current indentation level
395
+ # @return [void]
396
+ #
397
+
398
+ def process_indent(indent)
399
+ current_indent = @indent_stack.last
400
+
401
+ if indent > current_indent
402
+
403
+ # Increased indentation - push new level
404
+ @indent_stack.push(indent)
405
+ emit(INDENT, indent)
406
+ elsif indent < current_indent
407
+
408
+ # Decreased indentation - pop levels until we match
409
+ while @indent_stack.last > indent
410
+ @indent_stack.pop
411
+ emit(DEDENT, @indent_stack.last)
412
+ end
413
+ end
414
+
415
+ # Equal indentation - no change needed
416
+ end
417
+
418
+ # Emit DEDENT tokens for remaining indentation at end of file
419
+ #
420
+ # @return [void]
421
+ #
422
+
423
+ def emit_remaining_dedents
424
+ while @indent_stack.length > 1
425
+ @indent_stack.pop
426
+ emit(DEDENT, @indent_stack.last)
427
+ end
428
+ end
429
+
430
+ # ==========================================================================
431
+
432
+ # Schema Recognition
433
+ # ==========================================================================
434
+
435
+ # Check if we're looking at a schema definition
436
+ #
437
+ # A schema definition is an identifier followed by a colon at the start
438
+ # of a line with no indentation.
439
+ #
440
+ # @return [Boolean] true if this looks like a schema definition
441
+ #
442
+
443
+ def looking_at_schema?
444
+
445
+ # Look ahead to see if line ends with : (schema definition)
446
+ pos = @pos
447
+ while pos < @source.length && @source[pos] =~ /[A-Za-z0-9_]/
448
+ pos += 1
449
+ end
450
+ @source[pos] == ":"
451
+ end
452
+
453
+ # Tokenize a schema name (SchemaName:)
454
+ #
455
+ # @return [void]
456
+ #
457
+
458
+ def tokenize_schema_name
459
+ name = read_identifier
460
+ emit(SCHEMA_NAME, name)
461
+ skip_whitespace
462
+ expect_char(":")
463
+ emit(COLON, ":")
464
+ advance
465
+ skip_to_end_of_line
466
+ end
467
+
468
+ # ==========================================================================
469
+
470
+ # Simulyra DSL Recognition
471
+ # ==========================================================================
472
+
473
+ # Check if we're looking at a Simulyra DSL keyword (schema, api, scenario)
474
+ #
475
+ # @return [Boolean] true if this looks like a Simulyra keyword
476
+ #
477
+
478
+ def looking_at_simulyra_keyword?
479
+ # Look ahead to see the word
480
+ pos = @pos
481
+ word = ""
482
+ while pos < @source.length && @source[pos] =~ /[a-z]/
483
+ word += @source[pos]
484
+ pos += 1
485
+ end
486
+ SIMULYRA_KEYWORDS.key?(word)
487
+ end
488
+
489
+ # Tokenize a Simulyra DSL keyword and its arguments
490
+ #
491
+ # Handles:
492
+ # - schema Name:
493
+ # - api METHOD /path:
494
+ # - scenario Name:
495
+ #
496
+ # @return [void]
497
+ #
498
+
499
+ def tokenize_simulyra_keyword
500
+ keyword = read_identifier
501
+
502
+ case keyword
503
+ when "schema"
504
+ tokenize_schema_definition
505
+ when "api"
506
+ tokenize_api_definition
507
+ when "scenario"
508
+ tokenize_scenario_definition
509
+ end
510
+ end
511
+
512
+ # Tokenize a schema definition (schema Name:)
513
+ #
514
+ # @return [void]
515
+ #
516
+
517
+ def tokenize_schema_definition
518
+ emit(SCHEMA_KEYWORD, "schema")
519
+ skip_whitespace
520
+
521
+ # Read schema name
522
+ name = read_identifier
523
+ error("Expected schema name after 'schema'") if name.empty?
524
+ emit(SCHEMA_NAME, name)
525
+
526
+ skip_whitespace
527
+ expect_char(":")
528
+ emit(COLON, ":")
529
+ advance
530
+ skip_to_end_of_line
531
+ end
532
+
533
+ # Tokenize an API definition (api METHOD /path:)
534
+ #
535
+ # @return [void]
536
+ #
537
+
538
+ def tokenize_api_definition
539
+ emit(API_KEYWORD, "api")
540
+ skip_whitespace
541
+
542
+ # Read HTTP method
543
+ method = read_identifier.upcase
544
+ unless HTTP_METHODS.include?(method)
545
+ error("Expected HTTP method (GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD), got '#{method}'")
546
+ end
547
+ emit(HTTP_METHOD, method.downcase.to_sym)
548
+
549
+ skip_whitespace
550
+
551
+ # Read path
552
+ path = read_path
553
+ error("Expected path starting with '/' after HTTP method") if path.empty?
554
+ emit(PATH, path)
555
+
556
+ skip_whitespace
557
+ expect_char(":")
558
+ emit(COLON, ":")
559
+ advance
560
+ skip_to_end_of_line
561
+ end
562
+
563
+ # Read a URL path from current position
564
+ #
565
+ # Paths can include:
566
+ # - Forward slashes: /
567
+ # - Identifiers: users, orders
568
+ # - Path parameters: :id, :user_id
569
+ # - Hyphens and underscores: my-api, my_api
570
+ #
571
+ # @return [String] the path
572
+ #
573
+
574
+ def read_path
575
+ return "" unless current_char == "/"
576
+
577
+ path = ""
578
+ while @pos < @source.length
579
+ char = current_char
580
+
581
+ # Path parameters start with :
582
+ if char == ":"
583
+ # Look ahead to see if this is a path parameter or the end colon
584
+ next_char = peek_char
585
+ if next_char =~ /[A-Za-z_]/
586
+ # This is a path parameter like :id
587
+ path += char
588
+ advance
589
+ else
590
+ # This is the terminating colon
591
+ break
592
+ end
593
+ elsif char =~ %r{[A-Za-z0-9_\-/]}
594
+ path += char
595
+ advance
596
+ else
597
+ break
598
+ end
599
+ end
600
+ path
601
+ end
602
+
603
+ # Tokenize a scenario definition (scenario Name:)
604
+ #
605
+ # @return [void]
606
+ #
607
+
608
+ def tokenize_scenario_definition
609
+ emit(SCENARIO_KEYWORD, "scenario")
610
+ skip_whitespace
611
+
612
+ # Read scenario name
613
+ name = read_identifier
614
+ error("Expected scenario name after 'scenario'") if name.empty?
615
+ emit(IDENTIFIER, name)
616
+
617
+ skip_whitespace
618
+ expect_char(":")
619
+ emit(COLON, ":")
620
+ advance
621
+ skip_to_end_of_line
622
+ end
623
+
624
+ # Check if we're looking at a schema reference: schema(:Name)
625
+ #
626
+ # @return [Boolean] true if this looks like a schema reference
627
+ #
628
+
629
+ def looking_at_schema_reference?
630
+ # Check if we're at "schema(" pattern
631
+ return false unless current_char == "s"
632
+
633
+ pos = @pos
634
+ word = ""
635
+
636
+ # Read potential "schema" word
637
+ while pos < @source.length && @source[pos] =~ /[a-z]/
638
+ word += @source[pos]
639
+ pos += 1
640
+ end
641
+
642
+ return false unless word == "schema"
643
+
644
+ # Skip whitespace
645
+ while pos < @source.length && @source[pos] =~ /[ \t]/
646
+ pos += 1
647
+ end
648
+
649
+ # Check for opening paren
650
+ @source[pos] == "("
651
+ end
652
+
653
+ # Tokenize a schema reference: schema(:Name) or schema(:Name).array
654
+ #
655
+ # @return [void]
656
+ #
657
+
658
+ def tokenize_schema_reference
659
+ # Read "schema" keyword (read_identifier will consume it)
660
+ keyword = read_identifier
661
+ emit(SCHEMA_KEYWORD, keyword)
662
+
663
+ skip_whitespace
664
+ expect_char("(")
665
+ emit(LPAREN, "(")
666
+ advance
667
+
668
+ expect_char(":")
669
+ advance # Skip the colon in schema(:Name)
670
+
671
+ # Read the schema name
672
+ name = read_identifier
673
+ error("Expected schema name after 'schema(:'") if name.empty?
674
+ emit(SCHEMA_REF, name)
675
+
676
+ expect_char(")")
677
+ emit(RPAREN, ")")
678
+ advance
679
+
680
+ # Check for .array or .optional modifier
681
+ if current_char == "."
682
+ emit(DOT, ".")
683
+ advance
684
+ modifier = read_identifier
685
+ if modifier == "array"
686
+ emit(IDENTIFIER, "array")
687
+ # Check for range like .array(1..10)
688
+ if current_char == "("
689
+ emit(LPAREN, "(")
690
+ @paren_depth += 1
691
+ advance
692
+ end
693
+ elsif modifier == "optional"
694
+ emit(IDENTIFIER, "optional")
695
+ else
696
+ emit(IDENTIFIER, modifier)
697
+ end
698
+ end
699
+ end
700
+
701
+ # ==========================================================================
702
+
703
+ # Whitespace and Comment Handling
704
+ # ==========================================================================
705
+
706
+ # Skip a comment (from # to end of line)
707
+ #
708
+ # @return [void]
709
+ #
710
+
711
+ def skip_comment
712
+ while @pos < @source.length && current_char != "\n"
713
+ advance
714
+ end
715
+ advance if current_char == "\n"
716
+ end
717
+
718
+ # Skip horizontal whitespace (spaces and tabs)
719
+ #
720
+ # @return [void]
721
+ #
722
+
723
+ def skip_whitespace
724
+ while @pos < @source.length && current_char =~ /[ \t]/
725
+ advance
726
+ end
727
+ end
728
+
729
+ # Skip to end of current line
730
+ #
731
+ # @return [void]
732
+ #
733
+
734
+ def skip_to_end_of_line
735
+ while @pos < @source.length && current_char != "\n"
736
+ advance
737
+ end
738
+ advance if current_char == "\n"
739
+ end
740
+
741
+ # ==========================================================================
742
+
743
+ # Content Tokenization
744
+ # ==========================================================================
745
+
746
+ # Tokenize content (not at line start)
747
+ #
748
+ # Dispatches to specific tokenizers based on current character.
749
+ # Recursively processes until end of line.
750
+ #
751
+ # @return [void]
752
+ #
753
+
754
+ def tokenize_content
755
+ skip_whitespace
756
+
757
+ return if @pos >= @source.length
758
+
759
+ case current_char
760
+ when "\n"
761
+ emit(NEWLINE, "\n")
762
+ advance
763
+ when "#"
764
+ skip_comment
765
+ when "@"
766
+ tokenize_behavior
767
+ when ":"
768
+
769
+ # Check if this is a symbol literal (:ar, :en, etc.) or a colon separator
770
+ if peek_char =~ /[A-Za-z_]/
771
+ tokenize_symbol
772
+ else
773
+ emit(COLON, ":")
774
+ advance
775
+
776
+ # Process type after colon for field definitions (not inside parens)
777
+ tokenize_after_colon if @paren_depth.zero?
778
+ end
779
+ when "("
780
+ emit(LPAREN, "(")
781
+ @paren_depth += 1
782
+ advance
783
+ when ")"
784
+ emit(RPAREN, ")")
785
+ @paren_depth -= 1
786
+ advance
787
+ when ","
788
+ emit(COMMA, ",")
789
+ advance
790
+ when "-"
791
+
792
+ # Handle negative numbers (e.g., -10, -10..10)
793
+ if peek_char =~ /[0-9]/
794
+ tokenize_number
795
+ else
796
+ emit(MINUS, "-")
797
+ advance
798
+ end
799
+
800
+ when "*"
801
+ # Arithmetic operator, e.g. inside formula(a * b)
802
+ emit(STAR, "*")
803
+ advance
804
+
805
+ when "+"
806
+ # Arithmetic operator, e.g. inside formula(a + b)
807
+ emit(PLUS, "+")
808
+ advance
809
+
810
+ when "/"
811
+ # Arithmetic operator, e.g. inside formula(a / b). (Route paths are
812
+ # tokenized separately via tokenize_behavior, so a bare "/" here was
813
+ # previously an "Unexpected character" error.)
814
+ emit(SLASH, "/")
815
+ advance
816
+
817
+ when "."
818
+ if peek_char =~ /\d/
819
+ # .123 style decimal
820
+ tokenize_number
821
+ else
822
+ emit(DOT, ".")
823
+ advance
824
+ end
825
+ when "?"
826
+ emit(QUESTION, "?")
827
+ advance
828
+ when '"', "'"
829
+ tokenize_string
830
+ when /[0-9]/
831
+ tokenize_number
832
+ when /[A-Za-z_]/
833
+ # Check for schema reference: schema(:Name)
834
+ if looking_at_schema_reference?
835
+ tokenize_schema_reference
836
+ else
837
+ tokenize_identifier_or_keyword
838
+ end
839
+ else
840
+ error("Unexpected character: '#{current_char}'")
841
+ end
842
+
843
+ # Continue tokenizing until end of line
844
+ # Important: Check @at_line_start because after advancing past newline,
845
+ # we've set @at_line_start = true and should return to let handle_line_start
846
+ # process the next line's indentation
847
+ tokenize_content unless @pos >= @source.length || current_char == "\n" || @at_line_start
848
+ end
849
+
850
+ # Tokenize content after a colon (field type)
851
+ #
852
+ # After a field name and colon, the next identifier is usually a type.
853
+ #
854
+ # @return [void]
855
+ #
856
+
857
+ def tokenize_after_colon
858
+ skip_whitespace
859
+ return if @pos >= @source.length || current_char == "\n"
860
+
861
+ # Handle negative numbers (e.g., number(-10..10))
862
+ if current_char == "-"
863
+ tokenize_number
864
+ return
865
+ end
866
+
867
+ # Check for schema reference: schema(:Name)
868
+ if looking_at_schema_reference?
869
+ tokenize_schema_reference
870
+ return
871
+ end
872
+
873
+ # Read identifier (type name)
874
+ if current_char =~ /[A-Za-z_]/
875
+ name = read_identifier
876
+
877
+ # Check for ? suffix (nullable type)
878
+ has_question = current_char == "?"
879
+ if has_question
880
+ advance
881
+ end
882
+
883
+ if KEYWORDS.key?(name)
884
+ emit(KEYWORDS[name], name)
885
+ emit(QUESTION, "?") if has_question
886
+ elsif KNOWN_TYPES.include?(name)
887
+ emit(TYPE, has_question ? "#{name}?" : name)
888
+ elsif name[0] =~ /[A-Z]/
889
+
890
+ # Starts with uppercase - schema reference type
891
+ emit(TYPE, has_question ? "#{name}?" : name)
892
+ else
893
+ emit(IDENTIFIER, name)
894
+ emit(QUESTION, "?") if has_question
895
+ end
896
+ end
897
+ end
898
+
899
+ # ==========================================================================
900
+
901
+ # Specific Token Types
902
+ # ==========================================================================
903
+
904
+ # Tokenize a behavior annotation (@name)
905
+ #
906
+ # @return [void]
907
+ #
908
+
909
+ def tokenize_behavior
910
+ advance # Skip @
911
+ name = read_identifier
912
+ emit(AT_BEHAVIOR, name)
913
+ end
914
+
915
+ # Tokenize a string literal ("..." or '...')
916
+ #
917
+ # Supports escape sequences: \n, \t, \r, \", \'
918
+ #
919
+ # @return [void]
920
+ # @raise [SyntaxError] if string is not terminated
921
+ #
922
+
923
+ def tokenize_string
924
+ quote = current_char
925
+ advance # Skip opening quote
926
+
927
+ value = ""
928
+ while @pos < @source.length && current_char != quote
929
+ if current_char == "\\"
930
+ advance
931
+ value += escape_char(current_char)
932
+ else
933
+ value += current_char
934
+ end
935
+ advance
936
+ end
937
+
938
+ error("Unterminated string") if @pos >= @source.length
939
+ advance # Skip closing quote
940
+ emit(STRING, value)
941
+ end
942
+
943
+ # Process an escape sequence character
944
+ #
945
+ # @param char [String] character after backslash
946
+ # @return [String] the escaped character
947
+ #
948
+
949
+ def escape_char(char)
950
+ case char
951
+ when "n" then "\n"
952
+ when "t" then "\t"
953
+ when "r" then "\r"
954
+ else char
955
+ end
956
+ end
957
+
958
+ # Tokenize a number, range, or percentage
959
+ #
960
+ # Handles:
961
+ # - Integers: 123
962
+ # - Floats: 3.14
963
+ # - Negative: -10
964
+ # - Ranges: 18..80
965
+ # - Percentages: 50%
966
+ # - Duration suffixes: 100ms, 5s, 2h
967
+ #
968
+ # @return [void]
969
+ #
970
+
971
+ def tokenize_number
972
+ start_col = @column
973
+ value = ""
974
+
975
+ # Handle negative numbers
976
+ if current_char == "-"
977
+ value += current_char
978
+ advance
979
+ end
980
+
981
+ # Integer part
982
+ while @pos < @source.length && current_char =~ /[0-9]/
983
+ value += current_char
984
+ advance
985
+ end
986
+
987
+ # Check for range (..)
988
+ if current_char == "." && peek_char == "."
989
+ # :nocov: `value` only holds the integer part here — the decimal part is parsed
990
+ # after this range check — so value.include?(".") is always false (then-arm dead).
991
+ left = value.include?(".") ? value.to_f : value.to_i
992
+ # :nocov:
993
+ advance # First .
994
+ advance # Second .
995
+
996
+ # Parse right side of range (may be negative)
997
+ right_value = ""
998
+ right_value += current_char and advance if current_char == "-"
999
+ while @pos < @source.length && current_char =~ /[0-9.]/
1000
+ right_value += current_char
1001
+ advance
1002
+ end
1003
+
1004
+ # Handle duration suffix (ms, s, m, h, d, y)
1005
+ suffix = ""
1006
+ while @pos < @source.length && current_char =~ /[mshdyMHDY]/
1007
+ suffix += current_char
1008
+ advance
1009
+ end
1010
+
1011
+ right = right_value.include?(".") ? right_value.to_f : right_value.to_i
1012
+ emit(RANGE, { min: left, max: right, suffix: suffix.empty? ? nil : suffix.downcase })
1013
+ return
1014
+ end
1015
+
1016
+ # Decimal part
1017
+ if current_char == "." && peek_char =~ /[0-9]/
1018
+ value += current_char
1019
+ advance
1020
+ while @pos < @source.length && current_char =~ /[0-9]/
1021
+ value += current_char
1022
+ advance
1023
+ end
1024
+ end
1025
+
1026
+ # Check for percent
1027
+ if current_char == "%"
1028
+ emit(NUMBER, value.to_i)
1029
+ emit(PERCENT, "%")
1030
+ advance
1031
+ return
1032
+ end
1033
+
1034
+ # Check for duration suffix
1035
+ suffix = ""
1036
+ while @pos < @source.length && current_char =~ /[mshdyMHDY]/
1037
+ suffix += current_char
1038
+ advance
1039
+ end
1040
+
1041
+ num = value.include?(".") ? value.to_f : value.to_i
1042
+ if suffix.empty?
1043
+ emit(NUMBER, num)
1044
+ else
1045
+ emit(NUMBER, { value: num, suffix: suffix.downcase })
1046
+ end
1047
+ end
1048
+
1049
+ # Tokenize an identifier or keyword
1050
+ #
1051
+ # Determines if the identifier is a:
1052
+ # - Keyword (if, true, false, Ref)
1053
+ # - Known type (uuid, text, etc.)
1054
+ # - Schema reference (starts with uppercase)
1055
+ # - Field name (followed by :)
1056
+ # - Regular identifier
1057
+ #
1058
+ # @return [void]
1059
+ #
1060
+
1061
+ def tokenize_identifier_or_keyword
1062
+ name = read_identifier
1063
+
1064
+ # Check for ? suffix (optional/nullable)
1065
+ has_question = current_char == "?"
1066
+ if has_question
1067
+ advance
1068
+ end
1069
+
1070
+ # Check if followed by : (field definition)
1071
+ # But NOT if we're inside parentheses (named arguments)
1072
+ skip_whitespace
1073
+ if current_char == ":" && @paren_depth.zero?
1074
+ emit(FIELD_NAME, has_question ? "#{name}?" : name)
1075
+ return
1076
+ end
1077
+
1078
+ # Handle keywords (these apply everywhere)
1079
+ if KEYWORDS.key?(name)
1080
+ emit(KEYWORDS[name], name)
1081
+ emit(QUESTION, "?") if has_question
1082
+ return
1083
+ end
1084
+
1085
+ # Inside parentheses, treat as identifiers (for named args/enum values)
1086
+ if @paren_depth > 0
1087
+ emit(IDENTIFIER, name)
1088
+ emit(QUESTION, "?") if has_question
1089
+ return
1090
+ end
1091
+
1092
+ # Handle known types (only at field-definition level)
1093
+ if KNOWN_TYPES.include?(name)
1094
+ emit(TYPE, has_question ? "#{name}?" : name)
1095
+ return
1096
+ end
1097
+
1098
+ # Check if starts with uppercase (schema reference)
1099
+ if name[0] =~ /[A-Z]/
1100
+ emit(TYPE, has_question ? "#{name}?" : name)
1101
+ return
1102
+ end
1103
+
1104
+ # Regular identifier
1105
+ emit(IDENTIFIER, name)
1106
+ emit(QUESTION, "?") if has_question
1107
+ end
1108
+
1109
+ # Tokenize a symbol literal (:identifier)
1110
+ #
1111
+ # Symbols are colon-prefixed identifiers like :ar, :en, :locale
1112
+ #
1113
+ # @return [void]
1114
+ #
1115
+
1116
+ def tokenize_symbol
1117
+ advance # Skip the colon
1118
+ name = read_identifier
1119
+ emit(SYMBOL, name.to_sym)
1120
+ end
1121
+
1122
+ # Read an identifier from current position
1123
+ #
1124
+ # Identifiers consist of letters, digits, and underscores.
1125
+ #
1126
+ # @return [String] the identifier
1127
+ #
1128
+
1129
+ def read_identifier
1130
+ value = ""
1131
+ while @pos < @source.length && current_char =~ /[A-Za-z0-9_]/
1132
+ value += current_char
1133
+ advance
1134
+ end
1135
+ value
1136
+ end
1137
+
1138
+ # Expect a specific character at current position
1139
+ #
1140
+ # @param expected [String] the expected character
1141
+ # @raise [SyntaxError] if character doesn't match
1142
+ # @return [void]
1143
+ #
1144
+
1145
+ def expect_char(expected)
1146
+ return if current_char == expected
1147
+
1148
+ error("Expected '#{expected}', got '#{current_char}'")
1149
+ end
1150
+ end
1151
+ end
1152
+ end