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.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +40 -0
- data/.yardopts +18 -0
- data/ACCESS_DOCS.md +116 -0
- data/ADVANCED_FEATURES_SUMMARY.md +245 -0
- data/CHANGELOG.md +498 -0
- data/CODE_OF_CONDUCT.md +122 -0
- data/CONTRIBUTING.md +307 -0
- data/DOCUMENTATION.md +204 -0
- data/DOCUMENTATION_GUIDE.md +194 -0
- data/LICENSE +22 -0
- data/README.md +1458 -0
- data/Rakefile +128 -0
- data/benchmark/README.md +591 -0
- data/benchmark/batch_generation.rb +63 -0
- data/benchmark/engine_comparison.rb +295 -0
- data/benchmark/single_record.rb +108 -0
- data/benchmark/streaming.rb +74 -0
- data/docs/ACTIVERECORD_INFERENCE.md +524 -0
- data/docs/API_SERVER.md +278 -0
- data/docs/CONFIG_FILE.md +315 -0
- data/docs/DATA_CONTRACTS.md +312 -0
- data/docs/ENHANCED_REPL.md +304 -0
- data/docs/FACTORY_BOT.md +271 -0
- data/docs/GITHUB_ACTION.md +420 -0
- data/docs/GRAPHQL_EXPORT.md +303 -0
- data/docs/GRAPHQL_FEDERATION.md +651 -0
- data/docs/HOW_TO_GENERATE_DOCS.md +252 -0
- data/docs/LSP.md +189 -0
- data/docs/MIGRATION_GENERATOR.md +547 -0
- data/docs/MOCK_SERVER.md +280 -0
- data/docs/NATIVE_ENGINE.md +298 -0
- data/docs/OPENAPI_EXPORT.md +252 -0
- data/docs/PERFORMANCE_MODE.md +228 -0
- data/docs/PERSONAS.md +547 -0
- data/docs/PROPERTY_TESTING.md +284 -0
- data/docs/PROTOBUF_EXPORT.md +276 -0
- data/docs/QUALITY_METRICS.md +555 -0
- data/docs/QUICK_REFERENCE.md +88 -0
- data/docs/RAILS_ENGINE.md +404 -0
- data/docs/RAILS_INTEGRATION.md +454 -0
- data/docs/README.md +175 -0
- data/docs/REPL_QUICK_REFERENCE.md +149 -0
- data/docs/RUST_INTEGRATION_GUIDE.md +1376 -0
- data/docs/SCENARIOS.md +576 -0
- data/docs/SECURITY_TESTING.md +318 -0
- data/docs/SNAPSHOT_TESTING.md +308 -0
- data/docs/STRING_TEXT_TYPES_REFERENCE.md +512 -0
- data/docs/TERRAFORM_EXPORT.md +447 -0
- data/docs/TIME_TRAVEL.md +507 -0
- data/docs/USAGE_GUIDE.md +980 -0
- data/docs/WEBHOOK_SIMULATOR.md +579 -0
- data/docs/advanced_features.md +183 -0
- data/docs/api_reference.md +153 -0
- data/docs/behaviors.md +138 -0
- data/docs/best_practices.md +191 -0
- data/docs/dsl_reference.md +172 -0
- data/docs/examples.md +162 -0
- data/docs/generation_modes.md +125 -0
- data/docs/getting_started.md +92 -0
- data/docs/index.html +177 -0
- data/docs/strategic/CLOUD_REGISTRY_ARCHITECTURE.md +268 -0
- data/docs/strategic/SIMD_OPTIMIZATION_STRATEGY.md +153 -0
- data/docs/troubleshooting.md +215 -0
- data/docs/type_reference.md +224 -0
- data/examples/hostile_mode_demo.rb +64 -0
- data/exe/synthra +6 -0
- data/ext/synthra_native/Cargo.lock +972 -0
- data/ext/synthra_native/Cargo.toml +46 -0
- data/ext/synthra_native/extconf.rb +41 -0
- data/ext/synthra_native/src/generator.rs +397 -0
- data/ext/synthra_native/src/lib.rs +229 -0
- data/ext/synthra_native/src/types.rs +1485 -0
- data/lib/generators/synthra/install_generator.rb +86 -0
- data/lib/generators/synthra/templates/api_response.dsl +15 -0
- data/lib/generators/synthra/templates/fake_data.rake +116 -0
- data/lib/generators/synthra/templates/synthra.yml +31 -0
- data/lib/generators/synthra/templates/synthra_support.rb +19 -0
- data/lib/generators/synthra/templates/user.dsl +11 -0
- data/lib/synthra/activerecord_inference.rb +394 -0
- data/lib/synthra/api.rb +381 -0
- data/lib/synthra/api_server.rb +718 -0
- data/lib/synthra/behaviors/applicator.rb +165 -0
- data/lib/synthra/behaviors/base.rb +155 -0
- data/lib/synthra/behaviors/close_connection.rb +60 -0
- data/lib/synthra/behaviors/deprecated.rb +100 -0
- data/lib/synthra/behaviors/failure.rb +69 -0
- data/lib/synthra/behaviors/latency.rb +103 -0
- data/lib/synthra/behaviors/partial_data.rb +83 -0
- data/lib/synthra/behaviors/randomize_order.rb +74 -0
- data/lib/synthra/behaviors/registry.rb +238 -0
- data/lib/synthra/behaviors/simulate_error.rb +79 -0
- data/lib/synthra/cli/commands/base.rb +105 -0
- data/lib/synthra/cli/commands/diff.rb +227 -0
- data/lib/synthra/cli/commands/docs.rb +67 -0
- data/lib/synthra/cli/commands/export.rb +150 -0
- data/lib/synthra/cli/commands/generate.rb +72 -0
- data/lib/synthra/cli/commands/import.rb +80 -0
- data/lib/synthra/cli/commands/lint.rb +53 -0
- data/lib/synthra/cli/commands/live.rb +57 -0
- data/lib/synthra/cli/commands/seed.rb +99 -0
- data/lib/synthra/cli/commands/validate.rb +28 -0
- data/lib/synthra/cli.rb +2471 -0
- data/lib/synthra/config_file.rb +129 -0
- data/lib/synthra/configuration.rb +281 -0
- data/lib/synthra/contracts_registry.rb +408 -0
- data/lib/synthra/database_seeder.rb +268 -0
- data/lib/synthra/deterministic_ids.rb +218 -0
- data/lib/synthra/documentation_generator.rb +414 -0
- data/lib/synthra/engine.rb +251 -0
- data/lib/synthra/errors.rb +1169 -0
- data/lib/synthra/export/base.rb +85 -0
- data/lib/synthra/export/csv.rb +101 -0
- data/lib/synthra/export/graphql.rb +266 -0
- data/lib/synthra/export/graphql_federation.rb +377 -0
- data/lib/synthra/export/graphviz.rb +258 -0
- data/lib/synthra/export/javascript.rb +327 -0
- data/lib/synthra/export/json_data.rb +61 -0
- data/lib/synthra/export/json_schema.rb +290 -0
- data/lib/synthra/export/openapi.rb +514 -0
- data/lib/synthra/export/protobuf.rb +483 -0
- data/lib/synthra/export/python.rb +560 -0
- data/lib/synthra/export/sql.rb +381 -0
- data/lib/synthra/export/sql_insert.rb +152 -0
- data/lib/synthra/export/terraform.rb +501 -0
- data/lib/synthra/export/type_mapping.rb +389 -0
- data/lib/synthra/export/typescript.rb +300 -0
- data/lib/synthra/export/xml_data.rb +104 -0
- data/lib/synthra/export/yaml_data.rb +56 -0
- data/lib/synthra/export.rb +404 -0
- data/lib/synthra/factory_bot_integration.rb +157 -0
- data/lib/synthra/field.rb +440 -0
- data/lib/synthra/functions/registry.rb +104 -0
- data/lib/synthra/generator/context.rb +372 -0
- data/lib/synthra/generator/engine.rb +336 -0
- data/lib/synthra/generator/faker_adapter.rb +425 -0
- data/lib/synthra/generator/modes.rb +444 -0
- data/lib/synthra/generator/resolver.rb +256 -0
- data/lib/synthra/generator/rng.rb +273 -0
- data/lib/synthra/generator/streamer.rb +63 -0
- data/lib/synthra/generator/uniqueness.rb +118 -0
- data/lib/synthra/initializer.rb +97 -0
- data/lib/synthra/limits.rb +279 -0
- data/lib/synthra/live_preview.rb +518 -0
- data/lib/synthra/loader_config.rb +78 -0
- data/lib/synthra/lsp/server.rb +689 -0
- data/lib/synthra/migration_generator.rb +301 -0
- data/lib/synthra/mixin.rb +174 -0
- data/lib/synthra/mock_server.rb +458 -0
- data/lib/synthra/native_engine.rb +304 -0
- data/lib/synthra/openapi_importer.rb +228 -0
- data/lib/synthra/output/json_formatter.rb +40 -0
- data/lib/synthra/output/ndjson_formatter.rb +55 -0
- data/lib/synthra/parser/ast.rb +1287 -0
- data/lib/synthra/parser/lexer.rb +1152 -0
- data/lib/synthra/parser/parser.rb +1664 -0
- data/lib/synthra/parser/tokens.rb +461 -0
- data/lib/synthra/performance_mode.rb +364 -0
- data/lib/synthra/personas.rb +397 -0
- data/lib/synthra/property_testing.rb +172 -0
- data/lib/synthra/quality_metrics.rb +405 -0
- data/lib/synthra/rails_test_helper.rb +248 -0
- data/lib/synthra/registry.rb +533 -0
- data/lib/synthra/relationships.rb +193 -0
- data/lib/synthra/repl/enhanced_repl.rb +607 -0
- data/lib/synthra/repl/formatter.rb +191 -0
- data/lib/synthra/scenarios.rb +423 -0
- data/lib/synthra/schema.rb +605 -0
- data/lib/synthra/schema_inheritance.rb +104 -0
- data/lib/synthra/schema_versioning.rb +212 -0
- data/lib/synthra/snapshot_testing.rb +199 -0
- data/lib/synthra/time_travel.rb +338 -0
- data/lib/synthra/type_definitions.rb +274 -0
- data/lib/synthra/types/address_location/addresses.rb +125 -0
- data/lib/synthra/types/address_location/airports.rb +200 -0
- data/lib/synthra/types/address_location/banks_hospitals.rb +207 -0
- data/lib/synthra/types/address_location/locations.rb +406 -0
- data/lib/synthra/types/base.rb +48 -0
- data/lib/synthra/types/commerce_products/commerce.rb +119 -0
- data/lib/synthra/types/commerce_products/companies.rb +71 -0
- data/lib/synthra/types/commerce_products/construction.rb +126 -0
- data/lib/synthra/types/commerce_products/products.rb +205 -0
- data/lib/synthra/types/core/collections.rb +513 -0
- data/lib/synthra/types/core/defaults.rb +76 -0
- data/lib/synthra/types/core/enums.rb +102 -0
- data/lib/synthra/types/core/identifiers.rb +445 -0
- data/lib/synthra/types/core/primitives.rb +586 -0
- data/lib/synthra/types/core/references.rb +466 -0
- data/lib/synthra/types/core/sequences.rb +304 -0
- data/lib/synthra/types/crypto/crypto.rb +162 -0
- data/lib/synthra/types/date_time/dates.rb +387 -0
- data/lib/synthra/types/finance_banking/banking.rb +424 -0
- data/lib/synthra/types/finance_banking/credit_cards.rb +24 -0
- data/lib/synthra/types/finance_banking/identifiers.rb +93 -0
- data/lib/synthra/types/formula.rb +184 -0
- data/lib/synthra/types/health_medical/medical.rb +150 -0
- data/lib/synthra/types/hostile_payloads.rb +122 -0
- data/lib/synthra/types/json_array.rb +51 -0
- data/lib/synthra/types/media_entertainment/media.rb +62 -0
- data/lib/synthra/types/naughty_string.rb +30 -0
- data/lib/synthra/types/personal_names/chinese.rb +41 -0
- data/lib/synthra/types/personal_names/identifiers.rb +187 -0
- data/lib/synthra/types/personal_names/names.rb +246 -0
- data/lib/synthra/types/personal_names/national_id.rb +89 -0
- data/lib/synthra/types/personal_names/titles_suffixes.rb +41 -0
- data/lib/synthra/types/regex.rb +248 -0
- data/lib/synthra/types/registry.rb +182 -0
- data/lib/synthra/types/repeating_element.rb +50 -0
- data/lib/synthra/types/scenario.rb +29 -0
- data/lib/synthra/types/technology_internet/apps.rb +67 -0
- data/lib/synthra/types/technology_internet/communication.rb +162 -0
- data/lib/synthra/types/technology_internet/devices.rb +80 -0
- data/lib/synthra/types/technology_internet/formats.rb +139 -0
- data/lib/synthra/types/technology_internet/networking.rb +143 -0
- data/lib/synthra/types/template.rb +92 -0
- data/lib/synthra/types/text_content/business.rb +128 -0
- data/lib/synthra/types/text_content/colors.rb +70 -0
- data/lib/synthra/types/text_content/misc.rb +237 -0
- data/lib/synthra/types/text_content/security.rb +130 -0
- data/lib/synthra/types/text_content/text_generation.rb +532 -0
- data/lib/synthra/types/travel/travel.rb +150 -0
- data/lib/synthra/utils/string_distance.rb +133 -0
- data/lib/synthra/validator/dsl_validator.rb +339 -0
- data/lib/synthra/validator/path_validator.rb +615 -0
- data/lib/synthra/version.rb +35 -0
- data/lib/synthra/webhook_simulator.rb +341 -0
- data/lib/synthra.rb +259 -0
- data/schemas/address.dsl +16 -0
- data/schemas/api_response.dsl +8 -0
- data/schemas/error_payload.dsl +9 -0
- data/schemas/order.dsl +10 -0
- data/schemas/order_item.dsl +8 -0
- data/schemas/payment.dsl +11 -0
- data/schemas/social_post.dsl +13 -0
- data/schemas/user.dsl +10 -0
- data/scripts/batch_fix_all.rb +96 -0
- data/scripts/delete_old_files.rb +32 -0
- data/scripts/fix_all_grouped_files.rb +137 -0
- data/scripts/fix_all_indentation.rb +48 -0
- data/scripts/fix_all_syntax.rb +124 -0
- data/scripts/fix_grouped_files.rb +184 -0
- data/scripts/fix_syntax_errors.rb +108 -0
- data/scripts/group_domain_types.rb +112 -0
- data/scripts/group_domain_types_fixed.rb +150 -0
- data/scripts/merge_domains_to_one_file.rb +68 -0
- data/scripts/move_existing_types.rb +130 -0
- data/scripts/split_grouped_types.rb +142 -0
- data/tech_docs/README.md +134 -0
- data/tech_docs/advanced/streaming.md +405 -0
- data/tech_docs/advanced/thread_safety.md +253 -0
- data/tech_docs/api/overview.md +485 -0
- data/tech_docs/appendices/type_chart.md +193 -0
- data/tech_docs/basic_concepts.md +383 -0
- data/tech_docs/behaviors/overview.md +346 -0
- data/tech_docs/dsl/complex_types.md +790 -0
- data/tech_docs/dsl/core_types.md +464 -0
- data/tech_docs/dsl/datetime_types.md +325 -0
- data/tech_docs/dsl/field_modifiers.md +414 -0
- data/tech_docs/dsl/grammar.md +431 -0
- data/tech_docs/dsl/schema_definition.md +399 -0
- data/tech_docs/export/README.md +276 -0
- data/tech_docs/installation.md +273 -0
- data/tech_docs/integration/ci_cd.md +707 -0
- data/tech_docs/integration/ci_cd_guide.md +579 -0
- data/tech_docs/integration/factory_bot.md +485 -0
- data/tech_docs/integration/rails.md +630 -0
- data/tech_docs/integration/rspec.md +449 -0
- data/tech_docs/modes/overview.md +350 -0
- data/tech_docs/performance/NATIVE_RUST_EXTENSION.md +1270 -0
- data/tech_docs/performance/OPTIMIZATION_GUIDE.md +901 -0
- data/tech_docs/quick_start.md +256 -0
- data/tech_docs/templates/README.md +805 -0
- data/tech_docs/tutorials/advanced.md +371 -0
- data/tech_docs/tutorials/getting_started.md +189 -0
- data/tech_docs/tutorials/intermediate.md +231 -0
- data/tech_docs/tutorials/template_gallery.md +569 -0
- data/tech_example/01_basic_usage.rb +238 -0
- data/tech_example/02_types_demo.rb +336 -0
- data/tech_example/04_cli_usage.md +429 -0
- data/tech_example/05_database_seeding.rb +283 -0
- data/tech_example/07_rspec_integration.rb +359 -0
- data/tech_example/10_custom_types.rb +387 -0
- data/tech_example/12_behaviors.rb +372 -0
- data/tech_example/13_twitter_dm_example.rb +120 -0
- data/tech_example/14_exact_json_structure.rb +253 -0
- data/tech_example/16_lsp_server.rb +56 -0
- data/tech_example/17_property_testing.rb +92 -0
- data/tech_example/18_enhanced_repl.rb +120 -0
- data/tech_example/19_security_fuzzing.rb +145 -0
- data/tech_example/NEW_FEATURES.md +169 -0
- data/tech_example/README.md +118 -0
- data/tech_example/schemas/api_response.dsl +123 -0
- data/tech_example/schemas/ecommerce.dsl +121 -0
- data/tech_example/schemas/twitter_dm.dsl +109 -0
- data/tech_example/schemas/user.dsl +36 -0
- data/vscode-extension/README.md +246 -0
- data/vscode-extension/language-configuration.json +31 -0
- data/vscode-extension/package.json +55 -0
- data/vscode-extension/snippets/fakedatadsl.json +198 -0
- data/vscode-extension/syntaxes/fakedatadsl.tmLanguage.json +128 -0
- 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
|