# GdUnit generated TestSuite class_name ESCParserTest extends GdUnitTestSuite @warning_ignore("unused_parameter") @warning_ignore("return_value_discarded") func test_done_statement_does_not_consume_tokens_on_partial_match() -> void: # This fixture is intentionally valid up to the dialog option body: # `done` is in a dialog context, but `done x` is invalid because the parser # only accepts `done` followed immediately by a newline. var source := _load_fixture("done_partial_match.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("done_partial_match.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var done_index := _find_token_index(tokens, ESCTokenType.TokenType.DONE) var print_index := _find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "print") assert_int(done_index).is_greater_equal(0) assert_int(print_index).is_greater_equal(0) # Jump directly to the `done` token so this test exercises the statement # parser in isolation. We also force dialog context because `done` is only # legal inside dialogs. parser._current = done_index parser._dialog_level = 1 var statement = parser._statement() # We expect a parse error, but we specifically do not want the parser to # consume the `done` token before it discovers that the following token is # not a newline. If it advances here, later recovery code starts from the # wrong token and can corrupt the remaining parse. assert_object(statement).is_instanceof(ESCParseError) assert_bool(compiler.had_error).is_true() assert_int(parser._current).is_equal(done_index) assert_int(parser._peek().get_type()).is_equal(ESCTokenType.TokenType.DONE) assert_int(tokens[print_index].get_type()).is_equal(ESCTokenType.TokenType.IDENTIFIER) assert_str(tokens[print_index].get_lexeme()).is_equal("print") # Tightening the regression: the next statement must still be present in the # token stream after the failed `done` parse attempt. This protects against # accidental over-consumption beyond the failing statement. assert_int(_find_token_index_after(tokens, parser._current, ESCTokenType.TokenType.IDENTIFIER, "print")).is_equal(print_index) func test_parser_synchronizes_after_invalid_done_and_keeps_following_event() -> void: # This fixture places an invalid `done` at the start of an event body and # then follows it with a valid statement and a second event. The purpose is # to verify parser recovery at ASHES statement and event boundaries. var source := _load_fixture("synchronize_after_invalid_done.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("synchronize_after_invalid_done.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() # The parser should report an error for `done x`, but it should still recover # well enough to continue parsing later declarations. In particular, the # following event `:second` must survive recovery. assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(2) assert_object(statements[0]).is_not_null() assert_object(statements[1]).is_not_null() assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") func test_parser_synchronizes_after_invalid_if_and_keeps_following_event() -> void: # This fixture uses a malformed `if` statement inside an event body. The # missing colon forces a real statement parse error after the parser has # already committed to the `if` branch. Recovery must still preserve the # following top-level event declaration. var source := _load_fixture("synchronize_after_invalid_if.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("synchronize_after_invalid_if.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(2) assert_object(statements[0]).is_not_null() assert_object(statements[1]).is_not_null() assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") func test_dialog_option_missing_right_square_returns_parse_error() -> void: # This fixture omits the closing `]` from a dialog option predicate. The # parser should report a parse error here rather than silently building a # malformed dialog option node and continuing as if the condition was valid. # Recovery should still preserve the following top-level event declaration. var source := _load_fixture("dialog_option_missing_right_square.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("dialog_option_missing_right_square.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(2) assert_object(statements[0]).is_not_null() assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") var found_second := false for statement in statements: if statement is ESCGrammarStmts.Event and statement.get_event_name() == "second": found_second = true break assert_bool(found_second).is_true() func test_dialog_option_missing_indented_body_returns_parse_error() -> void: # This fixture omits the required indented body after a dialog option. The # parser should surface the missing block-start error instead of continuing # into `_block()` and constructing a malformed option node. var source := _load_fixture("dialog_option_missing_indented_body.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("dialog_option_missing_indented_body.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(2) assert_object(statements[0]).is_not_null() assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") var found_second := false for statement in statements: if statement is ESCGrammarStmts.Event and statement.get_event_name() == "second": found_second = true break assert_bool(found_second).is_true() func test_dialog_start_arguments_without_parens_parse_and_are_preserved() -> void: # The public docs show dialog-start arguments in space-separated form: # `?! "res://avatar" 5 2`. This regression locks that syntax down so the # parser preserves those arguments instead of requiring the older `?!(...)` # form. var source := _load_fixture("dialog_start_args_without_parens.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("dialog_start_args_without_parens.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_false() assert_int(statements.size()).is_equal(2) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) var talk_event := statements[0] as ESCGrammarStmts.Event var talk_block := talk_event.get_body() as ESCGrammarStmts.Block assert_int(talk_block.get_statements().size()).is_equal(1) assert_object(talk_block.get_statements()[0]).is_instanceof(ESCGrammarStmts.Dialog) var dialog := talk_block.get_statements()[0] as ESCGrammarStmts.Dialog var args := dialog.get_args() assert_int(args.size()).is_equal(3) assert_object(args[0]).is_instanceof(ESCGrammarExprs.Literal) assert_str(args[0].get_value()).is_equal("res://game/dialog_avatars/player.tres") assert_object(args[1]).is_instanceof(ESCGrammarExprs.Literal) assert_float(args[1].get_value()).is_equal(5.0) assert_object(args[2]).is_instanceof(ESCGrammarExprs.Literal) assert_float(args[2].get_value()).is_equal(2.0) func test_dialog_start_arguments_with_parens_parse_and_are_preserved() -> void: # Preserve the older parenthesized positional form as well, so both # positional dialog-start syntaxes remain supported. var source := _load_fixture("dialog_start_args_with_parens.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("dialog_start_args_with_parens.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_false() assert_int(statements.size()).is_equal(2) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) var talk_event := statements[0] as ESCGrammarStmts.Event var talk_block := talk_event.get_body() as ESCGrammarStmts.Block assert_int(talk_block.get_statements().size()).is_equal(1) assert_object(talk_block.get_statements()[0]).is_instanceof(ESCGrammarStmts.Dialog) var dialog := talk_block.get_statements()[0] as ESCGrammarStmts.Dialog var args := dialog.get_args() assert_int(args.size()).is_equal(3) assert_object(args[0]).is_instanceof(ESCGrammarExprs.Literal) assert_str(args[0].get_value()).is_equal("res://game/dialog_avatars/player.tres") assert_object(args[1]).is_instanceof(ESCGrammarExprs.Literal) assert_float(args[1].get_value()).is_equal(5.0) assert_object(args[2]).is_instanceof(ESCGrammarExprs.Literal) assert_float(args[2].get_value()).is_equal(2.0) func test_dialog_option_translation_key_is_parsed_explicitly() -> void: var source := _load_fixture("dialog_option_translation_key.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("dialog_option_translation_key.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_false() assert_int(statements.size()).is_equal(1) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) var talk_event := statements[0] as ESCGrammarStmts.Event var talk_block := talk_event.get_body() as ESCGrammarStmts.Block var dialog := talk_block.get_statements()[0] as ESCGrammarStmts.Dialog var first_option := dialog.get_options()[0] as ESCGrammarStmts.DialogOption var second_option := dialog.get_options()[1] as ESCGrammarStmts.DialogOption assert_str(first_option.get_translation_key()).is_equal("UNCLE_SVEN") assert_str(second_option.get_translation_key()).is_equal("") func test_grouping_missing_right_paren_returns_parse_error() -> void: # This fixture omits the closing `)` from a grouped expression inside a # function call. The parser must not silently accept the grouping and build # a malformed AST; it should report the error and still preserve the # following event declaration after recovery. var source := _load_fixture("grouping_missing_right_paren.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("grouping_missing_right_paren.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(2) assert_object(statements[0]).is_not_null() assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") assert_object(statements[1]).is_not_null() assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") func test_var_grouping_missing_right_paren_returns_parse_error() -> void: # This fixture omits the closing `)` from a grouped expression in a variable # initializer. Unlike the call-expression regression above, this path goes # straight through `_primary()`, so the parser must reject the malformed # grouping instead of silently constructing a `Grouping` node. var source := _load_fixture("var_grouping_missing_right_paren.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("var_grouping_missing_right_paren.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(2) assert_object(statements[0]).is_not_null() assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") assert_object(statements[1]).is_not_null() assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") func test_var_declaration_missing_newline_returns_parse_error() -> void: # This fixture continues a variable declaration onto the same line with a # second statement. The parser should not silently accept the declaration # when the required trailing newline is missing, and recovery should still # preserve the following event. var source := _load_fixture("var_missing_newline.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("var_missing_newline.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(2) assert_object(statements[0]).is_not_null() assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") assert_object(statements[1]).is_not_null() assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") func test_global_declaration_missing_newline_returns_parse_error() -> void: # This fixture continues a global declaration onto the same line with a # second statement. The parser should reject the malformed declaration # instead of constructing a partial `Global` node, and recovery should still # preserve the following event. var source := _load_fixture("global_missing_newline.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("global_missing_newline.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(2) assert_object(statements[0]).is_not_null() assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") assert_object(statements[1]).is_not_null() assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") func test_while_error_does_not_leak_loop_level_into_later_event() -> void: # This fixture forces `_while_statement()` to fail after incrementing # `_loop_level`. The later `break` in `:second` is top-level within that # event body and must still be rejected. If the loop counter leaks across the # error path, the parser will incorrectly accept it as a valid `Break`. var source := _load_fixture("while_error_then_top_level_break.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("while_error_then_top_level_break.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(3) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") assert_object(statements[2]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[2].get_event_name()).is_equal("third") var second_body: Array = statements[1].get_body().get_statements() assert_int(second_body.size()).is_equal(0) func test_dialog_error_does_not_leak_dialog_level_into_later_event() -> void: # This fixture forces `_dialog_statement()` to fail after incrementing # `_dialog_level`. The later `done` in `:second` is top-level within that # event body and must still be rejected. If the dialog counter leaks across # the error path, the parser will incorrectly accept it as a valid `Done`. var source := _load_fixture("dialog_error_then_top_level_done.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("dialog_error_then_top_level_done.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_true() assert_int(statements.size()).is_greater_equal(3) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") assert_object(statements[2]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[2].get_event_name()).is_equal("third") var second_body: Array = statements[1].get_body().get_statements() assert_int(second_body.size()).is_equal(0) func test_happy_path_full_language_fixture_parses_without_errors() -> void: # This smoke fixture exercises a broad cross-section of ASHES syntax on the # parser happy path: event flags, targets, globals, locals, branching, # looping, built-ins, dialog, nested dialog, conditional dialog options, # `break`, `done`, `in inventory`, `is active`, and `is "state"`. var source := _load_fixture("happy_path_full_language.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("happy_path_full_language.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_false() assert_int(statements.size()).is_equal(2) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("setup") assert_int(statements[0].get_num_statements_in_block()).is_equal(6) assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("look") assert_str(statements[1].get_target_name()).is_equal("worker") assert_int(statements[1].get_num_statements_in_block()).is_equal(3) func test_keyword_prefixed_names_scan_as_identifiers() -> void: # ASHES keywords should only match on exact identifier text. Names that # merely start with keyword text must still scan as ordinary identifiers. var source := _load_fixture("identifier_keyword_prefixes.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("identifier_keyword_prefixes.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_false() assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "breakfast")).is_greater_equal(0) assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "donee")).is_greater_equal(0) assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "elseify")).is_greater_equal(0) assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "notable")).is_greater_equal(0) assert_int(_find_token_index(tokens, ESCTokenType.TokenType.BREAK, "break")).is_equal(-1) assert_int(_find_token_index(tokens, ESCTokenType.TokenType.DONE, "done")).is_equal(-1) assert_int(_find_token_index(tokens, ESCTokenType.TokenType.ELSE, "else")).is_equal(-1) assert_int(_find_token_index(tokens, ESCTokenType.TokenType.NOT, "not")).is_equal(-1) assert_int(statements.size()).is_equal(1) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") func test_hyphenated_global_identifier_scans_as_single_identifier() -> void: # `$`-prefixed global-ID shorthand may contain hyphens, but ordinary `-` # characters must still scan as subtraction operators outside that prefix. var source := _load_fixture("hyphenated_global_identifier.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("hyphenated_global_identifier.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_false() assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "$closet-door")).is_greater_equal(0) assert_int(_find_token_index(tokens, ESCTokenType.TokenType.MINUS, "-")).is_greater_equal(0) assert_int(_find_token_index(tokens, ESCTokenType.TokenType.NUMBER, "1")).is_greater_equal(0) assert_int(statements.size()).is_equal(1) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") func test_blank_lines_in_nested_blocks_do_not_break_later_events() -> void: # Blank lines inside nested blocks should not corrupt INDENT/DEDENT handling. # The parser must still recover the later top-level event after the nested # block ends. var source := _load_fixture("blank_lines_in_nested_blocks.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("blank_lines_in_nested_blocks.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_false() assert_int(statements.size()).is_equal(2) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") func test_blank_lines_before_dedent_do_not_hide_block_termination() -> void: # Blank lines immediately before a block-closing dedent should not be eaten # in a way that hides the dedent from `_block()`. The outer statement after # the nested block, plus the later top-level event, must still parse. var source := _load_fixture("blank_lines_before_dedent.esc") var compiler := ESCCompiler.new() var scanner := ESCScanner.new() scanner.set_source(source) scanner.set_filename(_fixture_path("blank_lines_before_dedent.esc")) var tokens: Array = scanner.scan_tokens() var parser := ESCParser.new() parser.init(compiler, tokens, "") var statements := parser.parse() assert_bool(compiler.had_error).is_false() assert_int(statements.size()).is_equal(2) assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[0].get_event_name()).is_equal("test") assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event) assert_str(statements[1].get_event_name()).is_equal("second") assert_int(statements[0].get_num_statements_in_block()).is_equal(2) func _load_fixture(name: String) -> String: return FileAccess.get_file_as_string(_fixture_path(name)) func _fixture_path(name: String) -> String: return "res://addons/escoria-core/testing/ashes/parser/fixtures/%s" % name func _find_token_index(tokens: Array, token_type: int, lexeme: String = "") -> int: for index in range(tokens.size()): var token = tokens[index] if token.get_type() == token_type and (lexeme.is_empty() or token.get_lexeme() == lexeme): return index return -1 func _find_token_index_after(tokens: Array, current_index: int, token_type: int, lexeme: String = "") -> int: for index in range(current_index + 1, tokens.size()): var token = tokens[index] if token.get_type() == token_type and (lexeme.is_empty() or token.get_lexeme() == lexeme): return index return -1