diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..77dcc3a --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,687 @@ +"""Integration tests: full Expr DSL with lexer + parser end-to-end. + +The Expr language supports: + - Integer literals and named variables + - Arithmetic: +, -, *, /, ** (right-associative exponentiation) + - Unary negation (- expr) + - Boolean operators: and, or, not + - Comparisons: <, >, == + - Conditional: if expr then expr else expr + - Let binding: let id = expr in expr + +Operator precedence (lowest to highest): + or (1) left + and (2) left + not (3) right (prefix) + <,>,== (4) left + +,- (5) left + *,/, (6) left + -expr (6) right (unary, via prec_token UMINUS — same as *,/ so -3*2=(-3)*2) + ** (7) right (above UMINUS, so -2**2 = -(2**2) = -4) + +These tests exercise: precedence resolution, associativity, right-associativity, +operator precedence override (prec_token), lexer state, keyword dispatch, +and error reporting. +""" + +from __future__ import annotations + +import pytest + +from plare.exception import LexingError, ParsingError +from plare.lexer import Lexer +from plare.parser import Parser +from plare.token import Token + +# --------------------------------------------------------------------------- +# Token definitions +# --------------------------------------------------------------------------- + + +class NUM(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = int(value) + + +class ID(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.name = value + + +# Arithmetic operators +class PLUS(Token): + precedence = 5 + associative = "left" + + +class MINUS(Token): + precedence = 5 + associative = "left" + + +class STAR(Token): + precedence = 6 + associative = "left" + + +class SLASH(Token): + precedence = 6 + associative = "left" + + +class POW(Token): + precedence = 7 + associative = "right" + + +# Phantom token used only as a prec_token for unary minus (precedence=6). +# Being equal to STAR/SLASH (also 6, left-assoc) means -3*2 reduces the unary +# minus first (equal-prec + left-assoc → reduce). Being below POW (7) means +# -2**2 shifts ** first, giving -(2**2) = -4. +class UMINUS(Token): + precedence = 6 + associative = "right" + + +# Comparison operators +class LT(Token): + precedence = 4 + associative = "left" + + +class GT(Token): + precedence = 4 + associative = "left" + + +class EQEQ(Token): + precedence = 4 + associative = "left" + + +# Boolean operators +class OR(Token): + precedence = 1 + associative = "left" + + +class AND(Token): + precedence = 2 + associative = "left" + + +class NOT(Token): + precedence = 3 + associative = "right" + + +# Delimiters / keywords (all default precedence 0) +class LPAREN(Token): + pass + + +class RPAREN(Token): + pass + + +class EQ(Token): + pass # assignment = in let binding + + +class LET(Token): + pass + + +class IN(Token): + pass + + +class IF(Token): + pass + + +class THEN(Token): + pass + + +class ELSE(Token): + pass + + +# --------------------------------------------------------------------------- +# AST nodes +# --------------------------------------------------------------------------- + + +class Expr: + pass + + +class Num(Expr): + def __init__(self, tok: NUM) -> None: + self.value: int = tok.value + + +class Var(Expr): + def __init__(self, tok: ID) -> None: + self.name: str = tok.name + + +class Add(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class Sub(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class Mul(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class Div(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class Pow(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class Neg(Expr): + def __init__(self, operand: Expr) -> None: + self.operand = operand + + +class CmpLt(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class CmpGt(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class CmpEq(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class BoolAnd(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class BoolOr(Expr): + def __init__(self, l: Expr, r: Expr) -> None: + self.l, self.r = l, r + + +class BoolNot(Expr): + def __init__(self, operand: Expr) -> None: + self.operand = operand + + +class IfExpr(Expr): + def __init__(self, cond: Expr, then_: Expr, else_: Expr) -> None: + self.cond = cond + self.then_ = then_ + self.else_ = else_ + + +class LetExpr(Expr): + def __init__(self, name_tok: ID, defn: Expr, body: Expr) -> None: + self.name = name_tok.name + self.defn = defn + self.body = body + + +# --------------------------------------------------------------------------- +# Evaluator — all values are integers (booleans represented as 0 / 1) +# --------------------------------------------------------------------------- + + +def eval_expr(node: Expr, env: dict[str, int] | None = None) -> int: + if env is None: + env = {} + match node: + case Num(): + return node.value + case Var(): + return env[node.name] + case Add(): + return eval_expr(node.l, env) + eval_expr(node.r, env) + case Sub(): + return eval_expr(node.l, env) - eval_expr(node.r, env) + case Mul(): + return eval_expr(node.l, env) * eval_expr(node.r, env) + case Div(): + return eval_expr(node.l, env) // eval_expr(node.r, env) + case Pow(): + return eval_expr(node.l, env) ** eval_expr(node.r, env) + case Neg(): + return -eval_expr(node.operand, env) + case CmpLt(): + return int(eval_expr(node.l, env) < eval_expr(node.r, env)) + case CmpGt(): + return int(eval_expr(node.l, env) > eval_expr(node.r, env)) + case CmpEq(): + return int(eval_expr(node.l, env) == eval_expr(node.r, env)) + case BoolAnd(): + return int(bool(eval_expr(node.l, env)) and bool(eval_expr(node.r, env))) + case BoolOr(): + return int(bool(eval_expr(node.l, env)) or bool(eval_expr(node.r, env))) + case BoolNot(): + return int(not eval_expr(node.operand, env)) + case IfExpr(): + if eval_expr(node.cond, env): + return eval_expr(node.then_, env) + return eval_expr(node.else_, env) + case LetExpr(): + val = eval_expr(node.defn, env) + return eval_expr(node.body, {**env, node.name: val}) + case _: + raise ValueError(f"Unhandled node type: {type(node)}") + + +# --------------------------------------------------------------------------- +# Lexer +# --------------------------------------------------------------------------- + +KEYWORDS: dict[str, type[Token]] = { + "let": LET, + "in": IN, + "if": IF, + "then": THEN, + "else": ELSE, + "and": AND, + "or": OR, + "not": NOT, +} + + +def lex_word(matched: str, state: None, lineno: int, offset: int) -> Token: + cls = KEYWORDS.get(matched, ID) + return cls(matched, lineno=lineno, offset=offset) + + +expr_lexer: Lexer[None] = Lexer( + { + "start": [ + (r"[ \t\n]+", "start"), + (r"\d+", NUM), + (r"\*\*", POW), # ** before * + (r"\*", STAR), + (r"==", EQEQ), # == before = + (r"=", EQ), + (r"\+", PLUS), + (r"-", MINUS), + (r"/", SLASH), + (r"<", LT), + (r">", GT), + (r"\(", LPAREN), + (r"\)", RPAREN), + (r"[a-zA-Z_]\w*", lex_word), + ] + } +) + +# --------------------------------------------------------------------------- +# Parser +# --------------------------------------------------------------------------- + +expr_parser: Parser[Expr] = Parser( + { + "expr": [ + # Atoms + ([NUM], Num, [0]), + ([ID], Var, [0]), + ([LPAREN, "expr", RPAREN], None, [1]), + # Arithmetic (binary) + (["expr", PLUS, "expr"], Add, [0, 2]), + (["expr", MINUS, "expr"], Sub, [0, 2]), + (["expr", STAR, "expr"], Mul, [0, 2]), + (["expr", SLASH, "expr"], Div, [0, 2]), + (["expr", POW, "expr"], Pow, [0, 2]), + # Unary negation — UMINUS as prec_token gives it precedence 6, + # which is below POW=7, so -2**2 shifts ** first → -(2**2)=-4. + ([MINUS, "expr"], Neg, [1], UMINUS), + # Boolean + (["expr", AND, "expr"], BoolAnd, [0, 2]), + (["expr", OR, "expr"], BoolOr, [0, 2]), + ([NOT, "expr"], BoolNot, [1]), + # Comparisons + (["expr", LT, "expr"], CmpLt, [0, 2]), + (["expr", GT, "expr"], CmpGt, [0, 2]), + (["expr", EQEQ, "expr"], CmpEq, [0, 2]), + # Control flow (precedence 0 — body extends as far right as possible) + ([IF, "expr", THEN, "expr", ELSE, "expr"], IfExpr, [1, 3, 5]), + ([LET, ID, EQ, "expr", IN, "expr"], LetExpr, [1, 3, 5]), + ] + } +) + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def parse_eval(src: str, env: dict[str, int] | None = None) -> int: + result = expr_parser.parse("expr", expr_lexer.lex("start", src)) + assert isinstance(result, Expr) + return eval_expr(result, env) + + +# --------------------------------------------------------------------------- +# Section 1: Arithmetic — precedence and associativity +# --------------------------------------------------------------------------- + + +def test_literal() -> None: + """A bare integer literal evaluates to its value.""" + assert parse_eval("42") == 42 + + +def test_addition() -> None: + """Simple addition.""" + assert parse_eval("3 + 4") == 7 + + +def test_multiplication_precedence_over_addition() -> None: + """1 + 2 * 3 == 7: multiplication binds tighter than addition.""" + assert parse_eval("1 + 2 * 3") == 7 + + +def test_left_associativity_subtraction() -> None: + """10 - 3 - 2 == 5: subtraction is left-associative → (10-3)-2.""" + assert parse_eval("10 - 3 - 2") == 5 + + +def test_left_associativity_division() -> None: + """12 / 4 / 3 == 1: division is left-associative → (12/4)/3.""" + assert parse_eval("12 / 4 / 3") == 1 + + +def test_right_associativity_power() -> None: + """2 ** 3 ** 2 == 512: exponentiation is right-associative → 2**(3**2)=2**9.""" + assert parse_eval("2 ** 3 ** 2") == 512 + + +def test_power_not_right_assoc_without_nesting() -> None: + """2 ** 3 == 8: single exponentiation still works.""" + assert parse_eval("2 ** 3") == 8 + + +def test_parentheses_override_precedence() -> None: + """(1 + 2) * 3 == 9: parentheses override the default * > + priority.""" + assert parse_eval("(1 + 2) * 3") == 9 + + +def test_mixed_precedence_levels() -> None: + """1 + 2 * 3 - 4 / 2 == 5: several levels in one expression.""" + assert parse_eval("1 + 2 * 3 - 4 / 2") == 5 + + +# --------------------------------------------------------------------------- +# Section 2: Unary negation +# --------------------------------------------------------------------------- + + +def test_unary_negation_simple() -> None: + """-5 evaluates to -5.""" + assert parse_eval("-5") == -5 + + +def test_unary_negation_with_addition() -> None: + """-5 + 3 == -2: unary minus is tighter than addition.""" + assert parse_eval("-5 + 3") == -2 + + +def test_unary_negation_lower_than_power() -> None: + """-2 ** 2 == -4: power binds tighter than unary minus → -(2**2).""" + assert parse_eval("-2 ** 2") == -4 + + +def test_unary_negation_higher_than_mul() -> None: + """-3 * 2 == -6: unary minus binds tighter than multiplication → (-3)*2.""" + assert parse_eval("-3 * 2") == -6 + + +def test_double_unary_negation() -> None: + """--5 == 5: two consecutive unary minuses cancel.""" + assert parse_eval("--5") == 5 + + +# --------------------------------------------------------------------------- +# Section 3: Comparisons +# --------------------------------------------------------------------------- + + +def test_comparison_lt_true() -> None: + """1 < 2 evaluates to 1 (true).""" + assert parse_eval("1 < 2") == 1 + + +def test_comparison_lt_false() -> None: + """2 < 1 evaluates to 0 (false).""" + assert parse_eval("2 < 1") == 0 + + +def test_comparison_gt() -> None: + """3 > 2 evaluates to 1.""" + assert parse_eval("3 > 2") == 1 + + +def test_comparison_eq_true() -> None: + """4 == 4 evaluates to 1.""" + assert parse_eval("4 == 4") == 1 + + +def test_comparison_eq_false() -> None: + """4 == 5 evaluates to 0.""" + assert parse_eval("4 == 5") == 0 + + +def test_comparison_binds_looser_than_arithmetic() -> None: + """1 + 1 < 3 == 1: arithmetic is evaluated before comparison.""" + assert parse_eval("1 + 1 < 3") == 1 + + +# --------------------------------------------------------------------------- +# Section 4: Boolean operators +# --------------------------------------------------------------------------- + + +def test_bool_and_truthy() -> None: + """1 and 1 evaluates to 1.""" + assert parse_eval("1 and 1") == 1 + + +def test_bool_and_falsy() -> None: + """1 and 0 evaluates to 0.""" + assert parse_eval("1 and 0") == 0 + + +def test_bool_or_truthy() -> None: + """0 or 1 evaluates to 1.""" + assert parse_eval("0 or 1") == 1 + + +def test_bool_or_both_falsy() -> None: + """0 or 0 evaluates to 0.""" + assert parse_eval("0 or 0") == 0 + + +def test_bool_not_true() -> None: + """not 1 evaluates to 0.""" + assert parse_eval("not 1") == 0 + + +def test_bool_not_false() -> None: + """not 0 evaluates to 1.""" + assert parse_eval("not 0") == 1 + + +def test_not_lower_than_comparison() -> None: + """not 1 < 2 == 0: comparison binds tighter than not → not(1<2)=not(1)=0.""" + assert parse_eval("not 1 < 2") == 0 + + +def test_not_higher_than_and() -> None: + """not 0 and 0 == 0: not binds tighter than and → (not 0) and 0 = 1 and 0 = 0.""" + assert parse_eval("not 0 and 0") == 0 + + +def test_not_higher_than_or() -> None: + """not 0 or 0 == 1: not binds tighter than or → (not 0) or 0 = 1 or 0 = 1.""" + assert parse_eval("not 0 or 0") == 1 + + +def test_and_higher_than_or() -> None: + """1 or 0 and 0 == 1: and binds tighter than or → 1 or (0 and 0) = 1 or 0 = 1.""" + assert parse_eval("1 or 0 and 0") == 1 + + +def test_combined_boolean_and_comparison() -> None: + """(1 < 2) and (3 > 2) == 1: both comparisons true.""" + assert parse_eval("1 < 2 and 3 > 2") == 1 + + +# --------------------------------------------------------------------------- +# Section 5: Let bindings +# --------------------------------------------------------------------------- + + +def test_let_simple() -> None: + """let x = 5 in x + 1 evaluates to 6.""" + assert parse_eval("let x = 5 in x + 1") == 6 + + +def test_let_body_extends_right() -> None: + """let x = 3 in x * 2 + 1 == 7: body grabs entire right-hand expression.""" + assert parse_eval("let x = 3 in x * 2 + 1") == 7 + + +def test_let_shadows_outer() -> None: + """Nested let shadows the outer binding: let x=5 in let x=3 in x == 3.""" + assert parse_eval("let x = 5 in let x = 3 in x") == 3 + + +def test_let_uses_outer_in_defn() -> None: + """Inner let can reference outer binding in its definition.""" + assert parse_eval("let x = 4 in let y = x * 2 in y + 1") == 9 + + +def test_let_with_power() -> None: + """let x = 2 in x ** 3 + 1 == 9: correct precedence inside let body.""" + assert parse_eval("let x = 2 in x ** 3 + 1") == 9 + + +# --------------------------------------------------------------------------- +# Section 6: Conditional expressions +# --------------------------------------------------------------------------- + + +def test_if_true_branch() -> None: + """if 1 then 10 else 20 evaluates to 10 (condition is truthy).""" + assert parse_eval("if 1 then 10 else 20") == 10 + + +def test_if_false_branch() -> None: + """if 0 then 10 else 20 evaluates to 20 (condition is falsy).""" + assert parse_eval("if 0 then 10 else 20") == 20 + + +def test_if_with_comparison_condition() -> None: + """if 3 > 2 then 10 else 20 == 10: condition is a comparison expression.""" + assert parse_eval("if 3 > 2 then 10 else 20") == 10 + + +def test_if_else_body_extends_right() -> None: + """else branch grabs the full expression: if 1 then 1 else 2 + 3 == 1.""" + assert parse_eval("if 1 then 1 else 2 + 3") == 1 + + +def test_if_else_extends_into_arithmetic() -> None: + """if 0 then 1 else 2 + 3 == 5: else body is 2+3, not just 2.""" + assert parse_eval("if 0 then 1 else 2 + 3") == 5 + + +def test_if_nested_in_let() -> None: + """let x=3 in if x > 2 then x * 2 else x + 1 == 6.""" + assert parse_eval("let x = 3 in if x > 2 then x * 2 else x + 1") == 6 + + +# --------------------------------------------------------------------------- +# Section 7: Complex / combined expressions +# --------------------------------------------------------------------------- + + +def test_complex_arithmetic() -> None: + """2 ** 10 == 1024.""" + assert parse_eval("2 ** 10") == 1024 + + +def test_complex_nested_let() -> None: + """let a=2 in let b=3 in a**b + b**a == 8+9 == 17.""" + assert parse_eval("let a = 2 in let b = 3 in a ** b + b ** a") == 17 + + +def test_complex_boolean_expression() -> None: + """(1 < 2) and not (3 == 4) == 1: both sub-expressions are true.""" + assert parse_eval("1 < 2 and not 3 == 4") == 1 + + +def test_variable_in_comparison() -> None: + """Variables work inside comparisons inside let.""" + assert parse_eval("let n = 5 in n > 3") == 1 + + +# --------------------------------------------------------------------------- +# Section 8: Error cases +# --------------------------------------------------------------------------- + + +def test_lexing_error_on_unknown_char() -> None: + """An unrecognised character raises LexingError.""" + with pytest.raises(LexingError): + parse_eval("1 + @") + + +def test_parsing_error_on_double_operator() -> None: + """Two consecutive operators raise ParsingError.""" + with pytest.raises(ParsingError): + parse_eval("1 + * 2") + + +def test_parsing_error_on_unclosed_paren() -> None: + """An unclosed parenthesis raises ParsingError.""" + with pytest.raises(ParsingError): + parse_eval("(1 + 2") + + +def test_parsing_error_on_empty_input() -> None: + """An empty token stream (no expression) raises ParsingError.""" + with pytest.raises(ParsingError): + parse_eval("") + + +def test_lexing_error_lineno() -> None: + """LexingError on the second line carries lineno=2.""" + with pytest.raises(LexingError) as exc_info: + parse_eval("1 + 2\n@ + 3") + assert exc_info.value.lineno == 2 + + +def test_parsing_error_contains_expected_tokens() -> None: + """ParsingError exposes the set of expected token types.""" + with pytest.raises(ParsingError) as exc_info: + parse_eval("1 +") + assert len(exc_info.value.expected) > 0 diff --git a/tests/test_lexer_advanced.py b/tests/test_lexer_advanced.py new file mode 100644 index 0000000..d51e6f9 --- /dev/null +++ b/tests/test_lexer_advanced.py @@ -0,0 +1,318 @@ +"""Advanced lexer tests covering scenarios not in test_lexer.py. + +Covers: multiline position tracking, column accuracy after whitespace, +block-comment state machine, string-literal state machine, user_state threading, +error position inside a non-start state, and various edge cases. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import NoneType + +import pytest + +from plare.exception import LexingError +from plare.lexer import Lexer +from plare.token import Token + +# --------------------------------------------------------------------------- +# Shared token classes +# --------------------------------------------------------------------------- + + +class WORD(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = value + + +class NUM_A(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = int(value) + + +# Lexer that recognises lowercase words; skips newlines and horizontal whitespace. +word_lexer: Lexer[None] = Lexer( + { + "start": [ + (r"\n", "start"), + (r"[ \t]+", "start"), + (r"[a-z]+", WORD), + ] + } +) + +# --------------------------------------------------------------------------- +# Section 1: Multiline lineno tracking +# --------------------------------------------------------------------------- + + +def test_multiline_lineno() -> None: + """Three words on separate lines each have lineno 1, 2, 3.""" + tokens = list(word_lexer.lex("start", "aa\nbb\ncc")) + assert len(tokens) == 3 + assert tokens[0].lineno == 1 + assert tokens[1].lineno == 2 + assert tokens[2].lineno == 3 + + +def test_multiline_lineno_blank_lines() -> None: + """Blank lines between tokens still increment lineno correctly.""" + tokens = list(word_lexer.lex("start", "aa\n\nbb")) + assert len(tokens) == 2 + assert tokens[0].lineno == 1 + assert tokens[1].lineno == 3 + + +def test_second_line_offset_zero() -> None: + """First token on a new line has offset=0 after the newline resets the column.""" + tokens = list(word_lexer.lex("start", "ab\ncd")) + assert len(tokens) == 2 + assert tokens[1].lineno == 2 + assert tokens[1].offset == 0 + + +def test_mid_line_offset() -> None: + """A token after leading spaces on the same line has the correct column offset.""" + tokens = list(word_lexer.lex("start", "ab cd")) + assert len(tokens) == 2 + assert tokens[0].offset == 0 + assert tokens[1].offset == 4 + + +def test_second_line_mid_offset() -> None: + """A token after leading spaces on line 2 has the right column offset.""" + tokens = list(word_lexer.lex("start", "ab\n cd")) + assert len(tokens) == 2 + assert tokens[1].lineno == 2 + assert tokens[1].offset == 3 + + +def test_tab_counts_as_one_column() -> None: + """A tab character advances the column offset by exactly one.""" + tokens = list(word_lexer.lex("start", "\tab")) + assert len(tokens) == 1 + assert tokens[0].offset == 1 + + +# --------------------------------------------------------------------------- +# Section 2: Block-comment state machine +# --------------------------------------------------------------------------- + +# Lexer with C-style /* ... */ block comments: +# start → on "/*" → comment state +# comment → on "*/" → back to start +block_comment_lexer: Lexer[None] = Lexer( + { + "start": [ + (r"/\*", "comment"), + (r"[ \t\n]+", "start"), + (r"\d+", NUM_A), + ], + "comment": [ + (r"\*/", "start"), + (r"[^*]+", "comment"), + (r"\*(?!/)", "comment"), + ], + } +) + + +def test_block_comment_ignored() -> None: + """/* ... */ is consumed without emitting any token.""" + tokens = list(block_comment_lexer.lex("start", "/* hello */ 42")) + assert len(tokens) == 1 + assert isinstance(tokens[0], NUM_A) + assert tokens[0].value == 42 + + +def test_block_comment_between_tokens() -> None: + """Tokens before and after a block comment are both captured.""" + tokens = list(block_comment_lexer.lex("start", "1 /* skip */ 2")) + assert len(tokens) == 2 + assert isinstance(tokens[0], NUM_A) and tokens[0].value == 1 + assert isinstance(tokens[1], NUM_A) and tokens[1].value == 2 + + +def test_block_comment_multiline_advances_lineno() -> None: + """A comment spanning two lines gives the token after it lineno=2.""" + tokens = list(block_comment_lexer.lex("start", "/* line1\nline2 */ 99")) + assert len(tokens) == 1 + assert isinstance(tokens[0], NUM_A) + assert tokens[0].lineno == 2 + + +def test_block_comment_unclosed_silently_ends() -> None: + """An unclosed /* reaches EOF without raising — the lexer exits regardless of state. + + The Lexer has no concept of a "required end state", so EOF in the middle of + a comment simply terminates tokenisation. Only the token before the comment + is emitted. + """ + tokens = list(block_comment_lexer.lex("start", "1 /* unclosed")) + assert len(tokens) == 1 + assert isinstance(tokens[0], NUM_A) and tokens[0].value == 1 + + +def test_block_comment_adjacent_to_token() -> None: + """A comment immediately adjacent (no space) to a number is handled correctly.""" + tokens = list(block_comment_lexer.lex("start", "/*x*/7")) + assert len(tokens) == 1 + assert isinstance(tokens[0], NUM_A) and tokens[0].value == 7 + + +# --------------------------------------------------------------------------- +# Section 3: String-literal state machine +# --------------------------------------------------------------------------- + + +class STR(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.content = value + + +def make_str(matched: str, state: None, lineno: int, offset: int) -> STR: + return STR(matched[1:-1], lineno=lineno, offset=offset) + + +str_lexer: Lexer[None] = Lexer( + { + "start": [ + (r"[ \t]+", "start"), + (r'"[^"]*"', make_str), + ] + } +) + + +def test_string_literal_single() -> None: + """A double-quoted string produces a single STR token with inner content.""" + tokens = list(str_lexer.lex("start", '"hello world"')) + assert len(tokens) == 1 + assert isinstance(tokens[0], STR) + assert tokens[0].content == "hello world" + + +def test_string_literal_multiple() -> None: + """Two adjacent strings produce two separate STR tokens.""" + tokens = list(str_lexer.lex("start", '"foo" "bar"')) + assert len(tokens) == 2 + assert isinstance(tokens[0], STR) and tokens[0].content == "foo" + assert isinstance(tokens[1], STR) and tokens[1].content == "bar" + + +def test_string_literal_offset() -> None: + """The STR token's offset points to the opening quote character.""" + tokens = list(str_lexer.lex("start", ' "hi"')) + assert len(tokens) == 1 + assert tokens[0].offset == 3 + + +def test_string_literal_empty() -> None: + """An empty string literal produces a STR token with content == ''.""" + tokens = list(str_lexer.lex("start", '""')) + assert len(tokens) == 1 + assert isinstance(tokens[0], STR) + assert tokens[0].content == "" + + +# --------------------------------------------------------------------------- +# Section 4: user_state threading +# --------------------------------------------------------------------------- + + +@dataclass +class Counter: + count: int = 0 + + +class TOK_US(Token): + pass + + +def test_user_state_is_threaded() -> None: + """A callable handler receives and mutates the user state across all calls.""" + counter = Counter() + + def handler(matched: str, state: Counter, lineno: int, offset: int) -> TOK_US: + state.count += 1 + return TOK_US(matched, lineno=lineno, offset=offset) + + lexer: Lexer[Counter] = Lexer( + {"start": [(r"\w+", handler), (r" +", "start")]}, + state_factory=lambda: counter, + ) + tokens = list(lexer.lex("start", "a b c")) + assert len(tokens) == 3 + assert counter.count == 3 + + +def test_user_state_factory_called_per_lex() -> None: + """state_factory is called once per lex() invocation, not per token.""" + call_count = 0 + + def factory() -> None: + nonlocal call_count + call_count += 1 + + lexer: Lexer[None] = Lexer( + {"start": [(r"\w+", WORD), (r" +", "start")]}, + state_factory=factory, + ) + list(lexer.lex("start", "a b")) + list(lexer.lex("start", "c d")) + assert call_count == 2 + + +# --------------------------------------------------------------------------- +# Section 5: Edge cases +# --------------------------------------------------------------------------- + + +def test_empty_input_produces_no_tokens() -> None: + """An empty string yields no tokens.""" + lexer: Lexer[None] = Lexer({"start": [(r"\d+", NUM_A)]}) + tokens = list(lexer.lex("start", "")) + assert tokens == [] + + +def test_single_char_token_position() -> None: + """A single-character token has lineno=1 and offset=0.""" + + class PLUS_EC(Token): + pass + + lexer: Lexer[NoneType] = Lexer({"start": [(r"\+", PLUS_EC)]}, NoneType) + tokens = list(lexer.lex("start", "+")) + assert len(tokens) == 1 + assert tokens[0].lineno == 1 + assert tokens[0].offset == 0 + + +def test_lex_error_reports_correct_position() -> None: + """LexingError carries the correct lineno and offset of the unrecognised character.""" + with pytest.raises(LexingError) as exc_info: + list(word_lexer.lex("start", "aa\nbb\n@")) + err = exc_info.value + assert err.lineno == 3 + assert err.offset == 0 + + +def test_lex_error_mid_line_offset() -> None: + """LexingError offset matches the column of the bad character within its line.""" + with pytest.raises(LexingError) as exc_info: + list(word_lexer.lex("start", "abc @")) + err = exc_info.value + assert err.lineno == 1 + assert err.offset == 4 + + +def test_multiple_tokens_same_line() -> None: + """Several tokens on one line have monotonically increasing offsets.""" + tokens = list(word_lexer.lex("start", "ab cd ef")) + assert len(tokens) == 3 + assert tokens[0].offset < tokens[1].offset < tokens[2].offset + assert tokens[0].lineno == tokens[1].lineno == tokens[2].lineno == 1