From e684563a421f13bc8b18eb8e452a9fd6cc1f87d5 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Wed, 18 Dec 2024 16:06:53 +0530 Subject: [PATCH 1/8] test --- internal/stages_test.go | 129 +++++++++--------- .../fixtures/fail_scanning_errors_1 | 16 +++ .../fail_scanning_errors_1/app/main.py | 32 +++++ .../fail_scanning_errors_1/app/scanner.py | 75 ++++++++++ .../fail_scanning_errors_1/app/token.py | 38 ++++++ .../fail_scanning_errors_1/codecrafters.yml | 11 ++ .../fail_scanning_errors_1/your_program.sh | 15 ++ 7 files changed, 255 insertions(+), 61 deletions(-) create mode 100644 internal/test_helpers/fixtures/fail_scanning_errors_1 create mode 100644 internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_errors_1/app/token.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_errors_1/codecrafters.yml create mode 100755 internal/test_helpers/scenarios/fail_scanning_errors_1/your_program.sh diff --git a/internal/stages_test.go b/internal/stages_test.go index 66a72d48..357f8fae 100644 --- a/internal/stages_test.go +++ b/internal/stages_test.go @@ -12,69 +12,76 @@ func TestStages(t *testing.T) { os.Setenv("CODECRAFTERS_RANDOM_SEED", "1234567890") testCases := map[string]tester_utils_testing.TesterOutputTestCase{ - "pass_scanning_jlox": { - UntilStageSlug: "pq5", - CodePath: "../craftinginterpreters/build/gen/chap04_scanning", - ExpectedExitCode: 0, - StdoutFixturePath: "./test_helpers/fixtures/pass_scanning", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "pass_parsing_jlox": { - StageSlugs: []string{"wz8", "ht8", "uh4", "yf2", "wa9", "mq1", "xe6", "th5", "ra8", "sc2"}, - CodePath: "../craftinginterpreters/build/gen/chap06_parsing", - ExpectedExitCode: 0, - StdoutFixturePath: "./test_helpers/fixtures/pass_parsing", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "pass_evaluating_jlox": { - StageSlugs: []string{"ib5", "cq1", "yu6", "gj9", "hw7", "et4", "jx8", "jy2", "bp3", "dc1", "oq9", "lv1", "iz6"}, - CodePath: "../craftinginterpreters/build/gen/chap07_evaluating", - ExpectedExitCode: 0, - StdoutFixturePath: "./test_helpers/fixtures/pass_evaluating", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "pass_statements_inprogress_jlox": { - StageSlugs: []string{"xy1", "oe4", "fi3", "yg2", "sv7", "bc1", "dw9", "pl3", "vr5", "fb4"}, - CodePath: "../craftinginterpreters/build/gen/chap08_statements", - ExpectedExitCode: 0, - StdoutFixturePath: "./test_helpers/fixtures/pass_statements", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "pass_statements_completed_jlox": { - StageSlugs: []string{"xy1", "oe4", "fi3", "yg2", "sv7", "bc1", "dw9", "pl3", "vr5", "fb4"}, - CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", - ExpectedExitCode: 0, - StdoutFixturePath: "./test_helpers/fixtures/pass_statements_final", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "pass_control_flow_inprogress_jlox": { - StageSlugs: []string{"ne3", "st5", "fh8", "xj4", "wk8", "jx4", "qy3", "bw6", "vt1"}, - CodePath: "../craftinginterpreters/build/gen/chap09_control", - ExpectedExitCode: 0, - StdoutFixturePath: "./test_helpers/fixtures/pass_control_flow", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "pass_control_flow_completed_jlox": { - StageSlugs: []string{"ne3", "st5", "fh8", "xj4", "wk8", "jx4", "qy3", "bw6", "vt1"}, - CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", - ExpectedExitCode: 0, - StdoutFixturePath: "./test_helpers/fixtures/pass_control_flow_final", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "pass_functions_inprogress_jlox": { - StageSlugs: []string{"av4", "pg8", "lb6", "px4", "rd2", "ey3", "fj7", "bz4", "gg6"}, - CodePath: "../craftinginterpreters/build/gen/chap10_functions", - ExpectedExitCode: 0, - StdoutFixturePath: "./test_helpers/fixtures/pass_functions", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "pass_functions_completed_jlox": { - StageSlugs: []string{"av4", "pg8", "lb6", "px4", "rd2", "ey3", "fj7", "bz4", "gg6"}, - CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", - ExpectedExitCode: 0, - StdoutFixturePath: "./test_helpers/fixtures/pass_functions_final", + "fail_scanning_errors_1": { + UntilStageSlug: "ea6", + CodePath: "./test_helpers/scenarios/fail_scanning_errors_1", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_errors_1", NormalizeOutputFunc: normalizeTesterOutput, }, + // "pass_scanning_jlox": { + // UntilStageSlug: "pq5", + // CodePath: "../craftinginterpreters/build/gen/chap04_scanning", + // ExpectedExitCode: 0, + // StdoutFixturePath: "./test_helpers/fixtures/pass_scanning", + // NormalizeOutputFunc: normalizeTesterOutput, + // }, + // "pass_parsing_jlox": { + // StageSlugs: []string{"wz8", "ht8", "uh4", "yf2", "wa9", "mq1", "xe6", "th5", "ra8", "sc2"}, + // CodePath: "../craftinginterpreters/build/gen/chap06_parsing", + // ExpectedExitCode: 0, + // StdoutFixturePath: "./test_helpers/fixtures/pass_parsing", + // NormalizeOutputFunc: normalizeTesterOutput, + // }, + // "pass_evaluating_jlox": { + // StageSlugs: []string{"ib5", "cq1", "yu6", "gj9", "hw7", "et4", "jx8", "jy2", "bp3", "dc1", "oq9", "lv1", "iz6"}, + // CodePath: "../craftinginterpreters/build/gen/chap07_evaluating", + // ExpectedExitCode: 0, + // StdoutFixturePath: "./test_helpers/fixtures/pass_evaluating", + // NormalizeOutputFunc: normalizeTesterOutput, + // }, + // "pass_statements_inprogress_jlox": { + // StageSlugs: []string{"xy1", "oe4", "fi3", "yg2", "sv7", "bc1", "dw9", "pl3", "vr5", "fb4"}, + // CodePath: "../craftinginterpreters/build/gen/chap08_statements", + // ExpectedExitCode: 0, + // StdoutFixturePath: "./test_helpers/fixtures/pass_statements", + // NormalizeOutputFunc: normalizeTesterOutput, + // }, + // "pass_statements_completed_jlox": { + // StageSlugs: []string{"xy1", "oe4", "fi3", "yg2", "sv7", "bc1", "dw9", "pl3", "vr5", "fb4"}, + // CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", + // ExpectedExitCode: 0, + // StdoutFixturePath: "./test_helpers/fixtures/pass_statements_final", + // NormalizeOutputFunc: normalizeTesterOutput, + // }, + // "pass_control_flow_inprogress_jlox": { + // StageSlugs: []string{"ne3", "st5", "fh8", "xj4", "wk8", "jx4", "qy3", "bw6", "vt1"}, + // CodePath: "../craftinginterpreters/build/gen/chap09_control", + // ExpectedExitCode: 0, + // StdoutFixturePath: "./test_helpers/fixtures/pass_control_flow", + // NormalizeOutputFunc: normalizeTesterOutput, + // }, + // "pass_control_flow_completed_jlox": { + // StageSlugs: []string{"ne3", "st5", "fh8", "xj4", "wk8", "jx4", "qy3", "bw6", "vt1"}, + // CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", + // ExpectedExitCode: 0, + // StdoutFixturePath: "./test_helpers/fixtures/pass_control_flow_final", + // NormalizeOutputFunc: normalizeTesterOutput, + // }, + // "pass_functions_inprogress_jlox": { + // StageSlugs: []string{"av4", "pg8", "lb6", "px4", "rd2", "ey3", "fj7", "bz4", "gg6"}, + // CodePath: "../craftinginterpreters/build/gen/chap10_functions", + // ExpectedExitCode: 0, + // StdoutFixturePath: "./test_helpers/fixtures/pass_functions", + // NormalizeOutputFunc: normalizeTesterOutput, + // }, + // "pass_functions_completed_jlox": { + // StageSlugs: []string{"av4", "pg8", "lb6", "px4", "rd2", "ey3", "fj7", "bz4", "gg6"}, + // CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", + // ExpectedExitCode: 0, + // StdoutFixturePath: "./test_helpers/fixtures/pass_functions_final", + // NormalizeOutputFunc: normalizeTesterOutput, + // }, } tester_utils_testing.TestTesterOutput(t, testerDefinition, testCases) diff --git a/internal/test_helpers/fixtures/fail_scanning_errors_1 b/internal/test_helpers/fixtures/fail_scanning_errors_1 new file mode 100644 index 00000000..7303543e --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_errors_1 @@ -0,0 +1,16 @@ +[stage-5] Running tests for Stage #5: ea6 +[stage-5] [test-1] Running test case: 1 +[stage-5] [test-1] Writing contents to ./test.lox: +[stage-5] [test-1] [test.lox] @ +[stage-5] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] Traceback (most recent call last): +[your_program]  File "", line 198, in _run_module_as_main +[your_program]  File "", line 88, in _run_code +[your_program]  File "/Users/rohitpaulk/experiments/codecrafters/testers/interpreter-tester/internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py", line 32, in +[your_program]  main() +[your_program]  File "/Users/rohitpaulk/experiments/codecrafters/testers/interpreter-tester/internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py", line 18, in main +[your_program]  with open(filename) as file: +[your_program]  ^^^^^^^^^^^^^^ +[your_program] FileNotFoundError: [Errno 2] No such file or directory: 'test.lox' +[stage-5] [test-1] expected compile error (exit code 65), got exit code 1 +[stage-5] [test-1] Test failed (try setting 'debug: true' in your codecrafters.yml to see more details) diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py b/internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py new file mode 100644 index 00000000..3a19e88a --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py @@ -0,0 +1,32 @@ +import sys + +from .scanner import Scanner + + +def main(): + if len(sys.argv) < 3: + print("Usage: ./your_program.sh tokenize ", file=sys.stderr) + exit(1) + + command = sys.argv[1] + filename = sys.argv[2] + + if command != "tokenize": + print(f"Unknown command: {command}", file=sys.stderr) + exit(1) + + with open(filename) as file: + file_contents = file.read() + + scanner = Scanner(file_contents) + tokens = scanner.scan_tokens() + + for token in tokens: + print(token) + + if scanner.has_errors: + exit(65) + + +if __name__ == "__main__": + main() diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py b/internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py new file mode 100644 index 00000000..c640974e --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py @@ -0,0 +1,75 @@ +from .token import Token, TokenType +import sys + + +class Scanner: + current_token_start_index: int + current_index: int + current_line: int + has_errors: bool + source: str + tokens: list[Token] + + def __init__(self, source: str): + self.source = source + self.current_token_start_index = 0 + self.current_index = 0 + self.current_line = 1 + self.has_errors = False + self.tokens = [] + + def scan_tokens(self) -> list[Token]: + while not self._is_at_end(): + self.scan_token() + self.current_token_start_index = self.current_index + + self._add_token(TokenType.EOF) + + return self.tokens + + def scan_token(self): + match self._consume_char(): + case ",": + self._add_token(TokenType.COMMA) + case ".": + self._add_token(TokenType.DOT) + case "(": + self._add_token(TokenType.LEFT_PAREN) + case "{": + self._add_token(TokenType.LEFT_BRACE) + case "-": + self._add_token(TokenType.MINUS) + case "+": + self._add_token(TokenType.PLUS) + case ")": + self._add_token(TokenType.RIGHT_PAREN) + case "}": + self._add_token(TokenType.RIGHT_BRACE) + case ";": + self._add_token(TokenType.SEMICOLON) + case "*": + self._add_token(TokenType.STAR) + case char: + self.has_errors = True + + print( + f"[line {self.current_line}] Error: Unexpected character: {char}", + file=sys.stderr, + ) + + def _add_token(self, type: TokenType, literal: str | int | None = None): + self.tokens.append( + Token( + type, + self.source[self.current_token_start_index : self.current_index], + literal, + self.current_line, + ) + ) + + def _consume_char(self) -> str: + self.current_index += 1 + return self.source[self.current_index - 1] + + def _is_at_end(self) -> bool: + return self.current_index >= len(self.source) diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/app/token.py b/internal/test_helpers/scenarios/fail_scanning_errors_1/app/token.py new file mode 100644 index 00000000..78182b73 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_errors_1/app/token.py @@ -0,0 +1,38 @@ +from enum import StrEnum +from typing import Optional + + +class TokenType(StrEnum): + COMMA = "COMMA" + DOT = "DOT" + EOF = "EOF" + LEFT_BRACE = "LEFT_BRACE" + LEFT_PAREN = "LEFT_PAREN" + MINUS = "MINUS" + PLUS = "PLUS" + RIGHT_BRACE = "RIGHT_BRACE" + RIGHT_PAREN = "RIGHT_PAREN" + SEMICOLON = "SEMICOLON" + STAR = "STAR" + + +class Token: + type: TokenType + lexeme: Optional[str] + literal: str | int | None + line_number: int + + def __init__( + self, + type: TokenType, + lexeme: Optional[str], + literal: str | int | None, + line_number: int, + ): + self.type = type + self.lexeme = lexeme + self.literal = literal + self.line_number = line_number + + def __str__(self) -> str: + return f"{self.type} {self.lexeme} {self.literal or 'null'}" diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/codecrafters.yml b/internal/test_helpers/scenarios/fail_scanning_errors_1/codecrafters.yml new file mode 100644 index 00000000..ec2956e1 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_errors_1/codecrafters.yml @@ -0,0 +1,11 @@ +# Set this to true if you want debug logs. +# +# These can be VERY verbose, so we suggest turning them off +# unless you really need them. +debug: false + +# Use this to change the Python version used to run your code +# on Codecrafters. +# +# Available versions: python-3.12 +language_pack: python-3.12 diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/your_program.sh b/internal/test_helpers/scenarios/fail_scanning_errors_1/your_program.sh new file mode 100755 index 00000000..e6c2eb78 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_errors_1/your_program.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# +# Use this script to run your program LOCALLY. +# +# Note: Changing this script WILL NOT affect how CodeCrafters runs your program. +# +# Learn more: https://codecrafters.io/program-interface + +set -e # Exit early if any commands fail + +# Copied from .codecrafters/run.sh +# +# - Edit this to change how your program runs locally +# - Edit .codecrafters/run.sh to change how your program runs remotely +PYTHONPATH=$(dirname $0) exec python3 -m app.main "$@" From 325220f3e93a6f17515ab0d3a25ff85678248909 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Wed, 18 Dec 2024 16:14:06 +0530 Subject: [PATCH 2/8] simulate errors --- internal/assertions/stderr_assertion.go | 18 ++++++++++++++---- .../fixtures/fail_scanning_errors_1 | 13 +++---------- .../fail_scanning_errors_1/app/scanner.py | 2 +- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/internal/assertions/stderr_assertion.go b/internal/assertions/stderr_assertion.go index 61ca7f2c..651ab5fe 100644 --- a/internal/assertions/stderr_assertion.go +++ b/internal/assertions/stderr_assertion.go @@ -25,8 +25,11 @@ func (a StderrAssertion) Run(result executable.ExecutableResult, logger *logger. for i, expectedLine := range a.ExpectedLines { if i >= len(stderr) { logAllSuccessLogs(successLogs, logger) - logger.Errorf("? %s", expectedLine) - logger.Errorf("Skipped %d lines that didn't start with [line N]", skippedLines) + + if skippedLines > 0 { + logger.Errorf("Skipped %d lines that didn't start with [line N]", skippedLines) + } + return fmt.Errorf("Expected line #%d on stderr to be %q, but didn't find line", i+1, expectedLine) } actualValue := stderr[i] @@ -67,6 +70,13 @@ func getStderrLinesFromExecutableResult(result executable.ExecutableResult) []st } func getSkippedLinesCount(result executable.ExecutableResult) int { - unfilteredStderr := strings.Split(strings.TrimRight(string(result.Stderr), "\n"), "\n") - return len(unfilteredStderr) - len(getStderrLinesFromExecutableResult(result)) + trimmedStderr := strings.TrimRight(string(result.Stderr), "\n") + + // Handle the case where strings.Split("", "\n") returns [""] + if trimmedStderr == "" { + return 0 + } + + unfilteredStderrLines := strings.Split(trimmedStderr, "\n") + return len(unfilteredStderrLines) - len(getStderrLinesFromExecutableResult(result)) } diff --git a/internal/test_helpers/fixtures/fail_scanning_errors_1 b/internal/test_helpers/fixtures/fail_scanning_errors_1 index 7303543e..2882e69e 100644 --- a/internal/test_helpers/fixtures/fail_scanning_errors_1 +++ b/internal/test_helpers/fixtures/fail_scanning_errors_1 @@ -3,14 +3,7 @@ [stage-5] [test-1] Writing contents to ./test.lox: [stage-5] [test-1] [test.lox] @ [stage-5] [test-1] $ ./your_program.sh tokenize test.lox -[your_program] Traceback (most recent call last): -[your_program]  File "", line 198, in _run_module_as_main -[your_program]  File "", line 88, in _run_code -[your_program]  File "/Users/rohitpaulk/experiments/codecrafters/testers/interpreter-tester/internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py", line 32, in -[your_program]  main() -[your_program]  File "/Users/rohitpaulk/experiments/codecrafters/testers/interpreter-tester/internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py", line 18, in main -[your_program]  with open(filename) as file: -[your_program]  ^^^^^^^^^^^^^^ -[your_program] FileNotFoundError: [Errno 2] No such file or directory: 'test.lox' -[stage-5] [test-1] expected compile error (exit code 65), got exit code 1 +[your_program] [line 1] Error: Unexpected character: @ +[your_program] EOF null +[stage-5] [test-1] Expected line #1 on stderr to be "[line 1] Error: Unexpected character: @", but didn't find line [stage-5] [test-1] Test failed (try setting 'debug: true' in your codecrafters.yml to see more details) diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py b/internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py index c640974e..e6aa2b4d 100644 --- a/internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py +++ b/internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py @@ -54,7 +54,7 @@ def scan_token(self): print( f"[line {self.current_line}] Error: Unexpected character: {char}", - file=sys.stderr, + file=sys.stdout, ) def _add_token(self, type: TokenType, literal: str | int | None = None): From fced34c0bf00835eea87acd8cde58fbc96213322 Mon Sep 17 00:00:00 2001 From: Andy Li <1450947+andy1li@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:38:53 +0800 Subject: [PATCH 3/8] feat: Add test cases for failing scanning scenarios and update stderr assertion messages in tests. --- internal/Pipfile | 11 +++ internal/assertions/stderr_assertion.go | 20 +++-- internal/stages_test.go | 45 +++++++++-- .../1_missing_line | 14 ++++ .../fail_scanning_lexical_errors/2_mismatch | 13 ++++ .../fail_scanning_lexical_errors/3_extra_line | 14 ++++ .../1_missing_line}/app/main.py | 0 .../1_missing_line}/app/scanner.py | 5 +- .../1_missing_line}/app/token.py | 0 .../1_missing_line}/codecrafters.yml | 0 .../1_missing_line}/your_program.sh | 0 .../2_mismatch/app/main.py | 32 ++++++++ .../2_mismatch/app/scanner.py | 76 ++++++++++++++++++ .../2_mismatch/app/token.py | 38 +++++++++ .../2_mismatch/codecrafters.yml | 11 +++ .../2_mismatch/your_program.sh | 15 ++++ .../3_extra_line/app/main.py | 32 ++++++++ .../3_extra_line/app/scanner.py | 78 +++++++++++++++++++ .../3_extra_line/app/token.py | 38 +++++++++ .../3_extra_line/codecrafters.yml | 11 +++ .../3_extra_line/your_program.sh | 15 ++++ 21 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 internal/Pipfile create mode 100644 internal/test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line create mode 100644 internal/test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch create mode 100644 internal/test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line rename internal/test_helpers/scenarios/{fail_scanning_errors_1 => fail_scanning_lexical_errors/1_missing_line}/app/main.py (100%) rename internal/test_helpers/scenarios/{fail_scanning_errors_1 => fail_scanning_lexical_errors/1_missing_line}/app/scanner.py (96%) rename internal/test_helpers/scenarios/{fail_scanning_errors_1 => fail_scanning_lexical_errors/1_missing_line}/app/token.py (100%) rename internal/test_helpers/scenarios/{fail_scanning_errors_1 => fail_scanning_lexical_errors/1_missing_line}/codecrafters.yml (100%) rename internal/test_helpers/scenarios/{fail_scanning_errors_1 => fail_scanning_lexical_errors/1_missing_line}/your_program.sh (100%) create mode 100644 internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/main.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/scanner.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/token.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/codecrafters.yml create mode 100755 internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/your_program.sh create mode 100644 internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/main.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/scanner.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/token.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/codecrafters.yml create mode 100755 internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/your_program.sh diff --git a/internal/Pipfile b/internal/Pipfile new file mode 100644 index 00000000..645a67ea --- /dev/null +++ b/internal/Pipfile @@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] + +[requires] +python_version = "3.12" diff --git a/internal/assertions/stderr_assertion.go b/internal/assertions/stderr_assertion.go index 651ab5fe..26575d0b 100644 --- a/internal/assertions/stderr_assertion.go +++ b/internal/assertions/stderr_assertion.go @@ -27,17 +27,24 @@ func (a StderrAssertion) Run(result executable.ExecutableResult, logger *logger. logAllSuccessLogs(successLogs, logger) if skippedLines > 0 { - logger.Errorf("Skipped %d lines that didn't start with [line N]", skippedLines) + logger.Plainf("[stderr] Skipped %d lines that didn't start with [line N]", skippedLines) } - return fmt.Errorf("Expected line #%d on stderr to be %q, but didn't find line", i+1, expectedLine) + return fmt.Errorf(` +[stderr] Missing line #%d from stderr: %q +[stderr] Perhaps it's printed to stdout? It should be printed to stderr. + `, i+1, expectedLine) } actualValue := stderr[i] if actualValue != expectedLine { logAllSuccessLogs(successLogs, logger) - logger.Errorf("𐄂 %s", actualValue) - return fmt.Errorf("Expected line #%d on stderr to be %q, got %q", i+1, expectedLine, actualValue) + + return fmt.Errorf(` +[stderr] Mismatch on line #%d of stderr: +[stderr] Expected: %q +[stderr] Actual : %q + `, i+1, expectedLine, actualValue) } else { successLogs = append(successLogs, fmt.Sprintf("✓ %s", actualValue)) } @@ -45,8 +52,9 @@ func (a StderrAssertion) Run(result executable.ExecutableResult, logger *logger. if len(stderr) > len(a.ExpectedLines) { logAllSuccessLogs(successLogs, logger) - logger.Errorf("! %s", stderr[len(a.ExpectedLines)]) - return fmt.Errorf("Expected last stderr line to be %q, but found extra line: %q", a.ExpectedLines[len(a.ExpectedLines)-1], stderr[len(a.ExpectedLines)]) + return fmt.Errorf(` +𐄂 [stderr] Extra unexpected line in stderr: %q + `, stderr[len(a.ExpectedLines)]) } // If all lines match, we don't want to print all the lines again diff --git a/internal/stages_test.go b/internal/stages_test.go index 357f8fae..61496e18 100644 --- a/internal/stages_test.go +++ b/internal/stages_test.go @@ -12,11 +12,46 @@ func TestStages(t *testing.T) { os.Setenv("CODECRAFTERS_RANDOM_SEED", "1234567890") testCases := map[string]tester_utils_testing.TesterOutputTestCase{ - "fail_scanning_errors_1": { - UntilStageSlug: "ea6", - CodePath: "./test_helpers/scenarios/fail_scanning_errors_1", - ExpectedExitCode: 1, - StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_errors_1", + "fail_scanning_lexical_errors_1_missing_line": { + UntilStageSlug: "ea6", + CodePath: "./test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "fail_scanning_lexical_errors_2_mismatch": { + UntilStageSlug: "ea6", + CodePath: "./test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "fail_scanning_lexical_errors_3_extra_line": { + UntilStageSlug: "ea6", + CodePath: "./test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "fail_scanning_multiline_errors_1_missing_line": { + UntilStageSlug: "tz7", + CodePath: "./test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "fail_scanning_multiline_errors_2_mismatch": { + UntilStageSlug: "tz7", + CodePath: "./test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "fail_scanning_multiline_errors_3_extra_line": { + UntilStageSlug: "tz7", + CodePath: "./test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line", NormalizeOutputFunc: normalizeTesterOutput, }, // "pass_scanning_jlox": { diff --git a/internal/test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line new file mode 100644 index 00000000..58c77ac9 --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line @@ -0,0 +1,14 @@ +[stage-5] Running tests for Stage #5: ea6 +[stage-5] [test-1] Running test case: 1 +[stage-5] [test-1] Writing contents to ./test.lox: +[stage-5] [test-1] [test.lox] @ +[stage-5] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] [line 1] Error: Unexpected character: @ +[your_program] EOF null +[your_program] This line will be skipped in stderr +[stage-5] [test-1] [stderr] Skipped 1 lines that didn't start with [line N] +[stage-5] [test-1]  +[stage-5] [test-1] [stderr] Missing line #1 from stderr: "[line 1] Error: Unexpected character: @" +[stage-5] [test-1] [stderr] Perhaps it's printed to stdout? It should be printed to stderr. +[stage-5] [test-1]   +[stage-5] [test-1] Test failed (try setting 'debug: true' in your codecrafters.yml to see more details) diff --git a/internal/test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch new file mode 100644 index 00000000..5928ab37 --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch @@ -0,0 +1,13 @@ +[stage-5] Running tests for Stage #5: ea6 +[stage-5] [test-1] Running test case: 1 +[stage-5] [test-1] Writing contents to ./test.lox: +[stage-5] [test-1] [test.lox] @ +[stage-5] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] EOF null +[your_program] [line 1] eRrOr: uNeXpEcTed cHaRaCtEr: @ +[stage-5] [test-1]  +[stage-5] [test-1] [stderr] Mismatch on line #1 of stderr: +[stage-5] [test-1] [stderr] Expected: "[line 1] Error: Unexpected character: @" +[stage-5] [test-1] [stderr] Actual : "[line 1] eRrOr: uNeXpEcTed cHaRaCtEr: @" +[stage-5] [test-1]   +[stage-5] [test-1] Test failed (try setting 'debug: true' in your codecrafters.yml to see more details) diff --git a/internal/test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line new file mode 100644 index 00000000..311f3090 --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line @@ -0,0 +1,14 @@ +[stage-5] Running tests for Stage #5: ea6 +[stage-5] [test-1] Running test case: 1 +[stage-5] [test-1] Writing contents to ./test.lox: +[stage-5] [test-1] [test.lox] @ +[stage-5] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] EOF null +[your_program] [line 1] Error: Unexpected character: @ +[your_program] [line 666] Extra line +[your_program] [line 888] Another extra line +[stage-5] [test-1] ✓ [line 1] Error: Unexpected character: @ +[stage-5] [test-1]  +[stage-5] [test-1] 𐄂 [stderr] Extra unexpected line in stderr: "[line 666] Extra line" +[stage-5] [test-1]   +[stage-5] [test-1] Test failed (try setting 'debug: true' in your codecrafters.yml to see more details) diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/main.py similarity index 100% rename from internal/test_helpers/scenarios/fail_scanning_errors_1/app/main.py rename to internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/main.py diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/scanner.py similarity index 96% rename from internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py rename to internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/scanner.py index e6aa2b4d..868c8876 100644 --- a/internal/test_helpers/scenarios/fail_scanning_errors_1/app/scanner.py +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/scanner.py @@ -1,6 +1,7 @@ -from .token import Token, TokenType import sys +from .token import Token, TokenType + class Scanner: current_token_start_index: int @@ -52,6 +53,8 @@ def scan_token(self): case char: self.has_errors = True + print("This line will be skipped in stderr", file=sys.stderr) + print( f"[line {self.current_line}] Error: Unexpected character: {char}", file=sys.stdout, diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/app/token.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/token.py similarity index 100% rename from internal/test_helpers/scenarios/fail_scanning_errors_1/app/token.py rename to internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/token.py diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/codecrafters.yml b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/codecrafters.yml similarity index 100% rename from internal/test_helpers/scenarios/fail_scanning_errors_1/codecrafters.yml rename to internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/codecrafters.yml diff --git a/internal/test_helpers/scenarios/fail_scanning_errors_1/your_program.sh b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/your_program.sh similarity index 100% rename from internal/test_helpers/scenarios/fail_scanning_errors_1/your_program.sh rename to internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/your_program.sh diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/main.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/main.py new file mode 100644 index 00000000..3a19e88a --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/main.py @@ -0,0 +1,32 @@ +import sys + +from .scanner import Scanner + + +def main(): + if len(sys.argv) < 3: + print("Usage: ./your_program.sh tokenize ", file=sys.stderr) + exit(1) + + command = sys.argv[1] + filename = sys.argv[2] + + if command != "tokenize": + print(f"Unknown command: {command}", file=sys.stderr) + exit(1) + + with open(filename) as file: + file_contents = file.read() + + scanner = Scanner(file_contents) + tokens = scanner.scan_tokens() + + for token in tokens: + print(token) + + if scanner.has_errors: + exit(65) + + +if __name__ == "__main__": + main() diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/scanner.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/scanner.py new file mode 100644 index 00000000..4e7e5777 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/scanner.py @@ -0,0 +1,76 @@ +import sys + +from .token import Token, TokenType + + +class Scanner: + current_token_start_index: int + current_index: int + current_line: int + has_errors: bool + source: str + tokens: list[Token] + + def __init__(self, source: str): + self.source = source + self.current_token_start_index = 0 + self.current_index = 0 + self.current_line = 1 + self.has_errors = False + self.tokens = [] + + def scan_tokens(self) -> list[Token]: + while not self._is_at_end(): + self.scan_token() + self.current_token_start_index = self.current_index + + self._add_token(TokenType.EOF) + + return self.tokens + + def scan_token(self): + match self._consume_char(): + case ",": + self._add_token(TokenType.COMMA) + case ".": + self._add_token(TokenType.DOT) + case "(": + self._add_token(TokenType.LEFT_PAREN) + case "{": + self._add_token(TokenType.LEFT_BRACE) + case "-": + self._add_token(TokenType.MINUS) + case "+": + self._add_token(TokenType.PLUS) + case ")": + self._add_token(TokenType.RIGHT_PAREN) + case "}": + self._add_token(TokenType.RIGHT_BRACE) + case ";": + self._add_token(TokenType.SEMICOLON) + case "*": + self._add_token(TokenType.STAR) + case char: + self.has_errors = True + + print( + f"[line {self.current_line}] eRrOr: uNeXpEcTed cHaRaCtEr: {char}", + file=sys.stderr, + ) + + def _add_token(self, type: TokenType, literal: str | int | None = None): + self.tokens.append( + Token( + type, + self.source[self.current_token_start_index : self.current_index], + literal, + self.current_line, + ) + ) + + def _consume_char(self) -> str: + self.current_index += 1 + return self.source[self.current_index - 1] + + def _is_at_end(self) -> bool: + return self.current_index >= len(self.source) diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/token.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/token.py new file mode 100644 index 00000000..78182b73 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/token.py @@ -0,0 +1,38 @@ +from enum import StrEnum +from typing import Optional + + +class TokenType(StrEnum): + COMMA = "COMMA" + DOT = "DOT" + EOF = "EOF" + LEFT_BRACE = "LEFT_BRACE" + LEFT_PAREN = "LEFT_PAREN" + MINUS = "MINUS" + PLUS = "PLUS" + RIGHT_BRACE = "RIGHT_BRACE" + RIGHT_PAREN = "RIGHT_PAREN" + SEMICOLON = "SEMICOLON" + STAR = "STAR" + + +class Token: + type: TokenType + lexeme: Optional[str] + literal: str | int | None + line_number: int + + def __init__( + self, + type: TokenType, + lexeme: Optional[str], + literal: str | int | None, + line_number: int, + ): + self.type = type + self.lexeme = lexeme + self.literal = literal + self.line_number = line_number + + def __str__(self) -> str: + return f"{self.type} {self.lexeme} {self.literal or 'null'}" diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/codecrafters.yml b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/codecrafters.yml new file mode 100644 index 00000000..ec2956e1 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/codecrafters.yml @@ -0,0 +1,11 @@ +# Set this to true if you want debug logs. +# +# These can be VERY verbose, so we suggest turning them off +# unless you really need them. +debug: false + +# Use this to change the Python version used to run your code +# on Codecrafters. +# +# Available versions: python-3.12 +language_pack: python-3.12 diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/your_program.sh b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/your_program.sh new file mode 100755 index 00000000..e6c2eb78 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/your_program.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# +# Use this script to run your program LOCALLY. +# +# Note: Changing this script WILL NOT affect how CodeCrafters runs your program. +# +# Learn more: https://codecrafters.io/program-interface + +set -e # Exit early if any commands fail + +# Copied from .codecrafters/run.sh +# +# - Edit this to change how your program runs locally +# - Edit .codecrafters/run.sh to change how your program runs remotely +PYTHONPATH=$(dirname $0) exec python3 -m app.main "$@" diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/main.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/main.py new file mode 100644 index 00000000..3a19e88a --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/main.py @@ -0,0 +1,32 @@ +import sys + +from .scanner import Scanner + + +def main(): + if len(sys.argv) < 3: + print("Usage: ./your_program.sh tokenize ", file=sys.stderr) + exit(1) + + command = sys.argv[1] + filename = sys.argv[2] + + if command != "tokenize": + print(f"Unknown command: {command}", file=sys.stderr) + exit(1) + + with open(filename) as file: + file_contents = file.read() + + scanner = Scanner(file_contents) + tokens = scanner.scan_tokens() + + for token in tokens: + print(token) + + if scanner.has_errors: + exit(65) + + +if __name__ == "__main__": + main() diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/scanner.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/scanner.py new file mode 100644 index 00000000..88c5af17 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/scanner.py @@ -0,0 +1,78 @@ +import sys + +from .token import Token, TokenType + + +class Scanner: + current_token_start_index: int + current_index: int + current_line: int + has_errors: bool + source: str + tokens: list[Token] + + def __init__(self, source: str): + self.source = source + self.current_token_start_index = 0 + self.current_index = 0 + self.current_line = 1 + self.has_errors = False + self.tokens = [] + + def scan_tokens(self) -> list[Token]: + while not self._is_at_end(): + self.scan_token() + self.current_token_start_index = self.current_index + + self._add_token(TokenType.EOF) + + return self.tokens + + def scan_token(self): + match self._consume_char(): + case ",": + self._add_token(TokenType.COMMA) + case ".": + self._add_token(TokenType.DOT) + case "(": + self._add_token(TokenType.LEFT_PAREN) + case "{": + self._add_token(TokenType.LEFT_BRACE) + case "-": + self._add_token(TokenType.MINUS) + case "+": + self._add_token(TokenType.PLUS) + case ")": + self._add_token(TokenType.RIGHT_PAREN) + case "}": + self._add_token(TokenType.RIGHT_BRACE) + case ";": + self._add_token(TokenType.SEMICOLON) + case "*": + self._add_token(TokenType.STAR) + case char: + self.has_errors = True + + print( + f"[line {self.current_line}] Error: Unexpected character: {char}", + file=sys.stderr, + ) + print("[line 666] Extra line", file=sys.stderr) + print("[line 888] Another extra line", file=sys.stderr) + + def _add_token(self, type: TokenType, literal: str | int | None = None): + self.tokens.append( + Token( + type, + self.source[self.current_token_start_index : self.current_index], + literal, + self.current_line, + ) + ) + + def _consume_char(self) -> str: + self.current_index += 1 + return self.source[self.current_index - 1] + + def _is_at_end(self) -> bool: + return self.current_index >= len(self.source) diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/token.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/token.py new file mode 100644 index 00000000..78182b73 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/token.py @@ -0,0 +1,38 @@ +from enum import StrEnum +from typing import Optional + + +class TokenType(StrEnum): + COMMA = "COMMA" + DOT = "DOT" + EOF = "EOF" + LEFT_BRACE = "LEFT_BRACE" + LEFT_PAREN = "LEFT_PAREN" + MINUS = "MINUS" + PLUS = "PLUS" + RIGHT_BRACE = "RIGHT_BRACE" + RIGHT_PAREN = "RIGHT_PAREN" + SEMICOLON = "SEMICOLON" + STAR = "STAR" + + +class Token: + type: TokenType + lexeme: Optional[str] + literal: str | int | None + line_number: int + + def __init__( + self, + type: TokenType, + lexeme: Optional[str], + literal: str | int | None, + line_number: int, + ): + self.type = type + self.lexeme = lexeme + self.literal = literal + self.line_number = line_number + + def __str__(self) -> str: + return f"{self.type} {self.lexeme} {self.literal or 'null'}" diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/codecrafters.yml b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/codecrafters.yml new file mode 100644 index 00000000..ec2956e1 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/codecrafters.yml @@ -0,0 +1,11 @@ +# Set this to true if you want debug logs. +# +# These can be VERY verbose, so we suggest turning them off +# unless you really need them. +debug: false + +# Use this to change the Python version used to run your code +# on Codecrafters. +# +# Available versions: python-3.12 +language_pack: python-3.12 diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/your_program.sh b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/your_program.sh new file mode 100755 index 00000000..e6c2eb78 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/your_program.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# +# Use this script to run your program LOCALLY. +# +# Note: Changing this script WILL NOT affect how CodeCrafters runs your program. +# +# Learn more: https://codecrafters.io/program-interface + +set -e # Exit early if any commands fail + +# Copied from .codecrafters/run.sh +# +# - Edit this to change how your program runs locally +# - Edit .codecrafters/run.sh to change how your program runs remotely +PYTHONPATH=$(dirname $0) exec python3 -m app.main "$@" From fe6e4218db0dfb4b934bdf7ede2d1f0d2177a39b Mon Sep 17 00:00:00 2001 From: Andy Li <1450947+andy1li@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:39:24 +0800 Subject: [PATCH 4/8] feat: Add new test scenarios for failing scanning with multiline errors --- internal/Pipfile | 11 -- .../1_missing_line/app/main.py | 24 ++++ .../1_missing_line/codecrafters.yml | 11 ++ .../1_missing_line/lox/interpreter.py | 75 ++++++++++ .../1_missing_line/lox/lox.py | 64 +++++++++ .../1_missing_line/lox/parser.py | 134 ++++++++++++++++++ .../1_missing_line/lox/scanner.py | 120 ++++++++++++++++ .../1_missing_line/lox/types.py | 80 +++++++++++ .../1_missing_line/your_program.sh | 16 +++ .../2_mismatch/app/main.py | 24 ++++ .../2_mismatch/codecrafters.yml | 11 ++ .../2_mismatch/lox/interpreter.py | 75 ++++++++++ .../2_mismatch/lox/lox.py | 69 +++++++++ .../2_mismatch/lox/parser.py | 134 ++++++++++++++++++ .../2_mismatch/lox/scanner.py | 120 ++++++++++++++++ .../2_mismatch/lox/types.py | 80 +++++++++++ .../2_mismatch/your_program.sh | 16 +++ .../3_extra_line/app/main.py | 24 ++++ .../3_extra_line/codecrafters.yml | 11 ++ .../3_extra_line/lox/interpreter.py | 75 ++++++++++ .../3_extra_line/lox/lox.py | 67 +++++++++ .../3_extra_line/lox/parser.py | 134 ++++++++++++++++++ .../3_extra_line/lox/scanner.py | 120 ++++++++++++++++ .../3_extra_line/lox/types.py | 80 +++++++++++ .../3_extra_line/your_program.sh | 16 +++ 25 files changed, 1580 insertions(+), 11 deletions(-) delete mode 100644 internal/Pipfile create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/app/main.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/codecrafters.yml create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/interpreter.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/lox.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/parser.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/scanner.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/types.py create mode 100755 internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/your_program.sh create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/app/main.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/codecrafters.yml create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/interpreter.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/parser.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/scanner.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/types.py create mode 100755 internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/your_program.sh create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/app/main.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/codecrafters.yml create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/interpreter.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/parser.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/scanner.py create mode 100644 internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/types.py create mode 100755 internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/your_program.sh diff --git a/internal/Pipfile b/internal/Pipfile deleted file mode 100644 index 645a67ea..00000000 --- a/internal/Pipfile +++ /dev/null @@ -1,11 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] - -[dev-packages] - -[requires] -python_version = "3.12" diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/app/main.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/app/main.py new file mode 100644 index 00000000..a96f4b37 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/app/main.py @@ -0,0 +1,24 @@ +import argparse +import sys + +from lox.lox import Lox + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('command') + parser.add_argument('filename') + args = parser.parse_args() + + lox = Lox(args.filename) + # fmt: off + match args.command: + case 'tokenize' : lox.scan() + case 'parse' : lox.parse() + case 'evaluate' : lox.evaluate() + case _ : sys.exit(1) + # fmt: on + + +if __name__ == '__main__': + main() diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/codecrafters.yml b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/codecrafters.yml new file mode 100644 index 00000000..ec2956e1 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/codecrafters.yml @@ -0,0 +1,11 @@ +# Set this to true if you want debug logs. +# +# These can be VERY verbose, so we suggest turning them off +# unless you really need them. +debug: false + +# Use this to change the Python version used to run your code +# on Codecrafters. +# +# Available versions: python-3.12 +language_pack: python-3.12 diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/interpreter.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/interpreter.py new file mode 100644 index 00000000..48bcdc7e --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/interpreter.py @@ -0,0 +1,75 @@ +from lox.types import * + + +def repr(value): + if value is None: + return 'nil' + elif isinstance(value, bool): + return str(value).lower() + elif getattr(value, 'is_integer', False) and value.is_integer(): + return int(value) + else: + return value + + +def check_number_operand(operator: Token, operand): + if isinstance(operand, float): + return + # print(operand, type(operand)) + raise RuntimeError(operator, 'Operand must be a number.') + + +def check_number_operands(operator: Token, left, right): + if isinstance(left, float) and isinstance(right, float): + return + raise RuntimeError(operator, 'Operands must be numbers.') + + +def interpret(ast): + def eval(expr: Expr): + # fmt: off + match expr: + case Binary(left, op, right): + l, r = eval(left), eval(right) + + match op.type: + case TokenType.BANG_EQUAL : return l != r + case TokenType.EQUAL_EQUAL: return l == r + case TokenType.PLUS: + if (isinstance(l, float) and isinstance(r, float) + or isinstance(l, str) and isinstance(r, str)): + return l + r + raise RuntimeError(op, 'Operands must be two numbers or two strings.') + + check_number_operands(op, l, r) + match op.type: + case TokenType.MINUS : return l - r + case TokenType.SLASH : return l / r + case TokenType.STAR : return l * r + case TokenType.GREATER : return l > r + case TokenType.GREATER_EQUAL: return l >= r + case TokenType.LESS : return l < r + case TokenType.LESS_EQUAL : return l <= r + + case Grouping(expr) : return eval(expr) + case Literal(value) : return value + case Unary(op, right): + match op.type: + case TokenType.BANG : + return not is_truthy(right) + case TokenType.MINUS: + r = eval(right) + check_number_operand(op, r) + return -r + # fmt: on + + def is_truthy(value: Expr): + # fmt: off + match value: + case Literal('nil') : return False + case Literal('false'): return False + case Literal('true') : return True + case _ : return eval(value) + # fmt: on + + print(repr(eval(ast))) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/lox.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/lox.py new file mode 100644 index 00000000..38141d1b --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/lox.py @@ -0,0 +1,64 @@ +import sys + +from lox.interpreter import interpret +from lox.parser import Parser +from lox.scanner import Scanner, Token, TokenType + + +class Lox: + def __init__(self, filename: str): + self._tokens: list[Token] = [] + self._ast = None + self._had_error = False + self._had_runtime_error = False + + with open(filename) as f: + self._source = f.read() + + def error_line(self, line: int, message: str): + self._error_report(line, "", message) + + def error_token(self, token: Token, message: str): + where = " at " + ("end" if token.type == TokenType.EOF else f"'{token.lexeme}'") + self._error_report(token.line, where, message) + + def evaluate(self): + self.parse(False) + try: + interpret(self._ast) + except RuntimeError as e: + self._runtime_error(e) + self._check_errror_exit() + + def parse(self, should_print=True): + self.scan(False) + parser = Parser(self, self._tokens) + self._ast = parser.parse() + self._check_errror_exit() + if should_print: + Parser.print_ast(self._ast) + + def scan(self, should_print=True): + scanner = Scanner(self, self._source) + self._tokens = scanner.scan_tokens() + if should_print: + print(*self._tokens, sep="\n") + self._check_errror_exit() + + def _check_errror_exit(self): + if self._had_error: + sys.exit(65) + if self._had_runtime_error: + sys.exit(70) + + def _error_report(self, line: int, where: str, message: str): + self._had_error = True + if message.endswith("@"): + output = f"[line {line}] Error{where}: {message}" + print(output, file=sys.stderr) + + def _runtime_error(self, error: RuntimeError): + self._had_runtime_error = True + token, msg = error.args + output = msg + f"\n[line {token.line}]" + print(output, file=sys.stderr) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/parser.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/parser.py new file mode 100644 index 00000000..e003261f --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/parser.py @@ -0,0 +1,134 @@ +from typing import Optional + +from lox.scanner import Token, TokenType +from lox.types import * + + +class Parser: + @staticmethod + def print_ast(ast): + def repr(expr: Expr): + # fmt: off + match expr: + case Binary(left, op, right): return f'({op.lexeme} {repr(left)} {repr(right)})' + case Grouping(expr) : return f'(group {repr(expr)})' + case Literal(None) : return 'nil' + case Literal(False) : return 'false' + case Literal(True) : return 'true' + case Literal(value) : return value + case Unary(op, right) : return f'({op.lexeme} {repr(right)})' + # fmt: on + + print(repr(ast)) + + def __init__(self, lox, tokens: list[Token]): + self._lox = lox + self._tokens = tokens + self._current = 0 + + def parse(self) -> Optional[Expr]: + try: + return self._expression() + except ParseError: + return None + + def _advance(self) -> Token: + if not self._is_at_end(): + self._current += 1 + return self._previous() + + def _check(self, type: TokenType) -> bool: + if self._is_at_end(): + return False + return self._peek().type == type + + def _consume(self, type: TokenType, message: str) -> Token: + if self._check(type): + return self._advance() + raise self._error(self._peek(), message) + + def _error(self, token: Token, message: str) -> ParseError: + self._lox.error_token(token, message) + return ParseError() + + def _is_at_end(self) -> bool: + return self._peek().type == TokenType.EOF + + def _match(self, *types: TokenType) -> bool: + for type in types: + if self._check(type): + self._advance() + return True + return False + + def _peek(self) -> Token: + return self._tokens[self._current] + + def _previous(self) -> Token: + return self._tokens[self._current - 1] + + # Grammar + def _expression(self) -> Expr: + return self._equality() + + def _equality(self) -> Expr: + expr = self._comparison() + while self._match(TokenType.BANG_EQUAL, TokenType.EQUAL_EQUAL): + op = self._previous() + right = self._comparison() + expr = Binary(expr, op, right) + return expr + + def _comparison(self) -> Expr: + expr = self._term() + while self._match( + TokenType.GREATER, + TokenType.GREATER_EQUAL, + TokenType.LESS, + TokenType.LESS_EQUAL, + ): + op = self._previous() + right = self._term() + expr = Binary(expr, op, right) + return expr + + def _term(self) -> Expr: + expr = self._factor() + while self._match(TokenType.MINUS, TokenType.PLUS): + op = self._previous() + right = self._factor() + expr = Binary(expr, op, right) + return expr + + def _factor(self) -> Expr: + expr = self._unary() + while self._match(TokenType.SLASH, TokenType.STAR): + op = self._previous() + right = self._unary() + expr = Binary(expr, op, right) + return expr + + def _unary(self) -> Expr: + if self._match(TokenType.BANG, TokenType.MINUS): + op = self._previous() + right = self._unary() + return Unary(op, right) + return self._primary() + + def _primary(self): + if self._match(TokenType.FALSE): + return Literal(False) + if self._match(TokenType.TRUE): + return Literal(True) + if self._match(TokenType.NIL): + return Literal(None) + + if self._match(TokenType.NUMBER, TokenType.STRING): + return Literal(self._previous().literal) + + if self._match(TokenType.LEFT_PAREN): + expr = self._expression() + self._consume(TokenType.RIGHT_PAREN, "Expect ')' after expression.") + return Grouping(expr) + + self._error(self._peek(), 'Expect expression.') diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/scanner.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/scanner.py new file mode 100644 index 00000000..22f40996 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/scanner.py @@ -0,0 +1,120 @@ +from lox.types import * + + +class Scanner: + def __init__(self, lox, source: str): + self._tokens: list[Token] = [] + self._start = 0 + self._current = 0 + self._line = 1 + self._lox = lox + self._source = source + + def scan_tokens(self) -> list[Token]: + while not self._is_at_end(): + self._scan_token() + self._tokens += (Token(TokenType.EOF, '', None, self._line),) + return self._tokens + + def _add_token(self, type: TokenType, literal=None): + text = self._source[self._start : self._current] + self._tokens += (Token(type, text, literal, self._line),) + + def _advance(self) -> str: + c = self._source[self._current] + self._current += 1 + return c + + def _error_char(self): + char = self._source[self._start : self._current] + self._lox.error_line(self._line, 'Unexpected character: ' + char) + + def _identifier(self): + while (c := self._peek()).isalnum() or c == '_': + self._advance() + + text = self._source[self._start : self._current] + type = KEYWORDS.get(text) or TokenType.IDENTIFIER + self._add_token(type) + + def _is_at_end(self) -> bool: + return self._current >= len(self._source) + + def _match(self, expected: str) -> bool: + if self._is_at_end() or (self._source[self._current] != expected): + return False + self._current += 1 + return True + + def _number(self): + while self._peek().isdigit(): + self._advance() + + if self._peek() == '.' and self._peek_next().isdigit(): + self._advance() # Consume the "." + while self._peek().isdigit(): + self._advance() + + literal = self._source[self._start : self._current] + self._add_token(TokenType.NUMBER, float(literal)) + + def _peek(self) -> str: + return '\0' if self._is_at_end() else self._source[self._current] + + def _peek_next(self) -> str: + if self._current + 1 >= len(self._source): + return '\0' + return self._source[self._current + 1] + + def _scan_token(self): + self._start = self._current + # fmt: off + match c := self._advance(): + case '(' : self._add_token(TokenType.LEFT_PAREN) + case ')' : self._add_token(TokenType.RIGHT_PAREN) + case '{' : self._add_token(TokenType.LEFT_BRACE) + case '}' : self._add_token(TokenType.RIGHT_BRACE) + case ',' : self._add_token(TokenType.COMMA) + case '.' : self._add_token(TokenType.DOT) + case '-' : self._add_token(TokenType.MINUS) + case '+' : self._add_token(TokenType.PLUS) + case ';' : self._add_token(TokenType.SEMICOLON) + case '*' : self._add_token(TokenType.STAR) + + case '!' : self._add_token(TokenType.BANG_EQUAL if self._match('=') else TokenType.BANG) + case '=' : self._add_token(TokenType.EQUAL_EQUAL if self._match('=') else TokenType.EQUAL) + case '<' : self._add_token(TokenType.LESS_EQUAL if self._match('=') else TokenType.LESS) + case '>' : self._add_token(TokenType.GREATER_EQUAL if self._match('=') else TokenType.GREATER) + + case '/' : self._slash() + + case ' ' | '\t': pass + case '\n': self._line += 1 + + case '"' : self._string() + + case c if c.isdigit(): self._number() + case c if c.isalpha() or c == '_': self._identifier() + + case _ : self._error_char() + # fmt: on + + def _slash(self): + if self._match('/'): + while self._peek() != '\n' and not self._is_at_end(): + self._advance() + else: + self._add_token(TokenType.SLASH) + + def _string(self): + while self._peek() != '"' and not self._is_at_end(): + if self._peek() == '\n': + self._line += 1 + self._advance() + + if self._is_at_end(): + return self._lox.error_line(self._line, 'Unterminated string.') + + self._advance() # The closing ". + value = self._source[self._start + 1 : self._current - 1] + self._add_token(TokenType.STRING, value) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/types.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/types.py new file mode 100644 index 00000000..5dc1cd7f --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/lox/types.py @@ -0,0 +1,80 @@ +from enum import Enum +from typing import NamedTuple + +# fmt: off +TokenType = Enum('TokenType', [ + # Single-character tokens. + 'LEFT_PAREN', 'RIGHT_PAREN', 'LEFT_BRACE', 'RIGHT_BRACE', + 'COMMA', 'DOT', 'MINUS', 'PLUS', 'SEMICOLON', 'SLASH', 'STAR', + + # One or two character tokens. + 'BANG', 'BANG_EQUAL', + 'EQUAL', 'EQUAL_EQUAL', + 'GREATER', 'GREATER_EQUAL', + 'LESS', 'LESS_EQUAL', + + # Literals. + 'IDENTIFIER', 'NUMBER', 'STRING', + + # Keywords. + 'AND', 'CLASS', 'ELSE', 'FALSE', 'FUN', 'FOR', 'IF', 'NIL', 'OR', + 'PRINT', 'RETURN', 'SUPER', 'THIS', 'TRUE', 'VAR', 'WHILE', + + 'EOF' +]) +# fmt: on + +KEYWORDS = { + 'and': TokenType.AND, + 'class': TokenType.CLASS, + 'else': TokenType.ELSE, + 'false': TokenType.FALSE, + 'for': TokenType.FOR, + 'fun': TokenType.FUN, + 'if': TokenType.IF, + 'nil': TokenType.NIL, + 'or': TokenType.OR, + 'print': TokenType.PRINT, + 'return': TokenType.RETURN, + 'super': TokenType.SUPER, + 'this': TokenType.THIS, + 'true': TokenType.TRUE, + 'var': TokenType.VAR, + 'while': TokenType.WHILE, +} + + +class Token(NamedTuple): + type: TokenType + lexeme: str + literal: object + line: int + + def __repr__(self): + return f'{self.type.name} {self.lexeme} {"null" if self.literal is None else self.literal}' + + +class ParseError(Exception): + pass + + +class Binary(NamedTuple): + left: 'Expr' + op: Token + right: 'Expr' + + +class Grouping(NamedTuple): + expr: 'Expr' + + +class Literal(NamedTuple): + value: object + + +class Unary(NamedTuple): + op: Token + right: 'Expr' + + +Expr = Binary | Grouping | Literal | Unary diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/your_program.sh b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/your_program.sh new file mode 100755 index 00000000..d6cb050b --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line/your_program.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# +# Use this script to run your program LOCALLY. +# +# Note: Changing this script WILL NOT affect how CodeCrafters runs your program. +# +# Learn more: https://codecrafters.io/program-interface + +# Exit early if any commands fail +set -e + +# Copied from .codecrafters/run.sh +# +# - Edit this to change how your program runs locally +# - Edit .codecrafters/run.sh to change how your program runs remotely +PYTHONPATH=$(dirname $0) exec python3 -m app.main "$@" \ No newline at end of file diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/app/main.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/app/main.py new file mode 100644 index 00000000..a96f4b37 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/app/main.py @@ -0,0 +1,24 @@ +import argparse +import sys + +from lox.lox import Lox + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('command') + parser.add_argument('filename') + args = parser.parse_args() + + lox = Lox(args.filename) + # fmt: off + match args.command: + case 'tokenize' : lox.scan() + case 'parse' : lox.parse() + case 'evaluate' : lox.evaluate() + case _ : sys.exit(1) + # fmt: on + + +if __name__ == '__main__': + main() diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/codecrafters.yml b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/codecrafters.yml new file mode 100644 index 00000000..ec2956e1 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/codecrafters.yml @@ -0,0 +1,11 @@ +# Set this to true if you want debug logs. +# +# These can be VERY verbose, so we suggest turning them off +# unless you really need them. +debug: false + +# Use this to change the Python version used to run your code +# on Codecrafters. +# +# Available versions: python-3.12 +language_pack: python-3.12 diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/interpreter.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/interpreter.py new file mode 100644 index 00000000..48bcdc7e --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/interpreter.py @@ -0,0 +1,75 @@ +from lox.types import * + + +def repr(value): + if value is None: + return 'nil' + elif isinstance(value, bool): + return str(value).lower() + elif getattr(value, 'is_integer', False) and value.is_integer(): + return int(value) + else: + return value + + +def check_number_operand(operator: Token, operand): + if isinstance(operand, float): + return + # print(operand, type(operand)) + raise RuntimeError(operator, 'Operand must be a number.') + + +def check_number_operands(operator: Token, left, right): + if isinstance(left, float) and isinstance(right, float): + return + raise RuntimeError(operator, 'Operands must be numbers.') + + +def interpret(ast): + def eval(expr: Expr): + # fmt: off + match expr: + case Binary(left, op, right): + l, r = eval(left), eval(right) + + match op.type: + case TokenType.BANG_EQUAL : return l != r + case TokenType.EQUAL_EQUAL: return l == r + case TokenType.PLUS: + if (isinstance(l, float) and isinstance(r, float) + or isinstance(l, str) and isinstance(r, str)): + return l + r + raise RuntimeError(op, 'Operands must be two numbers or two strings.') + + check_number_operands(op, l, r) + match op.type: + case TokenType.MINUS : return l - r + case TokenType.SLASH : return l / r + case TokenType.STAR : return l * r + case TokenType.GREATER : return l > r + case TokenType.GREATER_EQUAL: return l >= r + case TokenType.LESS : return l < r + case TokenType.LESS_EQUAL : return l <= r + + case Grouping(expr) : return eval(expr) + case Literal(value) : return value + case Unary(op, right): + match op.type: + case TokenType.BANG : + return not is_truthy(right) + case TokenType.MINUS: + r = eval(right) + check_number_operand(op, r) + return -r + # fmt: on + + def is_truthy(value: Expr): + # fmt: off + match value: + case Literal('nil') : return False + case Literal('false'): return False + case Literal('true') : return True + case _ : return eval(value) + # fmt: on + + print(repr(eval(ast))) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py new file mode 100644 index 00000000..90669323 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py @@ -0,0 +1,69 @@ +import sys + +from lox.interpreter import interpret +from lox.parser import Parser +from lox.scanner import Scanner, Token, TokenType + + +class Lox: + def __init__(self, filename: str): + self._tokens: list[Token] = [] + self._ast = None + self._had_error = False + self._had_runtime_error = False + + with open(filename) as f: + self._source = f.read() + + def error_line(self, line: int, message: str): + self._error_report(line, "", message) + + def error_token(self, token: Token, message: str): + where = " at " + ("end" if token.type == TokenType.EOF else f"'{token.lexeme}'") + self._error_report(token.line, where, message) + + def evaluate(self): + self.parse(False) + try: + interpret(self._ast) + except RuntimeError as e: + self._runtime_error(e) + self._check_errror_exit() + + def parse(self, should_print=True): + self.scan(False) + parser = Parser(self, self._tokens) + self._ast = parser.parse() + self._check_errror_exit() + if should_print: + Parser.print_ast(self._ast) + + def scan(self, should_print=True): + scanner = Scanner(self, self._source) + self._tokens = scanner.scan_tokens() + if should_print: + print(*self._tokens, sep="\n") + self._check_errror_exit() + + def _check_errror_exit(self): + if self._had_error: + sys.exit(65) + if self._had_runtime_error: + sys.exit(70) + + def _error_report(self, line: int, where: str, message: str): + self._had_error = True + print(f"This line will be skipped in stderr", file=sys.stderr) + + if message.endswith("@"): + output = f"[line {line}] Error{where}: {message}" + print(output, file=sys.stderr) + else: + output = f"[line {line}] eRrRr{where}: {message}" + print(output, file=sys.stderr) + + def _runtime_error(self, error: RuntimeError): + self._had_runtime_error = True + token, msg = error.args + output = msg + f"\n[line {token.line}]" + print(output, file=sys.stderr) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/parser.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/parser.py new file mode 100644 index 00000000..e003261f --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/parser.py @@ -0,0 +1,134 @@ +from typing import Optional + +from lox.scanner import Token, TokenType +from lox.types import * + + +class Parser: + @staticmethod + def print_ast(ast): + def repr(expr: Expr): + # fmt: off + match expr: + case Binary(left, op, right): return f'({op.lexeme} {repr(left)} {repr(right)})' + case Grouping(expr) : return f'(group {repr(expr)})' + case Literal(None) : return 'nil' + case Literal(False) : return 'false' + case Literal(True) : return 'true' + case Literal(value) : return value + case Unary(op, right) : return f'({op.lexeme} {repr(right)})' + # fmt: on + + print(repr(ast)) + + def __init__(self, lox, tokens: list[Token]): + self._lox = lox + self._tokens = tokens + self._current = 0 + + def parse(self) -> Optional[Expr]: + try: + return self._expression() + except ParseError: + return None + + def _advance(self) -> Token: + if not self._is_at_end(): + self._current += 1 + return self._previous() + + def _check(self, type: TokenType) -> bool: + if self._is_at_end(): + return False + return self._peek().type == type + + def _consume(self, type: TokenType, message: str) -> Token: + if self._check(type): + return self._advance() + raise self._error(self._peek(), message) + + def _error(self, token: Token, message: str) -> ParseError: + self._lox.error_token(token, message) + return ParseError() + + def _is_at_end(self) -> bool: + return self._peek().type == TokenType.EOF + + def _match(self, *types: TokenType) -> bool: + for type in types: + if self._check(type): + self._advance() + return True + return False + + def _peek(self) -> Token: + return self._tokens[self._current] + + def _previous(self) -> Token: + return self._tokens[self._current - 1] + + # Grammar + def _expression(self) -> Expr: + return self._equality() + + def _equality(self) -> Expr: + expr = self._comparison() + while self._match(TokenType.BANG_EQUAL, TokenType.EQUAL_EQUAL): + op = self._previous() + right = self._comparison() + expr = Binary(expr, op, right) + return expr + + def _comparison(self) -> Expr: + expr = self._term() + while self._match( + TokenType.GREATER, + TokenType.GREATER_EQUAL, + TokenType.LESS, + TokenType.LESS_EQUAL, + ): + op = self._previous() + right = self._term() + expr = Binary(expr, op, right) + return expr + + def _term(self) -> Expr: + expr = self._factor() + while self._match(TokenType.MINUS, TokenType.PLUS): + op = self._previous() + right = self._factor() + expr = Binary(expr, op, right) + return expr + + def _factor(self) -> Expr: + expr = self._unary() + while self._match(TokenType.SLASH, TokenType.STAR): + op = self._previous() + right = self._unary() + expr = Binary(expr, op, right) + return expr + + def _unary(self) -> Expr: + if self._match(TokenType.BANG, TokenType.MINUS): + op = self._previous() + right = self._unary() + return Unary(op, right) + return self._primary() + + def _primary(self): + if self._match(TokenType.FALSE): + return Literal(False) + if self._match(TokenType.TRUE): + return Literal(True) + if self._match(TokenType.NIL): + return Literal(None) + + if self._match(TokenType.NUMBER, TokenType.STRING): + return Literal(self._previous().literal) + + if self._match(TokenType.LEFT_PAREN): + expr = self._expression() + self._consume(TokenType.RIGHT_PAREN, "Expect ')' after expression.") + return Grouping(expr) + + self._error(self._peek(), 'Expect expression.') diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/scanner.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/scanner.py new file mode 100644 index 00000000..22f40996 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/scanner.py @@ -0,0 +1,120 @@ +from lox.types import * + + +class Scanner: + def __init__(self, lox, source: str): + self._tokens: list[Token] = [] + self._start = 0 + self._current = 0 + self._line = 1 + self._lox = lox + self._source = source + + def scan_tokens(self) -> list[Token]: + while not self._is_at_end(): + self._scan_token() + self._tokens += (Token(TokenType.EOF, '', None, self._line),) + return self._tokens + + def _add_token(self, type: TokenType, literal=None): + text = self._source[self._start : self._current] + self._tokens += (Token(type, text, literal, self._line),) + + def _advance(self) -> str: + c = self._source[self._current] + self._current += 1 + return c + + def _error_char(self): + char = self._source[self._start : self._current] + self._lox.error_line(self._line, 'Unexpected character: ' + char) + + def _identifier(self): + while (c := self._peek()).isalnum() or c == '_': + self._advance() + + text = self._source[self._start : self._current] + type = KEYWORDS.get(text) or TokenType.IDENTIFIER + self._add_token(type) + + def _is_at_end(self) -> bool: + return self._current >= len(self._source) + + def _match(self, expected: str) -> bool: + if self._is_at_end() or (self._source[self._current] != expected): + return False + self._current += 1 + return True + + def _number(self): + while self._peek().isdigit(): + self._advance() + + if self._peek() == '.' and self._peek_next().isdigit(): + self._advance() # Consume the "." + while self._peek().isdigit(): + self._advance() + + literal = self._source[self._start : self._current] + self._add_token(TokenType.NUMBER, float(literal)) + + def _peek(self) -> str: + return '\0' if self._is_at_end() else self._source[self._current] + + def _peek_next(self) -> str: + if self._current + 1 >= len(self._source): + return '\0' + return self._source[self._current + 1] + + def _scan_token(self): + self._start = self._current + # fmt: off + match c := self._advance(): + case '(' : self._add_token(TokenType.LEFT_PAREN) + case ')' : self._add_token(TokenType.RIGHT_PAREN) + case '{' : self._add_token(TokenType.LEFT_BRACE) + case '}' : self._add_token(TokenType.RIGHT_BRACE) + case ',' : self._add_token(TokenType.COMMA) + case '.' : self._add_token(TokenType.DOT) + case '-' : self._add_token(TokenType.MINUS) + case '+' : self._add_token(TokenType.PLUS) + case ';' : self._add_token(TokenType.SEMICOLON) + case '*' : self._add_token(TokenType.STAR) + + case '!' : self._add_token(TokenType.BANG_EQUAL if self._match('=') else TokenType.BANG) + case '=' : self._add_token(TokenType.EQUAL_EQUAL if self._match('=') else TokenType.EQUAL) + case '<' : self._add_token(TokenType.LESS_EQUAL if self._match('=') else TokenType.LESS) + case '>' : self._add_token(TokenType.GREATER_EQUAL if self._match('=') else TokenType.GREATER) + + case '/' : self._slash() + + case ' ' | '\t': pass + case '\n': self._line += 1 + + case '"' : self._string() + + case c if c.isdigit(): self._number() + case c if c.isalpha() or c == '_': self._identifier() + + case _ : self._error_char() + # fmt: on + + def _slash(self): + if self._match('/'): + while self._peek() != '\n' and not self._is_at_end(): + self._advance() + else: + self._add_token(TokenType.SLASH) + + def _string(self): + while self._peek() != '"' and not self._is_at_end(): + if self._peek() == '\n': + self._line += 1 + self._advance() + + if self._is_at_end(): + return self._lox.error_line(self._line, 'Unterminated string.') + + self._advance() # The closing ". + value = self._source[self._start + 1 : self._current - 1] + self._add_token(TokenType.STRING, value) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/types.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/types.py new file mode 100644 index 00000000..5dc1cd7f --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/types.py @@ -0,0 +1,80 @@ +from enum import Enum +from typing import NamedTuple + +# fmt: off +TokenType = Enum('TokenType', [ + # Single-character tokens. + 'LEFT_PAREN', 'RIGHT_PAREN', 'LEFT_BRACE', 'RIGHT_BRACE', + 'COMMA', 'DOT', 'MINUS', 'PLUS', 'SEMICOLON', 'SLASH', 'STAR', + + # One or two character tokens. + 'BANG', 'BANG_EQUAL', + 'EQUAL', 'EQUAL_EQUAL', + 'GREATER', 'GREATER_EQUAL', + 'LESS', 'LESS_EQUAL', + + # Literals. + 'IDENTIFIER', 'NUMBER', 'STRING', + + # Keywords. + 'AND', 'CLASS', 'ELSE', 'FALSE', 'FUN', 'FOR', 'IF', 'NIL', 'OR', + 'PRINT', 'RETURN', 'SUPER', 'THIS', 'TRUE', 'VAR', 'WHILE', + + 'EOF' +]) +# fmt: on + +KEYWORDS = { + 'and': TokenType.AND, + 'class': TokenType.CLASS, + 'else': TokenType.ELSE, + 'false': TokenType.FALSE, + 'for': TokenType.FOR, + 'fun': TokenType.FUN, + 'if': TokenType.IF, + 'nil': TokenType.NIL, + 'or': TokenType.OR, + 'print': TokenType.PRINT, + 'return': TokenType.RETURN, + 'super': TokenType.SUPER, + 'this': TokenType.THIS, + 'true': TokenType.TRUE, + 'var': TokenType.VAR, + 'while': TokenType.WHILE, +} + + +class Token(NamedTuple): + type: TokenType + lexeme: str + literal: object + line: int + + def __repr__(self): + return f'{self.type.name} {self.lexeme} {"null" if self.literal is None else self.literal}' + + +class ParseError(Exception): + pass + + +class Binary(NamedTuple): + left: 'Expr' + op: Token + right: 'Expr' + + +class Grouping(NamedTuple): + expr: 'Expr' + + +class Literal(NamedTuple): + value: object + + +class Unary(NamedTuple): + op: Token + right: 'Expr' + + +Expr = Binary | Grouping | Literal | Unary diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/your_program.sh b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/your_program.sh new file mode 100755 index 00000000..d6cb050b --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/your_program.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# +# Use this script to run your program LOCALLY. +# +# Note: Changing this script WILL NOT affect how CodeCrafters runs your program. +# +# Learn more: https://codecrafters.io/program-interface + +# Exit early if any commands fail +set -e + +# Copied from .codecrafters/run.sh +# +# - Edit this to change how your program runs locally +# - Edit .codecrafters/run.sh to change how your program runs remotely +PYTHONPATH=$(dirname $0) exec python3 -m app.main "$@" \ No newline at end of file diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/app/main.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/app/main.py new file mode 100644 index 00000000..a96f4b37 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/app/main.py @@ -0,0 +1,24 @@ +import argparse +import sys + +from lox.lox import Lox + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('command') + parser.add_argument('filename') + args = parser.parse_args() + + lox = Lox(args.filename) + # fmt: off + match args.command: + case 'tokenize' : lox.scan() + case 'parse' : lox.parse() + case 'evaluate' : lox.evaluate() + case _ : sys.exit(1) + # fmt: on + + +if __name__ == '__main__': + main() diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/codecrafters.yml b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/codecrafters.yml new file mode 100644 index 00000000..ec2956e1 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/codecrafters.yml @@ -0,0 +1,11 @@ +# Set this to true if you want debug logs. +# +# These can be VERY verbose, so we suggest turning them off +# unless you really need them. +debug: false + +# Use this to change the Python version used to run your code +# on Codecrafters. +# +# Available versions: python-3.12 +language_pack: python-3.12 diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/interpreter.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/interpreter.py new file mode 100644 index 00000000..48bcdc7e --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/interpreter.py @@ -0,0 +1,75 @@ +from lox.types import * + + +def repr(value): + if value is None: + return 'nil' + elif isinstance(value, bool): + return str(value).lower() + elif getattr(value, 'is_integer', False) and value.is_integer(): + return int(value) + else: + return value + + +def check_number_operand(operator: Token, operand): + if isinstance(operand, float): + return + # print(operand, type(operand)) + raise RuntimeError(operator, 'Operand must be a number.') + + +def check_number_operands(operator: Token, left, right): + if isinstance(left, float) and isinstance(right, float): + return + raise RuntimeError(operator, 'Operands must be numbers.') + + +def interpret(ast): + def eval(expr: Expr): + # fmt: off + match expr: + case Binary(left, op, right): + l, r = eval(left), eval(right) + + match op.type: + case TokenType.BANG_EQUAL : return l != r + case TokenType.EQUAL_EQUAL: return l == r + case TokenType.PLUS: + if (isinstance(l, float) and isinstance(r, float) + or isinstance(l, str) and isinstance(r, str)): + return l + r + raise RuntimeError(op, 'Operands must be two numbers or two strings.') + + check_number_operands(op, l, r) + match op.type: + case TokenType.MINUS : return l - r + case TokenType.SLASH : return l / r + case TokenType.STAR : return l * r + case TokenType.GREATER : return l > r + case TokenType.GREATER_EQUAL: return l >= r + case TokenType.LESS : return l < r + case TokenType.LESS_EQUAL : return l <= r + + case Grouping(expr) : return eval(expr) + case Literal(value) : return value + case Unary(op, right): + match op.type: + case TokenType.BANG : + return not is_truthy(right) + case TokenType.MINUS: + r = eval(right) + check_number_operand(op, r) + return -r + # fmt: on + + def is_truthy(value: Expr): + # fmt: off + match value: + case Literal('nil') : return False + case Literal('false'): return False + case Literal('true') : return True + case _ : return eval(value) + # fmt: on + + print(repr(eval(ast))) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py new file mode 100644 index 00000000..c21cd65c --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py @@ -0,0 +1,67 @@ +import sys + +from lox.interpreter import interpret +from lox.parser import Parser +from lox.scanner import Scanner, Token, TokenType + + +class Lox: + def __init__(self, filename: str): + self._tokens: list[Token] = [] + self._ast = None + self._had_error = False + self._had_runtime_error = False + + with open(filename) as f: + self._source = f.read() + + def error_line(self, line: int, message: str): + self._error_report(line, "", message) + + def error_token(self, token: Token, message: str): + where = " at " + ("end" if token.type == TokenType.EOF else f"'{token.lexeme}'") + self._error_report(token.line, where, message) + + def evaluate(self): + self.parse(False) + try: + interpret(self._ast) + except RuntimeError as e: + self._runtime_error(e) + self._check_errror_exit() + + def parse(self, should_print=True): + self.scan(False) + parser = Parser(self, self._tokens) + self._ast = parser.parse() + self._check_errror_exit() + if should_print: + Parser.print_ast(self._ast) + + def scan(self, should_print=True): + scanner = Scanner(self, self._source) + self._tokens = scanner.scan_tokens() + if should_print: + print(*self._tokens, sep="\n") + self._check_errror_exit() + + def _check_errror_exit(self): + if self._had_error: + sys.exit(65) + if self._had_runtime_error: + sys.exit(70) + + def _error_report(self, line: int, where: str, message: str): + self._had_error = True + print(f"This line will be skipped in stderr", file=sys.stderr) + + output = f"[line {line}] Error{where}: {message}" + print(output, file=sys.stderr) + if message.endswith("#"): + print("[line 888] extra line", file=sys.stderr) + + def _runtime_error(self, error: RuntimeError): + self._had_runtime_error = True + token, msg = error.args + output = msg + f"\n[line {token.line}]" + print(output, file=sys.stderr) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/parser.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/parser.py new file mode 100644 index 00000000..e003261f --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/parser.py @@ -0,0 +1,134 @@ +from typing import Optional + +from lox.scanner import Token, TokenType +from lox.types import * + + +class Parser: + @staticmethod + def print_ast(ast): + def repr(expr: Expr): + # fmt: off + match expr: + case Binary(left, op, right): return f'({op.lexeme} {repr(left)} {repr(right)})' + case Grouping(expr) : return f'(group {repr(expr)})' + case Literal(None) : return 'nil' + case Literal(False) : return 'false' + case Literal(True) : return 'true' + case Literal(value) : return value + case Unary(op, right) : return f'({op.lexeme} {repr(right)})' + # fmt: on + + print(repr(ast)) + + def __init__(self, lox, tokens: list[Token]): + self._lox = lox + self._tokens = tokens + self._current = 0 + + def parse(self) -> Optional[Expr]: + try: + return self._expression() + except ParseError: + return None + + def _advance(self) -> Token: + if not self._is_at_end(): + self._current += 1 + return self._previous() + + def _check(self, type: TokenType) -> bool: + if self._is_at_end(): + return False + return self._peek().type == type + + def _consume(self, type: TokenType, message: str) -> Token: + if self._check(type): + return self._advance() + raise self._error(self._peek(), message) + + def _error(self, token: Token, message: str) -> ParseError: + self._lox.error_token(token, message) + return ParseError() + + def _is_at_end(self) -> bool: + return self._peek().type == TokenType.EOF + + def _match(self, *types: TokenType) -> bool: + for type in types: + if self._check(type): + self._advance() + return True + return False + + def _peek(self) -> Token: + return self._tokens[self._current] + + def _previous(self) -> Token: + return self._tokens[self._current - 1] + + # Grammar + def _expression(self) -> Expr: + return self._equality() + + def _equality(self) -> Expr: + expr = self._comparison() + while self._match(TokenType.BANG_EQUAL, TokenType.EQUAL_EQUAL): + op = self._previous() + right = self._comparison() + expr = Binary(expr, op, right) + return expr + + def _comparison(self) -> Expr: + expr = self._term() + while self._match( + TokenType.GREATER, + TokenType.GREATER_EQUAL, + TokenType.LESS, + TokenType.LESS_EQUAL, + ): + op = self._previous() + right = self._term() + expr = Binary(expr, op, right) + return expr + + def _term(self) -> Expr: + expr = self._factor() + while self._match(TokenType.MINUS, TokenType.PLUS): + op = self._previous() + right = self._factor() + expr = Binary(expr, op, right) + return expr + + def _factor(self) -> Expr: + expr = self._unary() + while self._match(TokenType.SLASH, TokenType.STAR): + op = self._previous() + right = self._unary() + expr = Binary(expr, op, right) + return expr + + def _unary(self) -> Expr: + if self._match(TokenType.BANG, TokenType.MINUS): + op = self._previous() + right = self._unary() + return Unary(op, right) + return self._primary() + + def _primary(self): + if self._match(TokenType.FALSE): + return Literal(False) + if self._match(TokenType.TRUE): + return Literal(True) + if self._match(TokenType.NIL): + return Literal(None) + + if self._match(TokenType.NUMBER, TokenType.STRING): + return Literal(self._previous().literal) + + if self._match(TokenType.LEFT_PAREN): + expr = self._expression() + self._consume(TokenType.RIGHT_PAREN, "Expect ')' after expression.") + return Grouping(expr) + + self._error(self._peek(), 'Expect expression.') diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/scanner.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/scanner.py new file mode 100644 index 00000000..22f40996 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/scanner.py @@ -0,0 +1,120 @@ +from lox.types import * + + +class Scanner: + def __init__(self, lox, source: str): + self._tokens: list[Token] = [] + self._start = 0 + self._current = 0 + self._line = 1 + self._lox = lox + self._source = source + + def scan_tokens(self) -> list[Token]: + while not self._is_at_end(): + self._scan_token() + self._tokens += (Token(TokenType.EOF, '', None, self._line),) + return self._tokens + + def _add_token(self, type: TokenType, literal=None): + text = self._source[self._start : self._current] + self._tokens += (Token(type, text, literal, self._line),) + + def _advance(self) -> str: + c = self._source[self._current] + self._current += 1 + return c + + def _error_char(self): + char = self._source[self._start : self._current] + self._lox.error_line(self._line, 'Unexpected character: ' + char) + + def _identifier(self): + while (c := self._peek()).isalnum() or c == '_': + self._advance() + + text = self._source[self._start : self._current] + type = KEYWORDS.get(text) or TokenType.IDENTIFIER + self._add_token(type) + + def _is_at_end(self) -> bool: + return self._current >= len(self._source) + + def _match(self, expected: str) -> bool: + if self._is_at_end() or (self._source[self._current] != expected): + return False + self._current += 1 + return True + + def _number(self): + while self._peek().isdigit(): + self._advance() + + if self._peek() == '.' and self._peek_next().isdigit(): + self._advance() # Consume the "." + while self._peek().isdigit(): + self._advance() + + literal = self._source[self._start : self._current] + self._add_token(TokenType.NUMBER, float(literal)) + + def _peek(self) -> str: + return '\0' if self._is_at_end() else self._source[self._current] + + def _peek_next(self) -> str: + if self._current + 1 >= len(self._source): + return '\0' + return self._source[self._current + 1] + + def _scan_token(self): + self._start = self._current + # fmt: off + match c := self._advance(): + case '(' : self._add_token(TokenType.LEFT_PAREN) + case ')' : self._add_token(TokenType.RIGHT_PAREN) + case '{' : self._add_token(TokenType.LEFT_BRACE) + case '}' : self._add_token(TokenType.RIGHT_BRACE) + case ',' : self._add_token(TokenType.COMMA) + case '.' : self._add_token(TokenType.DOT) + case '-' : self._add_token(TokenType.MINUS) + case '+' : self._add_token(TokenType.PLUS) + case ';' : self._add_token(TokenType.SEMICOLON) + case '*' : self._add_token(TokenType.STAR) + + case '!' : self._add_token(TokenType.BANG_EQUAL if self._match('=') else TokenType.BANG) + case '=' : self._add_token(TokenType.EQUAL_EQUAL if self._match('=') else TokenType.EQUAL) + case '<' : self._add_token(TokenType.LESS_EQUAL if self._match('=') else TokenType.LESS) + case '>' : self._add_token(TokenType.GREATER_EQUAL if self._match('=') else TokenType.GREATER) + + case '/' : self._slash() + + case ' ' | '\t': pass + case '\n': self._line += 1 + + case '"' : self._string() + + case c if c.isdigit(): self._number() + case c if c.isalpha() or c == '_': self._identifier() + + case _ : self._error_char() + # fmt: on + + def _slash(self): + if self._match('/'): + while self._peek() != '\n' and not self._is_at_end(): + self._advance() + else: + self._add_token(TokenType.SLASH) + + def _string(self): + while self._peek() != '"' and not self._is_at_end(): + if self._peek() == '\n': + self._line += 1 + self._advance() + + if self._is_at_end(): + return self._lox.error_line(self._line, 'Unterminated string.') + + self._advance() # The closing ". + value = self._source[self._start + 1 : self._current - 1] + self._add_token(TokenType.STRING, value) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/types.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/types.py new file mode 100644 index 00000000..5dc1cd7f --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/types.py @@ -0,0 +1,80 @@ +from enum import Enum +from typing import NamedTuple + +# fmt: off +TokenType = Enum('TokenType', [ + # Single-character tokens. + 'LEFT_PAREN', 'RIGHT_PAREN', 'LEFT_BRACE', 'RIGHT_BRACE', + 'COMMA', 'DOT', 'MINUS', 'PLUS', 'SEMICOLON', 'SLASH', 'STAR', + + # One or two character tokens. + 'BANG', 'BANG_EQUAL', + 'EQUAL', 'EQUAL_EQUAL', + 'GREATER', 'GREATER_EQUAL', + 'LESS', 'LESS_EQUAL', + + # Literals. + 'IDENTIFIER', 'NUMBER', 'STRING', + + # Keywords. + 'AND', 'CLASS', 'ELSE', 'FALSE', 'FUN', 'FOR', 'IF', 'NIL', 'OR', + 'PRINT', 'RETURN', 'SUPER', 'THIS', 'TRUE', 'VAR', 'WHILE', + + 'EOF' +]) +# fmt: on + +KEYWORDS = { + 'and': TokenType.AND, + 'class': TokenType.CLASS, + 'else': TokenType.ELSE, + 'false': TokenType.FALSE, + 'for': TokenType.FOR, + 'fun': TokenType.FUN, + 'if': TokenType.IF, + 'nil': TokenType.NIL, + 'or': TokenType.OR, + 'print': TokenType.PRINT, + 'return': TokenType.RETURN, + 'super': TokenType.SUPER, + 'this': TokenType.THIS, + 'true': TokenType.TRUE, + 'var': TokenType.VAR, + 'while': TokenType.WHILE, +} + + +class Token(NamedTuple): + type: TokenType + lexeme: str + literal: object + line: int + + def __repr__(self): + return f'{self.type.name} {self.lexeme} {"null" if self.literal is None else self.literal}' + + +class ParseError(Exception): + pass + + +class Binary(NamedTuple): + left: 'Expr' + op: Token + right: 'Expr' + + +class Grouping(NamedTuple): + expr: 'Expr' + + +class Literal(NamedTuple): + value: object + + +class Unary(NamedTuple): + op: Token + right: 'Expr' + + +Expr = Binary | Grouping | Literal | Unary diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/your_program.sh b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/your_program.sh new file mode 100755 index 00000000..d6cb050b --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/your_program.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# +# Use this script to run your program LOCALLY. +# +# Note: Changing this script WILL NOT affect how CodeCrafters runs your program. +# +# Learn more: https://codecrafters.io/program-interface + +# Exit early if any commands fail +set -e + +# Copied from .codecrafters/run.sh +# +# - Edit this to change how your program runs locally +# - Edit .codecrafters/run.sh to change how your program runs remotely +PYTHONPATH=$(dirname $0) exec python3 -m app.main "$@" \ No newline at end of file From 455409a3727231d6d0a3b1415a09b08264556183 Mon Sep 17 00:00:00 2001 From: Andy Li <1450947+andy1li@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:39:41 +0800 Subject: [PATCH 5/8] feat: Add new test fixture for failing multiline errors and remove old fixture --- internal/stages_test.go | 126 +++++++++--------- .../fixtures/fail_scanning_errors_1 | 9 -- .../1_missing_line | 25 ++++ .../fail_scanning_multiline_errors/2_mismatch | 30 +++++ .../3_extra_line | 30 +++++ 5 files changed, 148 insertions(+), 72 deletions(-) delete mode 100644 internal/test_helpers/fixtures/fail_scanning_errors_1 create mode 100644 internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line create mode 100644 internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch create mode 100644 internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line diff --git a/internal/stages_test.go b/internal/stages_test.go index 61496e18..db824b6c 100644 --- a/internal/stages_test.go +++ b/internal/stages_test.go @@ -54,69 +54,69 @@ func TestStages(t *testing.T) { StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line", NormalizeOutputFunc: normalizeTesterOutput, }, - // "pass_scanning_jlox": { - // UntilStageSlug: "pq5", - // CodePath: "../craftinginterpreters/build/gen/chap04_scanning", - // ExpectedExitCode: 0, - // StdoutFixturePath: "./test_helpers/fixtures/pass_scanning", - // NormalizeOutputFunc: normalizeTesterOutput, - // }, - // "pass_parsing_jlox": { - // StageSlugs: []string{"wz8", "ht8", "uh4", "yf2", "wa9", "mq1", "xe6", "th5", "ra8", "sc2"}, - // CodePath: "../craftinginterpreters/build/gen/chap06_parsing", - // ExpectedExitCode: 0, - // StdoutFixturePath: "./test_helpers/fixtures/pass_parsing", - // NormalizeOutputFunc: normalizeTesterOutput, - // }, - // "pass_evaluating_jlox": { - // StageSlugs: []string{"ib5", "cq1", "yu6", "gj9", "hw7", "et4", "jx8", "jy2", "bp3", "dc1", "oq9", "lv1", "iz6"}, - // CodePath: "../craftinginterpreters/build/gen/chap07_evaluating", - // ExpectedExitCode: 0, - // StdoutFixturePath: "./test_helpers/fixtures/pass_evaluating", - // NormalizeOutputFunc: normalizeTesterOutput, - // }, - // "pass_statements_inprogress_jlox": { - // StageSlugs: []string{"xy1", "oe4", "fi3", "yg2", "sv7", "bc1", "dw9", "pl3", "vr5", "fb4"}, - // CodePath: "../craftinginterpreters/build/gen/chap08_statements", - // ExpectedExitCode: 0, - // StdoutFixturePath: "./test_helpers/fixtures/pass_statements", - // NormalizeOutputFunc: normalizeTesterOutput, - // }, - // "pass_statements_completed_jlox": { - // StageSlugs: []string{"xy1", "oe4", "fi3", "yg2", "sv7", "bc1", "dw9", "pl3", "vr5", "fb4"}, - // CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", - // ExpectedExitCode: 0, - // StdoutFixturePath: "./test_helpers/fixtures/pass_statements_final", - // NormalizeOutputFunc: normalizeTesterOutput, - // }, - // "pass_control_flow_inprogress_jlox": { - // StageSlugs: []string{"ne3", "st5", "fh8", "xj4", "wk8", "jx4", "qy3", "bw6", "vt1"}, - // CodePath: "../craftinginterpreters/build/gen/chap09_control", - // ExpectedExitCode: 0, - // StdoutFixturePath: "./test_helpers/fixtures/pass_control_flow", - // NormalizeOutputFunc: normalizeTesterOutput, - // }, - // "pass_control_flow_completed_jlox": { - // StageSlugs: []string{"ne3", "st5", "fh8", "xj4", "wk8", "jx4", "qy3", "bw6", "vt1"}, - // CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", - // ExpectedExitCode: 0, - // StdoutFixturePath: "./test_helpers/fixtures/pass_control_flow_final", - // NormalizeOutputFunc: normalizeTesterOutput, - // }, - // "pass_functions_inprogress_jlox": { - // StageSlugs: []string{"av4", "pg8", "lb6", "px4", "rd2", "ey3", "fj7", "bz4", "gg6"}, - // CodePath: "../craftinginterpreters/build/gen/chap10_functions", - // ExpectedExitCode: 0, - // StdoutFixturePath: "./test_helpers/fixtures/pass_functions", - // NormalizeOutputFunc: normalizeTesterOutput, - // }, - // "pass_functions_completed_jlox": { - // StageSlugs: []string{"av4", "pg8", "lb6", "px4", "rd2", "ey3", "fj7", "bz4", "gg6"}, - // CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", - // ExpectedExitCode: 0, - // StdoutFixturePath: "./test_helpers/fixtures/pass_functions_final", - // NormalizeOutputFunc: normalizeTesterOutput, - // }, + "pass_scanning_jlox": { + UntilStageSlug: "pq5", + CodePath: "../craftinginterpreters/build/gen/chap04_scanning", + ExpectedExitCode: 0, + StdoutFixturePath: "./test_helpers/fixtures/pass_scanning", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "pass_parsing_jlox": { + StageSlugs: []string{"wz8", "ht8", "uh4", "yf2", "wa9", "mq1", "xe6", "th5", "ra8", "sc2"}, + CodePath: "../craftinginterpreters/build/gen/chap06_parsing", + ExpectedExitCode: 0, + StdoutFixturePath: "./test_helpers/fixtures/pass_parsing", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "pass_evaluating_jlox": { + StageSlugs: []string{"ib5", "cq1", "yu6", "gj9", "hw7", "et4", "jx8", "jy2", "bp3", "dc1", "oq9", "lv1", "iz6"}, + CodePath: "../craftinginterpreters/build/gen/chap07_evaluating", + ExpectedExitCode: 0, + StdoutFixturePath: "./test_helpers/fixtures/pass_evaluating", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "pass_statements_inprogress_jlox": { + StageSlugs: []string{"xy1", "oe4", "fi3", "yg2", "sv7", "bc1", "dw9", "pl3", "vr5", "fb4"}, + CodePath: "../craftinginterpreters/build/gen/chap08_statements", + ExpectedExitCode: 0, + StdoutFixturePath: "./test_helpers/fixtures/pass_statements", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "pass_statements_completed_jlox": { + StageSlugs: []string{"xy1", "oe4", "fi3", "yg2", "sv7", "bc1", "dw9", "pl3", "vr5", "fb4"}, + CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", + ExpectedExitCode: 0, + StdoutFixturePath: "./test_helpers/fixtures/pass_statements_final", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "pass_control_flow_inprogress_jlox": { + StageSlugs: []string{"ne3", "st5", "fh8", "xj4", "wk8", "jx4", "qy3", "bw6", "vt1"}, + CodePath: "../craftinginterpreters/build/gen/chap09_control", + ExpectedExitCode: 0, + StdoutFixturePath: "./test_helpers/fixtures/pass_control_flow", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "pass_control_flow_completed_jlox": { + StageSlugs: []string{"ne3", "st5", "fh8", "xj4", "wk8", "jx4", "qy3", "bw6", "vt1"}, + CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", + ExpectedExitCode: 0, + StdoutFixturePath: "./test_helpers/fixtures/pass_control_flow_final", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "pass_functions_inprogress_jlox": { + StageSlugs: []string{"av4", "pg8", "lb6", "px4", "rd2", "ey3", "fj7", "bz4", "gg6"}, + CodePath: "../craftinginterpreters/build/gen/chap10_functions", + ExpectedExitCode: 0, + StdoutFixturePath: "./test_helpers/fixtures/pass_functions", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "pass_functions_completed_jlox": { + StageSlugs: []string{"av4", "pg8", "lb6", "px4", "rd2", "ey3", "fj7", "bz4", "gg6"}, + CodePath: "../craftinginterpreters/build/gen/chap13_inheritance", + ExpectedExitCode: 0, + StdoutFixturePath: "./test_helpers/fixtures/pass_functions_final", + NormalizeOutputFunc: normalizeTesterOutput, + }, } tester_utils_testing.TestTesterOutput(t, testerDefinition, testCases) diff --git a/internal/test_helpers/fixtures/fail_scanning_errors_1 b/internal/test_helpers/fixtures/fail_scanning_errors_1 deleted file mode 100644 index 2882e69e..00000000 --- a/internal/test_helpers/fixtures/fail_scanning_errors_1 +++ /dev/null @@ -1,9 +0,0 @@ -[stage-5] Running tests for Stage #5: ea6 -[stage-5] [test-1] Running test case: 1 -[stage-5] [test-1] Writing contents to ./test.lox: -[stage-5] [test-1] [test.lox] @ -[stage-5] [test-1] $ ./your_program.sh tokenize test.lox -[your_program] [line 1] Error: Unexpected character: @ -[your_program] EOF null -[stage-5] [test-1] Expected line #1 on stderr to be "[line 1] Error: Unexpected character: @", but didn't find line -[stage-5] [test-1] Test failed (try setting 'debug: true' in your codecrafters.yml to see more details) diff --git a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line new file mode 100644 index 00000000..3a0f05dc --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line @@ -0,0 +1,25 @@ +[stage-11] Running tests for Stage #11: tz7 +[stage-11] [test-1] Running test case: 1 +[stage-11] [test-1] Writing contents to ./test.lox: +[stage-11] [test-1] [test.lox] ()<|SPACE|><|TAB|>@ +[stage-11] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] [line 2] Error: Unexpected character: @ +[your_program] LEFT_PAREN ( null +[your_program] RIGHT_PAREN ) null +[your_program] EOF null +[stage-11] [test-1] ✓ 1 line(s) match on stderr +[stage-11] [test-1] ✓ 3 line(s) match on stdout +[stage-11] [test-1] ✓ Received exit code 65. +[stage-11] [test-2] Running test case: 2 +[stage-11] [test-2] Writing contents to ./test.lox: +[stage-11] [test-2] [test.lox] @# +[stage-11] [test-2] [test.lox] <|SPACE|> +[stage-11] [test-2] $ ./your_program.sh tokenize test.lox +[your_program] EOF null +[your_program] [line 1] Error: Unexpected character: @ +[stage-11] [test-2] ✓ [line 1] Error: Unexpected character: @ +[stage-11] [test-2]  +[stage-11] [test-2] [stderr] Missing line #2 from stderr: "[line 1] Error: Unexpected character: #" +[stage-11] [test-2] [stderr] Perhaps it's printed to stdout? It should be printed to stderr. +[stage-11] [test-2]   +[stage-11] [test-2] Test failed (try setting 'debug: true' in your codecrafters.yml to see more details) diff --git a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch new file mode 100644 index 00000000..d1a7f4fb --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch @@ -0,0 +1,30 @@ +[stage-11] Running tests for Stage #11: tz7 +[stage-11] [test-1] Running test case: 1 +[stage-11] [test-1] Writing contents to ./test.lox: +[stage-11] [test-1] [test.lox] ()<|SPACE|><|TAB|>@ +[stage-11] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] LEFT_PAREN ( null +[your_program] RIGHT_PAREN ) null +[your_program] EOF null +[your_program] This line will be skipped in stderr +[your_program] [line 2] Error: Unexpected character: @ +[stage-11] [test-1] ✓ 1 line(s) match on stderr +[stage-11] [test-1] ✓ 3 line(s) match on stdout +[stage-11] [test-1] ✓ Received exit code 65. +[stage-11] [test-2] Running test case: 2 +[stage-11] [test-2] Writing contents to ./test.lox: +[stage-11] [test-2] [test.lox] @# +[stage-11] [test-2] [test.lox] <|SPACE|> +[stage-11] [test-2] $ ./your_program.sh tokenize test.lox +[your_program] EOF null +[your_program] This line will be skipped in stderr +[your_program] [line 1] Error: Unexpected character: @ +[your_program] This line will be skipped in stderr +[your_program] [line 1] eRrRr: Unexpected character: # +[stage-11] [test-2] ✓ [line 1] Error: Unexpected character: @ +[stage-11] [test-2]  +[stage-11] [test-2] [stderr] Mismatch on line #2 of stderr: +[stage-11] [test-2] [stderr] Expected: "[line 1] Error: Unexpected character: #" +[stage-11] [test-2] [stderr] Actual : "[line 1] eRrRr: Unexpected character: #" +[stage-11] [test-2]   +[stage-11] [test-2] Test failed (try setting 'debug: true' in your codecrafters.yml to see more details) diff --git a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line new file mode 100644 index 00000000..9cebc16b --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line @@ -0,0 +1,30 @@ +[stage-11] Running tests for Stage #11: tz7 +[stage-11] [test-1] Running test case: 1 +[stage-11] [test-1] Writing contents to ./test.lox: +[stage-11] [test-1] [test.lox] ()<|SPACE|><|TAB|>@ +[stage-11] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] LEFT_PAREN ( null +[your_program] This line will be skipped in stderr +[your_program] [line 2] Error: Unexpected character: @ +[your_program] RIGHT_PAREN ) null +[your_program] EOF null +[stage-11] [test-1] ✓ 1 line(s) match on stderr +[stage-11] [test-1] ✓ 3 line(s) match on stdout +[stage-11] [test-1] ✓ Received exit code 65. +[stage-11] [test-2] Running test case: 2 +[stage-11] [test-2] Writing contents to ./test.lox: +[stage-11] [test-2] [test.lox] @# +[stage-11] [test-2] [test.lox] <|SPACE|> +[stage-11] [test-2] $ ./your_program.sh tokenize test.lox +[your_program] This line will be skipped in stderr +[your_program] EOF null +[your_program] [line 1] Error: Unexpected character: @ +[your_program] This line will be skipped in stderr +[your_program] [line 1] Error: Unexpected character: # +[your_program] [line 888] extra line +[stage-11] [test-2] ✓ [line 1] Error: Unexpected character: @ +[stage-11] [test-2] ✓ [line 1] Error: Unexpected character: # +[stage-11] [test-2]  +[stage-11] [test-2] 𐄂 [stderr] Extra unexpected line in stderr: "[line 888] extra line" +[stage-11] [test-2]   +[stage-11] [test-2] Test failed (try setting 'debug: true' in your codecrafters.yml to see more details) From 4af00b60fab84ab96a175d34385dd79f9e24a6ed Mon Sep 17 00:00:00 2001 From: Andy Li <1450947+andy1li@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:50:03 +0800 Subject: [PATCH 6/8] feat: Improve handling of skipped lines in stderr assertion and update test fixtures --- internal/assertions/stderr_assertion.go | 8 ++++---- .../fail_scanning_multiline_errors/1_missing_line | 2 +- .../fixtures/fail_scanning_multiline_errors/2_mismatch | 5 +---- .../fixtures/fail_scanning_multiline_errors/3_extra_line | 7 ++----- .../fail_scanning_multiline_errors/2_mismatch/lox/lox.py | 2 -- .../3_extra_line/lox/lox.py | 2 -- 6 files changed, 8 insertions(+), 18 deletions(-) diff --git a/internal/assertions/stderr_assertion.go b/internal/assertions/stderr_assertion.go index 26575d0b..0e163c56 100644 --- a/internal/assertions/stderr_assertion.go +++ b/internal/assertions/stderr_assertion.go @@ -22,14 +22,14 @@ func (a StderrAssertion) Run(result executable.ExecutableResult, logger *logger. stderr := getStderrLinesFromExecutableResult(result) skippedLines := getSkippedLinesCount(result) + if skippedLines > 0 { + logger.Plainf("[stderr] Skipped %d lines that didn't start with [line N]", skippedLines) + } + for i, expectedLine := range a.ExpectedLines { if i >= len(stderr) { logAllSuccessLogs(successLogs, logger) - if skippedLines > 0 { - logger.Plainf("[stderr] Skipped %d lines that didn't start with [line N]", skippedLines) - } - return fmt.Errorf(` [stderr] Missing line #%d from stderr: %q [stderr] Perhaps it's printed to stdout? It should be printed to stderr. diff --git a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line index 3a0f05dc..d113fcfb 100644 --- a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line @@ -3,9 +3,9 @@ [stage-11] [test-1] Writing contents to ./test.lox: [stage-11] [test-1] [test.lox] ()<|SPACE|><|TAB|>@ [stage-11] [test-1] $ ./your_program.sh tokenize test.lox -[your_program] [line 2] Error: Unexpected character: @ [your_program] LEFT_PAREN ( null [your_program] RIGHT_PAREN ) null +[your_program] [line 2] Error: Unexpected character: @ [your_program] EOF null [stage-11] [test-1] ✓ 1 line(s) match on stderr [stage-11] [test-1] ✓ 3 line(s) match on stdout diff --git a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch index d1a7f4fb..f26bd973 100644 --- a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch @@ -3,11 +3,10 @@ [stage-11] [test-1] Writing contents to ./test.lox: [stage-11] [test-1] [test.lox] ()<|SPACE|><|TAB|>@ [stage-11] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] [line 2] Error: Unexpected character: @ [your_program] LEFT_PAREN ( null [your_program] RIGHT_PAREN ) null [your_program] EOF null -[your_program] This line will be skipped in stderr -[your_program] [line 2] Error: Unexpected character: @ [stage-11] [test-1] ✓ 1 line(s) match on stderr [stage-11] [test-1] ✓ 3 line(s) match on stdout [stage-11] [test-1] ✓ Received exit code 65. @@ -17,9 +16,7 @@ [stage-11] [test-2] [test.lox] <|SPACE|> [stage-11] [test-2] $ ./your_program.sh tokenize test.lox [your_program] EOF null -[your_program] This line will be skipped in stderr [your_program] [line 1] Error: Unexpected character: @ -[your_program] This line will be skipped in stderr [your_program] [line 1] eRrRr: Unexpected character: # [stage-11] [test-2] ✓ [line 1] Error: Unexpected character: @ [stage-11] [test-2]  diff --git a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line index 9cebc16b..2f8a4bff 100644 --- a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line @@ -3,9 +3,8 @@ [stage-11] [test-1] Writing contents to ./test.lox: [stage-11] [test-1] [test.lox] ()<|SPACE|><|TAB|>@ [stage-11] [test-1] $ ./your_program.sh tokenize test.lox -[your_program] LEFT_PAREN ( null -[your_program] This line will be skipped in stderr [your_program] [line 2] Error: Unexpected character: @ +[your_program] LEFT_PAREN ( null [your_program] RIGHT_PAREN ) null [your_program] EOF null [stage-11] [test-1] ✓ 1 line(s) match on stderr @@ -16,10 +15,8 @@ [stage-11] [test-2] [test.lox] @# [stage-11] [test-2] [test.lox] <|SPACE|> [stage-11] [test-2] $ ./your_program.sh tokenize test.lox -[your_program] This line will be skipped in stderr -[your_program] EOF null [your_program] [line 1] Error: Unexpected character: @ -[your_program] This line will be skipped in stderr +[your_program] EOF null [your_program] [line 1] Error: Unexpected character: # [your_program] [line 888] extra line [stage-11] [test-2] ✓ [line 1] Error: Unexpected character: @ diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py index 90669323..4e028b96 100644 --- a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py @@ -53,8 +53,6 @@ def _check_errror_exit(self): def _error_report(self, line: int, where: str, message: str): self._had_error = True - print(f"This line will be skipped in stderr", file=sys.stderr) - if message.endswith("@"): output = f"[line {line}] Error{where}: {message}" print(output, file=sys.stderr) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py index c21cd65c..9bf0ee06 100644 --- a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py @@ -53,8 +53,6 @@ def _check_errror_exit(self): def _error_report(self, line: int, where: str, message: str): self._had_error = True - print(f"This line will be skipped in stderr", file=sys.stderr) - output = f"[line {line}] Error{where}: {message}" print(output, file=sys.stderr) if message.endswith("#"): From f75c9b9d0fc809b211ba2b4ff96df6dc2d8faf81 Mon Sep 17 00:00:00 2001 From: Andy Li <1450947+andy1li@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:06:13 +0800 Subject: [PATCH 7/8] refactor(tests): Remove unnecessary test cases from stages_test.go --- internal/stages_test.go | 42 ----------------------------------------- 1 file changed, 42 deletions(-) diff --git a/internal/stages_test.go b/internal/stages_test.go index db824b6c..66a72d48 100644 --- a/internal/stages_test.go +++ b/internal/stages_test.go @@ -12,48 +12,6 @@ func TestStages(t *testing.T) { os.Setenv("CODECRAFTERS_RANDOM_SEED", "1234567890") testCases := map[string]tester_utils_testing.TesterOutputTestCase{ - "fail_scanning_lexical_errors_1_missing_line": { - UntilStageSlug: "ea6", - CodePath: "./test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line", - ExpectedExitCode: 1, - StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "fail_scanning_lexical_errors_2_mismatch": { - UntilStageSlug: "ea6", - CodePath: "./test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch", - ExpectedExitCode: 1, - StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "fail_scanning_lexical_errors_3_extra_line": { - UntilStageSlug: "ea6", - CodePath: "./test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line", - ExpectedExitCode: 1, - StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "fail_scanning_multiline_errors_1_missing_line": { - UntilStageSlug: "tz7", - CodePath: "./test_helpers/scenarios/fail_scanning_multiline_errors/1_missing_line", - ExpectedExitCode: 1, - StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "fail_scanning_multiline_errors_2_mismatch": { - UntilStageSlug: "tz7", - CodePath: "./test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch", - ExpectedExitCode: 1, - StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch", - NormalizeOutputFunc: normalizeTesterOutput, - }, - "fail_scanning_multiline_errors_3_extra_line": { - UntilStageSlug: "tz7", - CodePath: "./test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line", - ExpectedExitCode: 1, - StdoutFixturePath: "./test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line", - NormalizeOutputFunc: normalizeTesterOutput, - }, "pass_scanning_jlox": { UntilStageSlug: "pq5", CodePath: "../craftinginterpreters/build/gen/chap04_scanning", From c90760b7898e3b697761f99ccbbf7c6667ff9515 Mon Sep 17 00:00:00 2001 From: Andy Li <1450947+andy1li@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:10:26 +0800 Subject: [PATCH 8/8] feat: Update error reporting to skip certain lines in stderr output --- .../fixtures/fail_scanning_lexical_errors/1_missing_line | 2 +- .../fixtures/fail_scanning_lexical_errors/2_mismatch | 2 ++ .../fixtures/fail_scanning_lexical_errors/3_extra_line | 2 ++ .../fail_scanning_multiline_errors/1_missing_line | 2 +- .../fixtures/fail_scanning_multiline_errors/2_mismatch | 7 ++++++- .../fixtures/fail_scanning_multiline_errors/3_extra_line | 9 +++++++-- .../2_mismatch/app/scanner.py | 2 ++ .../3_extra_line/app/scanner.py | 2 ++ .../fail_scanning_multiline_errors/2_mismatch/lox/lox.py | 3 +++ .../3_extra_line/lox/lox.py | 3 +++ 10 files changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line index 58c77ac9..5e0bbb58 100644 --- a/internal/test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line +++ b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line @@ -3,9 +3,9 @@ [stage-5] [test-1] Writing contents to ./test.lox: [stage-5] [test-1] [test.lox] @ [stage-5] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] This line will be skipped in stderr [your_program] [line 1] Error: Unexpected character: @ [your_program] EOF null -[your_program] This line will be skipped in stderr [stage-5] [test-1] [stderr] Skipped 1 lines that didn't start with [line N] [stage-5] [test-1]  [stage-5] [test-1] [stderr] Missing line #1 from stderr: "[line 1] Error: Unexpected character: @" diff --git a/internal/test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch index 5928ab37..59af6058 100644 --- a/internal/test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch +++ b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch @@ -4,7 +4,9 @@ [stage-5] [test-1] [test.lox] @ [stage-5] [test-1] $ ./your_program.sh tokenize test.lox [your_program] EOF null +[your_program] This line will be skipped in stderr [your_program] [line 1] eRrOr: uNeXpEcTed cHaRaCtEr: @ +[stage-5] [test-1] [stderr] Skipped 1 lines that didn't start with [line N] [stage-5] [test-1]  [stage-5] [test-1] [stderr] Mismatch on line #1 of stderr: [stage-5] [test-1] [stderr] Expected: "[line 1] Error: Unexpected character: @" diff --git a/internal/test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line index 311f3090..7528133c 100644 --- a/internal/test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line +++ b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line @@ -3,10 +3,12 @@ [stage-5] [test-1] Writing contents to ./test.lox: [stage-5] [test-1] [test.lox] @ [stage-5] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] This line will be skipped in stderr [your_program] EOF null [your_program] [line 1] Error: Unexpected character: @ [your_program] [line 666] Extra line [your_program] [line 888] Another extra line +[stage-5] [test-1] [stderr] Skipped 1 lines that didn't start with [line N] [stage-5] [test-1] ✓ [line 1] Error: Unexpected character: @ [stage-5] [test-1]  [stage-5] [test-1] 𐄂 [stderr] Extra unexpected line in stderr: "[line 666] Extra line" diff --git a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line index d113fcfb..3a0f05dc 100644 --- a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/1_missing_line @@ -3,9 +3,9 @@ [stage-11] [test-1] Writing contents to ./test.lox: [stage-11] [test-1] [test.lox] ()<|SPACE|><|TAB|>@ [stage-11] [test-1] $ ./your_program.sh tokenize test.lox +[your_program] [line 2] Error: Unexpected character: @ [your_program] LEFT_PAREN ( null [your_program] RIGHT_PAREN ) null -[your_program] [line 2] Error: Unexpected character: @ [your_program] EOF null [stage-11] [test-1] ✓ 1 line(s) match on stderr [stage-11] [test-1] ✓ 3 line(s) match on stdout diff --git a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch index f26bd973..30467026 100644 --- a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch @@ -3,10 +3,12 @@ [stage-11] [test-1] Writing contents to ./test.lox: [stage-11] [test-1] [test.lox] ()<|SPACE|><|TAB|>@ [stage-11] [test-1] $ ./your_program.sh tokenize test.lox -[your_program] [line 2] Error: Unexpected character: @ [your_program] LEFT_PAREN ( null [your_program] RIGHT_PAREN ) null +[your_program] This line will be skipped in stderr [your_program] EOF null +[your_program] [line 2] Error: Unexpected character: @ +[stage-11] [test-1] [stderr] Skipped 1 lines that didn't start with [line N] [stage-11] [test-1] ✓ 1 line(s) match on stderr [stage-11] [test-1] ✓ 3 line(s) match on stdout [stage-11] [test-1] ✓ Received exit code 65. @@ -16,8 +18,11 @@ [stage-11] [test-2] [test.lox] <|SPACE|> [stage-11] [test-2] $ ./your_program.sh tokenize test.lox [your_program] EOF null +[your_program] This line will be skipped in stderr [your_program] [line 1] Error: Unexpected character: @ +[your_program] This line will be skipped in stderr [your_program] [line 1] eRrRr: Unexpected character: # +[stage-11] [test-2] [stderr] Skipped 2 lines that didn't start with [line N] [stage-11] [test-2] ✓ [line 1] Error: Unexpected character: @ [stage-11] [test-2]  [stage-11] [test-2] [stderr] Mismatch on line #2 of stderr: diff --git a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line index 2f8a4bff..01675782 100644 --- a/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line @@ -3,10 +3,12 @@ [stage-11] [test-1] Writing contents to ./test.lox: [stage-11] [test-1] [test.lox] ()<|SPACE|><|TAB|>@ [stage-11] [test-1] $ ./your_program.sh tokenize test.lox -[your_program] [line 2] Error: Unexpected character: @ [your_program] LEFT_PAREN ( null [your_program] RIGHT_PAREN ) null +[your_program] This line will be skipped in stderr +[your_program] [line 2] Error: Unexpected character: @ [your_program] EOF null +[stage-11] [test-1] [stderr] Skipped 1 lines that didn't start with [line N] [stage-11] [test-1] ✓ 1 line(s) match on stderr [stage-11] [test-1] ✓ 3 line(s) match on stdout [stage-11] [test-1] ✓ Received exit code 65. @@ -15,10 +17,13 @@ [stage-11] [test-2] [test.lox] @# [stage-11] [test-2] [test.lox] <|SPACE|> [stage-11] [test-2] $ ./your_program.sh tokenize test.lox -[your_program] [line 1] Error: Unexpected character: @ [your_program] EOF null +[your_program] This line will be skipped in stderr +[your_program] [line 1] Error: Unexpected character: @ +[your_program] This line will be skipped in stderr [your_program] [line 1] Error: Unexpected character: # [your_program] [line 888] extra line +[stage-11] [test-2] [stderr] Skipped 2 lines that didn't start with [line N] [stage-11] [test-2] ✓ [line 1] Error: Unexpected character: @ [stage-11] [test-2] ✓ [line 1] Error: Unexpected character: # [stage-11] [test-2]  diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/scanner.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/scanner.py index 4e7e5777..c724da9d 100644 --- a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/scanner.py +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/app/scanner.py @@ -53,6 +53,8 @@ def scan_token(self): case char: self.has_errors = True + print("This line will be skipped in stderr", file=sys.stderr) + print( f"[line {self.current_line}] eRrOr: uNeXpEcTed cHaRaCtEr: {char}", file=sys.stderr, diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/scanner.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/scanner.py index 88c5af17..04a4ca7b 100644 --- a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/scanner.py +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/scanner.py @@ -53,6 +53,8 @@ def scan_token(self): case char: self.has_errors = True + print("This line will be skipped in stderr", file=sys.stderr) + print( f"[line {self.current_line}] Error: Unexpected character: {char}", file=sys.stderr, diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py index 4e028b96..d9794320 100644 --- a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py @@ -53,6 +53,9 @@ def _check_errror_exit(self): def _error_report(self, line: int, where: str, message: str): self._had_error = True + + print("This line will be skipped in stderr", file=sys.stderr) + if message.endswith("@"): output = f"[line {line}] Error{where}: {message}" print(output, file=sys.stderr) diff --git a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py index 9bf0ee06..2d2244d6 100644 --- a/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py @@ -53,6 +53,9 @@ def _check_errror_exit(self): def _error_report(self, line: int, where: str, message: str): self._had_error = True + + print("This line will be skipped in stderr", file=sys.stderr) + output = f"[line {line}] Error{where}: {message}" print(output, file=sys.stderr) if message.endswith("#"):