summaryrefslogtreecommitdiff
path: root/addons/escoria-core/testing/ashes/parser
diff options
context:
space:
mode:
authorradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
committerradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
commita89e744ed0e8c759caf041a580112365bbfe4303 (patch)
treedd61e320904176995727e86a857d00c90d4933ba /addons/escoria-core/testing/ashes/parser
parent6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff)
escoria updateHEADmain
Diffstat (limited to 'addons/escoria-core/testing/ashes/parser')
-rw-r--r--addons/escoria-core/testing/ashes/parser/esc_parser_test.gd586
-rw-r--r--addons/escoria-core/testing/ashes/parser/esc_parser_test.gd.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc11
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc10
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc7
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_right_square.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc6
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/done_partial_match.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc41
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc2
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc6
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_done.esc6
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_if.esc7
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc.uid1
36 files changed, 768 insertions, 0 deletions
diff --git a/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd b/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd
new file mode 100644
index 0000000..8a2539c
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd
@@ -0,0 +1,586 @@
+# 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
diff --git a/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd.uid b/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd.uid
new file mode 100644
index 0000000..e97a2bb
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd.uid
@@ -0,0 +1 @@
+uid://di2o7dahssqsc
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc
new file mode 100644
index 0000000..0ba7826
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc
@@ -0,0 +1,9 @@
+:test
+ if true:
+ print("inside")
+
+
+ print("after")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc.uid
new file mode 100644
index 0000000..4287f2f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc.uid
@@ -0,0 +1 @@
+uid://dc3kyx3rnh13d
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc
new file mode 100644
index 0000000..f1bf7dc
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc
@@ -0,0 +1,11 @@
+:test
+ if true:
+ var value = 1
+
+ if true:
+ print(value)
+
+ print("done")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc.uid
new file mode 100644
index 0000000..5d9dc6a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc.uid
@@ -0,0 +1 @@
+uid://c6pkm7n2t4wyf
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc
new file mode 100644
index 0000000..a6aa528
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc
@@ -0,0 +1,10 @@
+:test
+ ?!(1,
+ - "Option"
+ print("broken")
+
+:second
+ done
+
+:third
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc.uid
new file mode 100644
index 0000000..13ee072
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc.uid
@@ -0,0 +1 @@
+uid://f4q8q8et7mbo2
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc
new file mode 100644
index 0000000..51c525c
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc
@@ -0,0 +1,7 @@
+:test
+ ?!
+ - "Option"
+ print("after")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc.uid
new file mode 100644
index 0000000..7bd0fe8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc.uid
@@ -0,0 +1 @@
+uid://blnqmv4wjsoi8
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_right_square.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_right_square.esc
new file mode 100644
index 0000000..32f407a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_right_square.esc
@@ -0,0 +1,9 @@
+:test
+ ?!
+ - "Option" [some_flag < 3
+ print("after")
+ - "Second option"
+ print("ok")
+
+:second
+ print("after_dialog")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc
new file mode 100644
index 0000000..69f88db
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc
@@ -0,0 +1,6 @@
+:talk
+ ?!
+ - UNCLE_SVEN:"Uncle Sven sends regards."
+ break
+ - "Fallback"
+ break
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc.uid
new file mode 100644
index 0000000..7ac79a6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc.uid
@@ -0,0 +1 @@
+uid://doadggrcx5l6l
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc
new file mode 100644
index 0000000..0b3c59e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc
@@ -0,0 +1,9 @@
+:talk
+ ?!("res://game/dialog_avatars/player.tres", 5, 2)
+ - "First option"
+ say($player, "First option")
+ - "Second option"
+ say($player, "Second option")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc.uid
new file mode 100644
index 0000000..076398a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc.uid
@@ -0,0 +1 @@
+uid://d0xvqlf18ax11
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc
new file mode 100644
index 0000000..cdb2090
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc
@@ -0,0 +1,9 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" 5 2
+ - "First option"
+ say($player, "First option")
+ - "Second option"
+ say($player, "Second option")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc.uid
new file mode 100644
index 0000000..d0dd054
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc.uid
@@ -0,0 +1 @@
+uid://cb2w2jdpxy8sr
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/done_partial_match.esc b/addons/escoria-core/testing/ashes/parser/fixtures/done_partial_match.esc
new file mode 100644
index 0000000..a36a85b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/done_partial_match.esc
@@ -0,0 +1,5 @@
+:test
+ ?!
+ - "Option"
+ done x
+ print("after")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc b/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc
new file mode 100644
index 0000000..f51f093
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc
@@ -0,0 +1,5 @@
+:test
+ global value = 1 print("oops")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc.uid
new file mode 100644
index 0000000..bcb4fd1
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc.uid
@@ -0,0 +1 @@
+uid://di8w10jb2c7u4
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc b/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc
new file mode 100644
index 0000000..97e2e1e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc
@@ -0,0 +1,5 @@
+:test
+ print((1 + 2)
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc.uid
new file mode 100644
index 0000000..a872107
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc.uid
@@ -0,0 +1 @@
+uid://dnj6r3c3d8m5x
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc b/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc
new file mode 100644
index 0000000..0a82acd
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc
@@ -0,0 +1,41 @@
+:setup | NO_UI | NO_SAVE
+ global has_met_worker = false
+ global ask_count = 0
+ var local_count = 0
+ var target_name = $worker
+
+ if !has_met_worker and ESC_LAST_SCENE == "room1":
+ print("first visit")
+ elif $worker is active:
+ print("worker active")
+ else:
+ print("fallback")
+
+ while local_count < 3:
+ local_count = local_count + 1
+ if local_count == 2:
+ break
+
+:look "worker"
+ if $badge in inventory:
+ print("has badge")
+ elif $worker is "busy":
+ print("busy")
+ else:
+ print("idle")
+
+ ?!
+ - "Who are you?" [!has_met_worker]
+ print("ask name")
+ has_met_worker = true
+ - "Tell me more." [ask_count < 2]
+ ask_count = ask_count + 1
+ ?!
+ - "Enough."
+ break 1
+ - "I am done."
+ done
+ - "Bye."
+ break
+
+ print("after dialog")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc.uid
new file mode 100644
index 0000000..d6713b5
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc.uid
@@ -0,0 +1 @@
+uid://cbv6x0w8g17ap
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc b/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc
new file mode 100644
index 0000000..0c86561
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc
@@ -0,0 +1,2 @@
+:test
+ var total = $closet-door - 1
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc.uid
new file mode 100644
index 0000000..f2e55d8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc.uid
@@ -0,0 +1 @@
+uid://b2mxj0r3fk4yy
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc b/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc
new file mode 100644
index 0000000..dd86b8f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc
@@ -0,0 +1,6 @@
+:test
+ var breakfast = 1
+ var donee = 2
+ var elseify = 3
+ var notable = 4
+ print(breakfast + donee + elseify + notable)
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc.uid
new file mode 100644
index 0000000..3bdfa1b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc.uid
@@ -0,0 +1 @@
+uid://d1s8r2n3kq1vh
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_done.esc b/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_done.esc
new file mode 100644
index 0000000..0203977
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_done.esc
@@ -0,0 +1,6 @@
+:test
+ done x
+ print("after")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_if.esc b/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_if.esc
new file mode 100644
index 0000000..d871c98
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_if.esc
@@ -0,0 +1,7 @@
+:test
+ if some_flag
+ print("broken")
+ print("after")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc b/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc
new file mode 100644
index 0000000..05fd749
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc
@@ -0,0 +1,5 @@
+:test
+ var x = (1 + 2
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc.uid
new file mode 100644
index 0000000..567b162
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc.uid
@@ -0,0 +1 @@
+uid://b2d1d5qj4o8t7
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc b/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc
new file mode 100644
index 0000000..c62d37d
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc
@@ -0,0 +1,5 @@
+:test
+ var x = 1 print("bad")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc.uid
new file mode 100644
index 0000000..f65cfb9
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc.uid
@@ -0,0 +1 @@
+uid://dssmdd6b0cwut
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc b/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc
new file mode 100644
index 0000000..dd6ef8b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc
@@ -0,0 +1,9 @@
+:test
+ while true
+ print("broken")
+
+:second
+ break
+
+:third
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc.uid
new file mode 100644
index 0000000..1a1f894
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc.uid
@@ -0,0 +1 @@
+uid://dgo0jap62bmth