diff --git a/internal/assertions/stderr_assertion.go b/internal/assertions/stderr_assertion.go index 61ca7f2c..0e163c56 100644 --- a/internal/assertions/stderr_assertion.go +++ b/internal/assertions/stderr_assertion.go @@ -22,19 +22,29 @@ 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) - logger.Errorf("? %s", expectedLine) - 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) + + 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)) } @@ -42,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 @@ -67,6 +78,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_lexical_errors/1_missing_line b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/1_missing_line new file mode 100644 index 00000000..5e0bbb58 --- /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] This line will be skipped in stderr +[your_program] [line 1] Error: Unexpected character: @ +[your_program] EOF null +[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..59af6058 --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/2_mismatch @@ -0,0 +1,15 @@ +[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] 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: @" +[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..7528133c --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_lexical_errors/3_extra_line @@ -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] 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" +[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_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..30467026 --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/2_mismatch @@ -0,0 +1,32 @@ +[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] 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. +[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] [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: +[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..01675782 --- /dev/null +++ b/internal/test_helpers/fixtures/fail_scanning_multiline_errors/3_extra_line @@ -0,0 +1,32 @@ +[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] 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. +[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] 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]  +[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) diff --git a/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/main.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/main.py new file mode 100644 index 00000000..3a19e88a --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_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/1_missing_line/app/scanner.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/scanner.py new file mode 100644 index 00000000..868c8876 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_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("This line will be skipped in stderr", file=sys.stderr) + + print( + f"[line {self.current_line}] Error: Unexpected character: {char}", + file=sys.stdout, + ) + + 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/1_missing_line/app/token.py b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/app/token.py new file mode 100644 index 00000000..78182b73 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_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/1_missing_line/codecrafters.yml b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/codecrafters.yml new file mode 100644 index 00000000..ec2956e1 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_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_lexical_errors/1_missing_line/your_program.sh b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_line/your_program.sh new file mode 100755 index 00000000..e6c2eb78 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/1_missing_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 "$@" 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..c724da9d --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/2_mismatch/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("This line will be skipped in stderr", file=sys.stderr) + + 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..04a4ca7b --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_lexical_errors/3_extra_line/app/scanner.py @@ -0,0 +1,80 @@ +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("This line will be skipped in stderr", file=sys.stderr) + + 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 "$@" 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..d9794320 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/2_mismatch/lox/lox.py @@ -0,0 +1,70 @@ +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("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..2d2244d6 --- /dev/null +++ b/internal/test_helpers/scenarios/fail_scanning_multiline_errors/3_extra_line/lox/lox.py @@ -0,0 +1,68 @@ +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("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