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