Class: Synthra::Parser::Parser
- Inherits:
-
Object
- Object
- Synthra::Parser::Parser
- Includes:
- Tokens
- Defined in:
- lib/synthra/parser/parser.rb
Overview
Recursive descent parser for the Synthra language
Converts a token stream from the Lexer into an AST that represents the structure of the DSL. The AST can then be converted to Schema objects for data generation.
Constant Summary collapse
- LOCALE_CODES =
Known locale codes (two-letter or full locale identifiers)
%i[ en ar es fr de it pt ru zh ja ko hi 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 ].freeze
Instance Method Summary collapse
-
#advance ⇒ Token
private
Advance to the next token.
-
#current_token ⇒ Token
private
Get the current token.
-
#eof? ⇒ Boolean
private
Check if we've reached the end of tokens.
-
#error(message, token = current_token) ⇒ Object
private
Raise a syntax error.
-
#expect(type) ⇒ Token
private
Expect and consume a specific token type.
-
#formula_token_text(token) ⇒ Object
private
Render a single token as a fragment of a formula expression string.
-
#initialize ⇒ Parser
constructor
Create a new Parser instance.
-
#locale_code?(name) ⇒ Boolean
private
Check if an identifier is a known locale code.
-
#parse(source) ⇒ Array<AST::SchemaNode>, AST::ProgramNode
Parse DSL source code into AST.
-
#parse_api_definition ⇒ AST::ApiDefinitionNode
private
Parse an API definition: api METHOD /path:.
-
#parse_arg_value ⇒ Object
private
Parse a single argument value.
-
#parse_array_args ⇒ Hash
private
Parse array type arguments.
-
#parse_array_type(arguments, line, nullable = false) ⇒ AST::ArrayTypeNode
private
Parse an array type from arguments.
-
#parse_as_args ⇒ Hash
private
Parse as: argument for types that support string/integer output.
-
#parse_behavior ⇒ AST::BehaviorNode
private
Parse a schema-level behavior.
-
#parse_behavior_value ⇒ Object?
private
Parse a behavior value.
-
#parse_boolean_args ⇒ Hash
private
Parse boolean type arguments.
-
#parse_const_args ⇒ Hash
private
Parse const type arguments.
-
#parse_copy_args ⇒ Hash
private
Parse a copy() argument: copy("path.to.field") or copy(bare_field).
-
#parse_directive_string_value ⇒ String
private
Parse a directive string value (for headers).
-
#parse_directive_value ⇒ Object
private
Parse a directive value (number, identifier).
-
#parse_enum_values ⇒ Array<AST::EnumValueNode>
private
Parse enum values with optional probabilities.
-
#parse_field ⇒ AST::FieldNode
private
Parse a field definition.
-
#parse_field_behavior ⇒ AST::BehaviorNode
private
Parse a field-level behavior.
-
#parse_formula_args ⇒ Hash
private
Parse a formula() argument list.
-
#parse_generic_args ⇒ Hash
private
Parse generic arguments (key:value pairs, ranges, etc.).
-
#parse_legacy ⇒ Array<AST::SchemaNode>
Parse legacy DSL format (SchemaName: ...).
-
#parse_map_by_field_args ⇒ Hash
private
Parse map_by_field arguments.
-
#parse_one_of_args ⇒ Hash
private
Parse one_of type arguments.
-
#parse_pattern_args(key) ⇒ Hash
private
Parse a single quoted/bare string argument for regex() and template().
-
#parse_program ⇒ AST::ProgramNode
Parse new Simulyra DSL format into ProgramNode.
-
#parse_reference(line) ⇒ AST::TypeNode
private
Parse a Ref() expression.
-
#parse_repeating_element_args ⇒ Hash
private
Parse repeating_element arguments — positional or named.
-
#parse_response_field ⇒ AST::ResponseFieldNode
private
Parse a response field in a scenario.
-
#parse_scenario ⇒ AST::ScenarioNode
private
Parse a scenario definition: scenario Name:.
-
#parse_scenario_directive ⇒ AST::DirectiveNode
private
Parse a scenario directive (@status, @probability, @header, @latency, @method).
-
#parse_schema ⇒ AST::SchemaNode
private
Parse a schema definition.
-
#parse_schema_definition ⇒ AST::SchemaDefinitionNode
private
Parse a schema definition with new syntax: schema Name:.
-
#parse_schema_reference ⇒ AST::SchemaReferenceNode
private
Parse a schema reference: schema(:Name) or schema(:Name).array.
-
#parse_shared_args ⇒ Hash
private
Parse shared type arguments.
-
#parse_symbol_arg ⇒ Symbol?
private
Parse a symbol argument (for custom functions).
-
#parse_type ⇒ AST::TypeNode
private
Parse a type expression.
-
#parse_type_arguments(type_name) ⇒ Hash
private
Parse type arguments based on type name.
-
#peek_token(offset = 1) ⇒ Token
private
Peek at a token ahead of current position.
-
#skip_newlines ⇒ void
private
Skip any newline tokens.
-
#uses_simulyra_syntax? ⇒ Boolean
Check if the source uses new Simulyra syntax.
Constructor Details
#initialize ⇒ Parser
Create a new Parser instance
69 70 71 72 |
# File 'lib/synthra/parser/parser.rb', line 69 def initialize @tokens = [] # Token array from lexer @pos = 0 # Current position in token array end |
Instance Method Details
#advance ⇒ Token (private)
Advance to the next token
209 210 211 212 213 |
# File 'lib/synthra/parser/parser.rb', line 209 def advance token = current_token @pos += 1 token end |
#current_token ⇒ Token (private)
Get the current token
190 191 192 |
# File 'lib/synthra/parser/parser.rb', line 190 def current_token @tokens[@pos] || Token.new(EOF, nil, line: 0, column: 0) end |
#eof? ⇒ Boolean (private)
Check if we've reached the end of tokens
220 221 222 |
# File 'lib/synthra/parser/parser.rb', line 220 def eof? @pos >= @tokens.length end |
#error(message, token = current_token) ⇒ Object (private)
Raise a syntax error
246 247 248 249 250 251 252 |
# File 'lib/synthra/parser/parser.rb', line 246 def error(, token = current_token) raise SyntaxError.new( , line: token.line, column: token.column ) end |
#expect(type) ⇒ Token (private)
Expect and consume a specific token type
231 232 233 234 235 236 237 |
# File 'lib/synthra/parser/parser.rb', line 231 def expect(type) token = current_token unless token.type?(type) error("Expected #{type}, got #{token.type}", token) end advance end |
#formula_token_text(token) ⇒ Object (private)
Render a single token as a fragment of a formula expression string.
1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 |
# File 'lib/synthra/parser/parser.rb', line 1381 def formula_token_text(token) case token.type when STAR then "*" when SLASH then "/" when PLUS then "+" when MINUS then "-" when LPAREN then "(" when RPAREN then ")" else token.value.to_s end end |
#locale_code?(name) ⇒ Boolean (private)
Check if an identifier is a known locale code
1653 1654 1655 |
# File 'lib/synthra/parser/parser.rb', line 1653 def locale_code?(name) LOCALE_CODES.include?(name.to_s.downcase.to_sym) end |
#parse(source) ⇒ Array<AST::SchemaNode>, AST::ProgramNode
Parse DSL source code into AST
This is the main entry point for parsing. It tokenizes the source using the Lexer, then parses the tokens into schema nodes.
Supports both legacy syntax (SchemaName:) and new Simulyra syntax (schema Name:, api METHOD /path:, scenario Name:).
98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/synthra/parser/parser.rb', line 98 def parse(source) @tokens = Lexer.new(source).tokenize @pos = 0 # Detect if this is new Simulyra syntax or legacy syntax if uses_simulyra_syntax? parse_program else parse_legacy end end |
#parse_api_definition ⇒ AST::ApiDefinitionNode (private)
Parse an API definition: api METHOD /path:
Grammar: API_KEYWORD HTTP_METHOD PATH COLON NEWLINE INDENT scenario* DEDENT
379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 |
# File 'lib/synthra/parser/parser.rb', line 379 def parse_api_definition api_kw_token = expect(API_KEYWORD) method_token = expect(HTTP_METHOD) http_method = method_token.value path_token = expect(PATH) path = path_token.value expect(COLON) skip_newlines scenarios = [] # Parse indented block of scenarios if current_token.type?(INDENT) api_indent_level = current_token.value advance skip_newlines loop do skip_newlines # :nocov: defensive guard — the loop exits via the non-scenario `else break` # below before EOF is reached, so this break's then-arm is unreachable. break if eof? || current_token.type?(EOF) # :nocov: # Check if we've returned to API's indent level (DEDENT) # DEDENT token value indicates the new indent level if current_token.type?(DEDENT) # Only break if this is the final DEDENT for the API block # (returning to level 0 or lower than API's content level) # :nocov: a DEDENT inside the scenario loop always returns below the API # content level, so the value-not-less-than else-arm is unreachable. if current_token.value < api_indent_level advance break end # :nocov: end if current_token.type?(SCENARIO_KEYWORD) scenarios << parse_scenario else break end end end AST::ApiDefinitionNode.new( method: http_method, path: path, scenarios: scenarios, line: api_kw_token.line ) end |
#parse_arg_value ⇒ Object (private)
Parse a single argument value
1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 |
# File 'lib/synthra/parser/parser.rb', line 1535 def parse_arg_value token = current_token case token.type when NUMBER advance token.value when TRUE_KW advance true when FALSE_KW advance false when STRING advance token.value when SYMBOL # Handle symbol literals like :ar, :en, :locale advance token.value when IDENTIFIER advance token.value when TYPE # Handle uppercase identifiers like USD, HTTP, etc. advance token.value when RANGE advance token.value[:min]..token.value[:max] else advance token.value end end |
#parse_array_args ⇒ Hash (private)
Parse array type arguments
Supports both positional and named arguments:
- Positional: array(text, 1..5) or array(OrderItem, 3)
- Named: array(element: text, size: 1..5)
Grammar: (element_type | element: type) (COMMA (size_spec | size: spec))? size_spec := RANGE | NUMBER
1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 |
# File 'lib/synthra/parser/parser.rb', line 1004 def parse_array_args args = {} # Check for named arguments first (element: type, size: range) # :nocov: peek_token never returns nil (it falls back to a synthetic EOF token), so # the `&.` nil-receiver else-arm in this condition is unreachable. if current_token.type?(IDENTIFIER) && current_token.value.to_s == "element" && peek_token(1)&.type?(COLON) # :nocov: # Named argument: element: type advance # Skip "element" expect(COLON) if current_token.type?(TYPE) || current_token.type?(IDENTIFIER) args[:element] = current_token.value advance end # Check for size: spec if current_token.type?(COMMA) advance # :nocov: peek_token never returns nil (synthetic EOF fallback), so the `&.` # nil-receiver else-arm in this condition is unreachable. if current_token.type?(IDENTIFIER) && current_token.value.to_s == "size" && peek_token(1)&.type?(COLON) # :nocov: advance # Skip "size" expect(COLON) if current_token.type?(RANGE) args[:size] = current_token.value advance elsif current_token.type?(NUMBER) min = current_token.value advance if current_token.type?(RANGE) args[:size] = current_token.value advance else args[:size] = { min: min, max: min } end end end end else # Positional arguments: element_type (COMMA size_spec)? if current_token.type?(TYPE) || current_token.type?(IDENTIFIER) args[:element] = current_token.value advance end if current_token.type?(COMMA) advance # Second arg is size range if current_token.type?(RANGE) range = current_token.value args[:size] = range advance elsif current_token.type?(NUMBER) min = current_token.value advance if current_token.type?(RANGE) range = current_token.value args[:size] = range advance else args[:size] = { min: min, max: min } end end end end args end |
#parse_array_type(arguments, line, nullable = false) ⇒ AST::ArrayTypeNode (private)
Parse an array type from arguments
Parse an array type from arguments
Creates an ArrayTypeNode from parsed arguments containing element type and size specification.
837 838 839 840 841 842 843 844 845 846 847 |
# File 'lib/synthra/parser/parser.rb', line 837 def parse_array_type(arguments, line, nullable = false) element_type = arguments[:element] || arguments.values.first size_range = arguments[:size] || arguments[:range] || { min: 0, max: 10 } AST::ArrayTypeNode.new( element_type: element_type, size_range: size_range, nullable: nullable, line: line ) end |
#parse_as_args ⇒ Hash (private)
Parse as: argument for types that support string/integer output
Grammar: (as: (string | integer))? Example: snowflake_id(as: string) or snowflake_id(as: integer)
1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 |
# File 'lib/synthra/parser/parser.rb', line 1234 def parse_as_args args = {} # Check for as: argument # :nocov: peek_token never returns nil (synthetic EOF fallback), so the `&.` # nil-receiver else-arm in this condition is unreachable. if current_token.type?(IDENTIFIER) && current_token.value.to_s == "as" && peek_token(1)&.type?(COLON) # :nocov: advance # Skip "as" expect(COLON) if current_token.type?(IDENTIFIER) || current_token.type?(TYPE) args[:as] = current_token.value.to_s advance end end args end |
#parse_behavior ⇒ AST::BehaviorNode (private)
Parse a schema-level behavior
Grammar: AT_BEHAVIOR value? NEWLINE
1584 1585 1586 1587 1588 1589 1590 1591 1592 |
# File 'lib/synthra/parser/parser.rb', line 1584 def parse_behavior token = expect(AT_BEHAVIOR) name = token.value value = parse_behavior_value skip_newlines AST::BehaviorNode.new(name: name, value: value, line: token.line) end |
#parse_behavior_value ⇒ Object? (private)
Parse a behavior value
Handles:
- Ranges: 100..500ms
- Percentages: 10%
- Numbers: 500
- Duration values: 500ms, 2s
Parse a behavior value
Handles various behavior value formats including ranges with duration suffixes, percentages, and plain numbers.
1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 |
# File 'lib/synthra/parser/parser.rb', line 1627 def parse_behavior_value if current_token.type?(RANGE) range = current_token.value advance { min: range[:min], max: range[:max], suffix: range[:suffix] } elsif current_token.type?(NUMBER) num = current_token.value advance if current_token.type?(PERCENT) advance num # Percentage as integer elsif num.is_a?(Hash) { value: num[:value], suffix: num[:suffix] } else num end else nil end end |
#parse_boolean_args ⇒ Hash (private)
Parse boolean type arguments
Handles true:X% and false:Y% probability specifications.
965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 |
# File 'lib/synthra/parser/parser.rb', line 965 def parse_boolean_args args = {} loop do break if current_token.type?(RPAREN) if current_token.type?(TRUE_KW) || current_token.type?(FALSE_KW) bool_val = current_token.type?(TRUE_KW) advance if current_token.type?(COLON) advance num_token = expect(NUMBER) expect(PERCENT) args[bool_val ? :true_probability : :false_probability] = num_token.value end else advance end break unless current_token.type?(COMMA) advance end args end |
#parse_const_args ⇒ Hash (private)
Parse const type arguments
Grammar: STRING | NUMBER | TRUE_KW | FALSE_KW | IDENTIFIER Example: const("message_create") or const(42) or const(true)
1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 |
# File 'lib/synthra/parser/parser.rb', line 1262 def parse_const_args args = {} case current_token.type when STRING args[:value] = current_token.value advance when NUMBER args[:value] = current_token.value advance when TRUE_KW args[:value] = true advance when FALSE_KW args[:value] = false advance when IDENTIFIER args[:value] = current_token.value.to_s advance end args end |
#parse_copy_args ⇒ Hash (private)
Parse a copy() argument: copy("path.to.field") or copy(bare_field).
Stores the referenced path as a String under :path so the Copy generator (and PathValidator) can resolve it. The bare-identifier form previously fell through to parse_generic_args and was stored as a Symbol :value, which crashed the validator (Symbol#split).
1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 |
# File 'lib/synthra/parser/parser.rb', line 1296 def parse_copy_args args = {} case current_token.type when STRING args[:path] = current_token.value.to_s advance when IDENTIFIER, TYPE args[:path] = current_token.value.to_s advance end args end |
#parse_directive_string_value ⇒ String (private)
Parse a directive string value (for headers)
557 558 559 560 561 562 563 564 565 566 567 568 569 |
# File 'lib/synthra/parser/parser.rb', line 557 def parse_directive_string_value if current_token.type?(STRING) value = current_token.value advance value elsif current_token.type?(IDENTIFIER) || current_token.type?(TYPE) value = current_token.value advance value.to_s else "" end end |
#parse_directive_value ⇒ Object (private)
Parse a directive value (number, identifier)
534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 |
# File 'lib/synthra/parser/parser.rb', line 534 def parse_directive_value if current_token.type?(NUMBER) value = current_token.value advance # Skip percentage sign if present if current_token.type?(PERCENT) advance end value elsif current_token.type?(IDENTIFIER) value = current_token.value advance value else nil end end |
#parse_enum_values ⇒ Array<AST::EnumValueNode> (private)
Parse enum values with optional probabilities
Grammar: value (:NUMBER%)? (COMMA value (:NUMBER%)?)*
914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 |
# File 'lib/synthra/parser/parser.rb', line 914 def parse_enum_values values = [] total_probability = 0 loop do break if current_token.type?(RPAREN) value_token = advance value = value_token.value probability = nil # Check for :probability% syntax if current_token.type?(COLON) advance num_token = expect(NUMBER) expect(PERCENT) probability = num_token.value total_probability += probability end values << AST::EnumValueNode.new(value: value, probability: probability) break unless current_token.type?(COMMA) advance # Skip comma end # Validate total probability doesn't exceed 100% if total_probability > 100 raise InvalidProbabilityError.new(total_probability, line: current_token.line) end # Distribute remaining probability to unspecified values unspecified = values.count { |v| v.probability.nil? } if unspecified.positive? && total_probability < 100 remainder = (100 - total_probability) / unspecified values.each do |v| v.instance_variable_set(:@probability, remainder) if v.probability.nil? end end values end |
#parse_field ⇒ AST::FieldNode (private)
Parse a field definition
Grammar: FIELD_NAME COLON type behavior* condition? NEWLINE
604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 |
# File 'lib/synthra/parser/parser.rb', line 604 def parse_field field_token = expect(FIELD_NAME) field_name = field_token.value optional = field_name.end_with?("?") clean_name = field_name.delete_suffix("?") expect(COLON) type_node = parse_type # Parse field-level behaviors (@partial_data, @latency, etc.) behaviors = [] while current_token.type?(AT_BEHAVIOR) behaviors << parse_field_behavior end # Parse condition (if field_name) condition = nil if current_token.type?(IF) advance cond_token = expect(IDENTIFIER) condition = AST::ConditionNode.new(field_name: cond_token.value, line: cond_token.line) end skip_newlines AST::FieldNode.new( name: clean_name, type_node: type_node, optional: optional, condition: condition, behaviors: behaviors, line: field_token.line ) end |
#parse_field_behavior ⇒ AST::BehaviorNode (private)
Parse a field-level behavior
Grammar: AT_BEHAVIOR value?
1601 1602 1603 1604 1605 1606 1607 |
# File 'lib/synthra/parser/parser.rb', line 1601 def parse_field_behavior token = expect(AT_BEHAVIOR) name = token.value value = parse_behavior_value AST::BehaviorNode.new(name: name, value: value, line: token.line) end |
#parse_formula_args ⇒ Hash (private)
Parse a formula() argument list.
Two supported forms:
- Named: formula(expression: "a * b") / formula(expr: "a * b")
— the documented, eval-safe form; delegated to parse_generic_args
which already maps
expression:/expr:to the matching key. - Bare: formula(a * b + 2) — reconstructs the raw expression text from the token stream and stores it under :expression. This is the form whose arithmetic operators (STAR/SLASH/PLUS/MINUS) used to crash the lexer.
Evaluation stays gated behind allow_eval in the generator either way.
1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 |
# File 'lib/synthra/parser/parser.rb', line 1355 def parse_formula_args # Named form: leading IDENTIFIER ':' — let the generic parser handle it # so formula(expression: "...") keeps mapping to args[:expression]. return parse_generic_args if current_token.type?(IDENTIFIER) && peek_token.type?(COLON) parts = [] depth = 0 loop do tok = current_token break if tok.type?(EOF) || tok.type?(NEWLINE) # The closing ) of the formula(...) call is at depth 0; inner grouping # parens (e.g. formula((p + 1) * 2)) raise/lower depth so they don't # terminate the loop early. break if tok.type?(RPAREN) && depth.zero? depth += 1 if tok.type?(LPAREN) depth -= 1 if tok.type?(RPAREN) parts << formula_token_text(tok) advance end { expression: parts.join(" ").strip } end |
#parse_generic_args ⇒ Hash (private)
Parse generic arguments (key:value pairs, ranges, etc.)
Handles various argument formats:
- Ranges: 18..80
- Named args: min:10, max:100
- Percentages: 50%
- Booleans: true, false
- Numbers: 42
1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 |
# File 'lib/synthra/parser/parser.rb', line 1469 def parse_generic_args args = {} loop do break if current_token.type?(RPAREN) # Handle range like 18..80 if current_token.type?(RANGE) range = current_token.value args[:range] = range[:min]..range[:max] advance # Handle named args like min:10, max:100 elsif current_token.type?(IDENTIFIER) name = current_token.value.to_sym advance if current_token.type?(COLON) advance value = parse_arg_value args[name] = value else # Single identifier with no colon: check if it's a known locale code if locale_code?(name) args[:locale] = name else args[:value] = name end end # Handle number elsif current_token.type?(NUMBER) num = current_token.value advance if current_token.type?(PERCENT) advance args[:probability] = num else args[:value] = num end # Handle true/false elsif current_token.type?(TRUE_KW) || current_token.type?(FALSE_KW) args[:value] = current_token.type?(TRUE_KW) advance # Handle string like "user_id" or "users.sender.id" elsif current_token.type?(STRING) args[:path] = current_token.value advance else advance end break unless current_token.type?(COMMA) advance end args end |
#parse_legacy ⇒ Array<AST::SchemaNode>
Parse legacy DSL format (SchemaName: ...)
124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/synthra/parser/parser.rb', line 124 def parse_legacy schemas = [] while !eof? && !current_token.type?(EOF) skip_newlines # :nocov: defensive guard — the enclosing `while` already exits on EOF, so this # break's then-arm is unreachable. break if eof? || current_token.type?(EOF) # :nocov: schemas << parse_schema skip_newlines end schemas end |
#parse_map_by_field_args ⇒ Hash (private)
Parse map_by_field arguments
Grammar: STRING COMMA entry (COMMA entry)* entry := IDENTIFIER COLON IDENTIFIER
1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 |
# File 'lib/synthra/parser/parser.rb', line 1089 def parse_map_by_field_args args = { entries: {} } # First argument: key field name (string) if current_token.type?(STRING) args[:key_field] = current_token.value advance else args[:key_field] = "id" # Default end # Remaining arguments: name: SchemaType or name: shared(SchemaType) pairs while current_token.type?(COMMA) advance # Skip comma # Parse entry name if current_token.type?(IDENTIFIER) entry_name = current_token.value.to_sym advance # Expect colon if current_token.type?(COLON) advance # Check if it's a shared() wrapper if current_token.type?(IDENTIFIER) && current_token.value.to_s == "shared" advance # Skip "shared" expect(LPAREN) if current_token.type?(IDENTIFIER) || current_token.type?(TYPE) schema_name = current_token.value # Mark as shared by storing a hash instead of just the name args[:entries][entry_name] = { schema: schema_name.to_s, shared: true } advance end expect(RPAREN) elsif current_token.type?(IDENTIFIER) || current_token.type?(TYPE) # Regular schema type schema_name = current_token.value args[:entries][entry_name] = schema_name.to_s advance end end end end args end |
#parse_one_of_args ⇒ Hash (private)
Parse one_of type arguments
Grammar: SchemaName (COMMA SchemaName)* | SchemaName:PERCENTAGE (COMMA SchemaName:PERCENTAGE)* Example: one_of(MessageCreate, TypingIndicator, ReadReceipt) Example with weights: one_of(CommonPayload:80%, RarePayload:20%)
1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 |
# File 'lib/synthra/parser/parser.rb', line 1184 def parse_one_of_args args = { schemas: [] } loop do break if current_token.type?(RPAREN) if current_token.type?(IDENTIFIER) || current_token.type?(TYPE) schema_name = current_token.value.to_s advance # Check for weight (percentage) if current_token.type?(COLON) advance if current_token.type?(NUMBER) weight = current_token.value advance # Skip optional % sign # :nocov: the lexer never emits a standalone "%" IDENTIFIER token after a # weight literal, so this conditional advance's then-arm is unreachable. advance if current_token.type?(IDENTIFIER) && current_token.value.to_s == "%" # :nocov: args[:schemas] << { schema: schema_name, weight: weight } else args[:schemas] << schema_name end else args[:schemas] << schema_name end end # Check for comma to continue if current_token.type?(COMMA) advance else break end end args end |
#parse_pattern_args(key) ⇒ Hash (private)
Parse a single quoted/bare string argument for regex() and template().
regex("\d3") and template("Hello $a") were previously routed through parse_generic_args, which stored the string under :path — so the generators (reading :pattern / :template) saw nil and returned "".
Also accepts the explicit named form regex(pattern: "...") / template(template: "...") by delegating to parse_generic_args.
1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 |
# File 'lib/synthra/parser/parser.rb', line 1323 def parse_pattern_args(key) # Named form (pattern:/template:/etc.) — let parse_generic_args map it. return parse_generic_args if current_token.type?(IDENTIFIER) && peek_token.type?(COLON) args = {} if current_token.type?(STRING) args[key] = current_token.value.to_s advance elsif current_token.type?(IDENTIFIER) || current_token.type?(TYPE) args[key] = current_token.value.to_s advance end args end |
#parse_program ⇒ AST::ProgramNode
Parse new Simulyra DSL format into ProgramNode
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/synthra/parser/parser.rb', line 144 def parse_program schemas = [] apis = [] legacy_schemas = [] while !eof? && !current_token.type?(EOF) skip_newlines # :nocov: defensive guard — the enclosing `while` already exits on EOF, so this # break's then-arm is unreachable. break if eof? || current_token.type?(EOF) # :nocov: case current_token.type when SCHEMA_KEYWORD schemas << parse_schema_definition when API_KEYWORD apis << parse_api_definition when SCHEMA_NAME # Legacy schema definition in mixed content legacy_schemas << parse_schema else error("Expected 'schema', 'api', or schema name", current_token) end skip_newlines end AST::ProgramNode.new( schemas: schemas, apis: apis, legacy_schemas: legacy_schemas, line: 1 ) end |
#parse_reference(line) ⇒ AST::TypeNode (private)
Parse a Ref() expression
Grammar: REF LPAREN IDENTIFIER DOT path RPAREN path := IDENTIFIER (DOT IDENTIFIER)*
785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 |
# File 'lib/synthra/parser/parser.rb', line 785 def parse_reference(line) expect(LPAREN) schema_token = advance schema_name = schema_token.value unless current_token.type?(DOT) error("ref(#{schema_name}) needs a field — write ref(#{schema_name}.<field>), e.g. ref(#{schema_name}.id)") end expect(DOT) field_token = advance field_path = field_token.value # Handle nested paths like address.city while current_token.type?(DOT) advance next_token = advance field_path = "#{field_path}.#{next_token.value}" end expect(RPAREN) ref_node = AST::ReferenceNode.new( schema_name: schema_name, field_path: field_path, line: line ) # Wrap in TypeNode with name "reference" AST::TypeNode.new( name: "reference", arguments: { ref: ref_node }, line: line ) end |
#parse_repeating_element_args ⇒ Hash (private)
Parse repeating_element arguments — positional or named.
repeating_element(uuid, 3) => { type: "uuid", count: 3 } repeating_element(type: uuid, count: 3) => { type: "uuid", count: 3 } repeating_element("x", 3) => { value: "x", count: 3 }
A bare type/identifier is the element type, a bare number is the count, a bare string is a literal value to repeat. Without this the positional form fell through to parse_generic_args and mis-mapped both args onto :value (returning the count as a string).
1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 |
# File 'lib/synthra/parser/parser.rb', line 1425 def parse_repeating_element_args args = {} loop do break if current_token.type?(RPAREN) if current_token.type?(IDENTIFIER) && peek_token.type?(COLON) name = current_token.value.to_sym advance advance args[name] = parse_arg_value elsif current_token.type?(NUMBER) args[:count] = current_token.value advance elsif current_token.type?(STRING) args[:value] = current_token.value advance elsif current_token.type?(IDENTIFIER) || current_token.type?(TYPE) args[:type] = current_token.value advance else advance end break unless current_token.type?(COMMA) advance end args end |
#parse_response_field ⇒ AST::ResponseFieldNode (private)
Parse a response field in a scenario
Grammar: FIELD_NAME COLON type NEWLINE
578 579 580 581 582 583 584 585 586 587 588 589 590 |
# File 'lib/synthra/parser/parser.rb', line 578 def parse_response_field field_token = expect(FIELD_NAME) field_name = field_token.value.delete_suffix("?") expect(COLON) type_node = parse_type skip_newlines AST::ResponseFieldNode.new( name: field_name, type_node: type_node, line: field_token.line ) end |
#parse_scenario ⇒ AST::ScenarioNode (private)
Parse a scenario definition: scenario Name:
Grammar: SCENARIO_KEYWORD IDENTIFIER COLON NEWLINE INDENT (directive | field)* DEDENT
440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 |
# File 'lib/synthra/parser/parser.rb', line 440 def parse_scenario scenario_kw_token = expect(SCENARIO_KEYWORD) name_token = expect(IDENTIFIER) name = name_token.value expect(COLON) skip_newlines directives = [] response_fields = [] # Parse indented block of directives and fields if current_token.type?(INDENT) advance skip_newlines while !eof? && !current_token.type?(DEDENT) && !current_token.type?(EOF) skip_newlines # :nocov: defensive guard — the enclosing `while` already exits on DEDENT/EOF, # so this break's then-arm is unreachable. break if current_token.type?(DEDENT) || current_token.type?(EOF) # :nocov: if current_token.type?(AT_BEHAVIOR) directives << parse_scenario_directive elsif current_token.type?(FIELD_NAME) response_fields << parse_response_field else break end skip_newlines end advance if current_token.type?(DEDENT) end AST::ScenarioNode.new( name: name, directives: directives, response_fields: response_fields, line: scenario_kw_token.line ) end |
#parse_scenario_directive ⇒ AST::DirectiveNode (private)
Parse a scenario directive (@status, @probability, @header, @latency, @method)
Grammar: AT_BEHAVIOR value
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 |
# File 'lib/synthra/parser/parser.rb', line 490 def parse_scenario_directive token = expect(AT_BEHAVIOR) directive_name = token.value.to_s case directive_name when "status" status_code = parse_directive_value AST::StatusDirective.new(status_code: status_code.to_i, line: token.line) when "probability" percentage = parse_directive_value # Handle percentage format (e.g., "80" from "80%") AST::ProbabilityDirective.new(percentage: percentage.to_i, line: token.line) when "latency" latency_value = parse_behavior_value if latency_value.is_a?(Hash) AST::LatencyDirective.new( min_ms: latency_value[:min], max_ms: latency_value[:max], line: token.line ) else AST::LatencyDirective.new(min_ms: latency_value, max_ms: latency_value, line: token.line) end when "header" key = expect(IDENTIFIER).value expect(COLON) value = parse_directive_string_value AST::HeaderDirective.new(key: key, value: value, line: token.line) when "method" method_value = expect(IDENTIFIER).value.downcase.to_sym AST::MethodDirective.new(http_method: method_value, line: token.line) else # Generic behavior - convert to behavior node for compatibility value = parse_behavior_value skip_newlines AST::BehaviorNode.new(name: directive_name, value: value, line: token.line) end end |
#parse_schema ⇒ AST::SchemaNode (private)
Parse a schema definition
Grammar: SCHEMA_NAME COLON NEWLINE INDENT (behavior | field)* DEDENT
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/synthra/parser/parser.rb', line 275 def parse_schema token = expect(SCHEMA_NAME) name = token.value expect(COLON) skip_newlines fields = [] behaviors = [] # Parse indented block of fields and behaviors if current_token.type?(INDENT) advance skip_newlines while !eof? && !current_token.type?(DEDENT) && !current_token.type?(EOF) skip_newlines # :nocov: defensive guard — the enclosing `while` already exits on DEDENT/EOF, # so this break's then-arm is unreachable. break if current_token.type?(DEDENT) || current_token.type?(EOF) # :nocov: if current_token.type?(AT_BEHAVIOR) behaviors << parse_behavior elsif current_token.type?(FIELD_NAME) fields << parse_field else break end skip_newlines end advance if current_token.type?(DEDENT) end AST::SchemaNode.new( name: name, fields: fields, behaviors: behaviors, line: token.line ) end |
#parse_schema_definition ⇒ AST::SchemaDefinitionNode (private)
Parse a schema definition with new syntax: schema Name:
Grammar: SCHEMA_KEYWORD SCHEMA_NAME COLON NEWLINE INDENT field* DEDENT
329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 |
# File 'lib/synthra/parser/parser.rb', line 329 def parse_schema_definition schema_kw_token = expect(SCHEMA_KEYWORD) name_token = expect(SCHEMA_NAME) name = name_token.value expect(COLON) skip_newlines fields = [] behaviors = [] # Parse indented block of fields and behaviors if current_token.type?(INDENT) advance skip_newlines while !eof? && !current_token.type?(DEDENT) && !current_token.type?(EOF) skip_newlines # :nocov: defensive guard — the enclosing `while` already exits on DEDENT/EOF, # so this break's then-arm is unreachable. break if current_token.type?(DEDENT) || current_token.type?(EOF) # :nocov: if current_token.type?(AT_BEHAVIOR) behaviors << parse_behavior elsif current_token.type?(FIELD_NAME) fields << parse_field else break end skip_newlines end advance if current_token.type?(DEDENT) end AST::SchemaDefinitionNode.new( name: name, fields: fields, behaviors: behaviors, line: schema_kw_token.line ) end |
#parse_schema_reference ⇒ AST::SchemaReferenceNode (private)
Parse a schema reference: schema(:Name) or schema(:Name).array
Grammar: SCHEMA_KEYWORD LPAREN SCHEMA_REF RPAREN (DOT array_modifier)?
717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 |
# File 'lib/synthra/parser/parser.rb', line 717 def parse_schema_reference token = expect(SCHEMA_KEYWORD) expect(LPAREN) schema_ref_token = expect(SCHEMA_REF) schema_name = schema_ref_token.value expect(RPAREN) is_array = false array_range = nil is_optional = false # Check for modifiers: .array, .array(1..10), .optional while current_token.type?(DOT) advance # :nocov: the lexer always emits an IDENTIFIER token after a DOT (even for digits # or symbols), so the else-arm of this guard is unreachable. if current_token.type?(IDENTIFIER) # :nocov: modifier = current_token.value advance case modifier when "array" is_array = true # Check for array range: .array(1..10) if current_token.type?(LPAREN) advance if current_token.type?(RANGE) range = current_token.value array_range = range[:min]..range[:max] advance elsif current_token.type?(NUMBER) num = current_token.value array_range = num..num advance end expect(RPAREN) end when "optional" is_optional = true end end end # Check for ? suffix if current_token.type?(QUESTION) is_optional = true advance end AST::SchemaReferenceNode.new( schema_name: schema_name, array: is_array, array_range: array_range, optional: is_optional, line: token.line ) end |
#parse_shared_args ⇒ Hash (private)
Parse shared type arguments
Grammar: TypeName (COMMA key: STRING)? Example: shared(TwitterUser) or shared(TwitterUser, key: "recipient")
1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 |
# File 'lib/synthra/parser/parser.rb', line 1146 def parse_shared_args args = {} # First argument: type/schema name if current_token.type?(IDENTIFIER) || current_token.type?(TYPE) args[:type_name] = current_token.value.to_s advance end # Optional key: argument for custom cache key if current_token.type?(COMMA) advance # :nocov: peek_token never returns nil (synthetic EOF fallback), so the `&.` # nil-receiver else-arm in this condition is unreachable. if current_token.type?(IDENTIFIER) && current_token.value.to_s == "key" && peek_token(1)&.type?(COLON) # :nocov: advance # Skip "key" expect(COLON) if current_token.type?(STRING) args[:shared_key] = current_token.value advance end end end args end |
#parse_symbol_arg ⇒ Symbol? (private)
Parse a symbol argument (for custom functions)
Grammar: COLON? IDENTIFIER
1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 |
# File 'lib/synthra/parser/parser.rb', line 1400 def parse_symbol_arg if current_token.type?(COLON) advance end if current_token.type?(IDENTIFIER) || current_token.type?(SYMBOL) value = current_token.value.to_sym advance value end end |
#parse_type ⇒ AST::TypeNode (private)
Parse a type expression
Handles:
- Simple types: uuid, text, number
- Nullable types: User?
- Types with arguments: number(18..80), enum(a, b, c)
- References: Ref(User.id)
- Schema references: schema(:User), schema(:User).array
- Arrays: array(text, 1..5)
- Custom functions: custom(:my_function)
658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 |
# File 'lib/synthra/parser/parser.rb', line 658 def parse_type # Handle schema(:Name) reference if current_token.type?(SCHEMA_KEYWORD) return parse_schema_reference end type_token = advance # TYPE or schema reference type_name = type_token.value nullable = type_name.to_s.end_with?("?") clean_type = type_name.to_s.delete_suffix("?") arguments = {} # Handle Ref(Schema.field) - cross-schema reference if type_token.type?(REF) return parse_reference(type_token.line) end # Handle type arguments in parentheses if current_token.type?(LPAREN) advance arguments = parse_type_arguments(clean_type) expect(RPAREN) end # Check for ? suffix after arguments (e.g., text(20..100)? or User?) # The QUESTION token comes after RPAREN when type has arguments if current_token.type?(QUESTION) nullable = true advance end # Special handling for array type if clean_type == "array" return parse_array_type(arguments, type_token.line, nullable) end # Special handling for custom function type if clean_type == "custom" func_name = arguments[:function] || arguments.values.first return AST::CustomNode.new(function_name: func_name, line: type_token.line) end AST::TypeNode.new( name: clean_type, arguments: arguments, nullable: nullable, line: type_token.line ) end |
#parse_type_arguments(type_name) ⇒ Hash (private)
Parse type arguments based on type name
Different types have different argument formats:
- enum: comma-separated values with optional probabilities
- boolean: true/false with probabilities
- array: element type and size
- custom: symbol function name
- others: generic key:value pairs or ranges
867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 |
# File 'lib/synthra/parser/parser.rb', line 867 def parse_type_arguments(type_name) args = {} case type_name when "enum" args[:values] = parse_enum_values when "boolean" args.merge!(parse_boolean_args) when "array" args.merge!(parse_array_args) when "map_by_field" args.merge!(parse_map_by_field_args) when "shared" args.merge!(parse_shared_args) when "one_of" args.merge!(parse_one_of_args) when "snowflake_id", "unix_timestamp_ms" args.merge!(parse_as_args) when "custom" args[:function] = parse_symbol_arg when "const", "default_value", "default" args.merge!(parse_const_args) when "copy" args.merge!(parse_copy_args) when "regex" args.merge!(parse_pattern_args(:pattern)) when "template" args.merge!(parse_pattern_args(:template)) when "formula" args.merge!(parse_formula_args) when "repeating_element" args.merge!(parse_repeating_element_args) else args.merge!(parse_generic_args) end args end |
#peek_token(offset = 1) ⇒ Token (private)
Peek at a token ahead of current position
200 201 202 |
# File 'lib/synthra/parser/parser.rb', line 200 def peek_token(offset = 1) @tokens[@pos + offset] || Token.new(EOF, nil, line: 0, column: 0) end |
#skip_newlines ⇒ void (private)
This method returns an undefined value.
Skip any newline tokens
259 260 261 |
# File 'lib/synthra/parser/parser.rb', line 259 def skip_newlines advance while current_token.type?(NEWLINE) end |
#uses_simulyra_syntax? ⇒ Boolean
Check if the source uses new Simulyra syntax
115 116 117 |
# File 'lib/synthra/parser/parser.rb', line 115 def uses_simulyra_syntax? @tokens.any? { |t| t.type?(SCHEMA_KEYWORD) || t.type?(API_KEYWORD) || t.type?(SCENARIO_KEYWORD) } end |