From 97f5110d44de360b54d80c9fef0f92d8a3d4e763 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:22:44 +0000 Subject: [PATCH 01/17] test: arithmetic calculator with value evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6가지 테스트: 덧셈, 곱셈 우선순위(STAR>PLUS), 좌결합 뺄셈 체인, 정수 나눗셈, 괄호 우선순위 오버라이드, 동일 우선순위 혼합 체인. eval_c()로 실제 정수 값을 계산하여 검증. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 228 ++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 tests/test_grammar_logic.py diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py new file mode 100644 index 0000000..e8a31c9 --- /dev/null +++ b/tests/test_grammar_logic.py @@ -0,0 +1,228 @@ +"""Comprehensive grammar and logic tests for the Plare lexer/parser framework. + +Covers: arithmetic calculator with value evaluation, default S/R associativity, +dangling else (xfail), three-level precedence with %prec, comma-separated list +grammar, function call grammar, deep nesting, lexer multiline tracking, error +cases, and top-level epsilon productions. +""" + +from __future__ import annotations + +import pytest + +from plare.exception import ParsingError +from plare.lexer import Lexer +from plare.parser import Parser +from plare.token import Token + +# --------------------------------------------------------------------------- +# Section 1: Arithmetic Calculator +# --------------------------------------------------------------------------- + + +class NUM_C(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = int(value) + + +class PLUS_C(Token): + precedence = 1 + associative = "left" + + +class MINUS_C(Token): + precedence = 1 + associative = "left" + + +class STAR_C(Token): + precedence = 2 + associative = "left" + + +class SLASH_C(Token): + precedence = 2 + associative = "left" + + +class LPAREN_C(Token): + pass + + +class RPAREN_C(Token): + pass + + +class ExprC: + pass + + +class NumC(ExprC): + def __init__(self, tok: NUM_C) -> None: + self.value = tok.value + + +class AddC(ExprC): + def __init__(self, l: ExprC, r: ExprC) -> None: + self.l = l + self.r = r + + +class SubC(ExprC): + def __init__(self, l: ExprC, r: ExprC) -> None: + self.l = l + self.r = r + + +class MulC(ExprC): + def __init__(self, l: ExprC, r: ExprC) -> None: + self.l = l + self.r = r + + +class DivC(ExprC): + def __init__(self, l: ExprC, r: ExprC) -> None: + self.l = l + self.r = r + + +def eval_c(node: ExprC) -> int: + match node: + case NumC(): + return node.value + case AddC(): + return eval_c(node.l) + eval_c(node.r) + case SubC(): + return eval_c(node.l) - eval_c(node.r) + case MulC(): + return eval_c(node.l) * eval_c(node.r) + case DivC(): + return eval_c(node.l) // eval_c(node.r) + case _: + raise ValueError(f"Unknown node: {node}") + + +CALC_GRAMMAR: dict = { + "expr": [ + ([NUM_C], NumC, [0]), + (["expr", PLUS_C, "expr"], AddC, [0, 2]), + (["expr", MINUS_C, "expr"], SubC, [0, 2]), + (["expr", STAR_C, "expr"], MulC, [0, 2]), + (["expr", SLASH_C, "expr"], DivC, [0, 2]), + ([LPAREN_C, "expr", RPAREN_C], None, [1]), + ] +} + +CALC_LEXER = Lexer( + { + "start": [ + (r"[ \t\n]+", "start"), + (r"\d+", NUM_C), + (r"\+", PLUS_C), + (r"-", MINUS_C), + (r"\*", STAR_C), + (r"/", SLASH_C), + (r"\(", LPAREN_C), + (r"\)", RPAREN_C), + ] + } +) + +_calc_parser = Parser(CALC_GRAMMAR) + + +def _num(v: int, o: int = 0) -> NUM_C: + return NUM_C(str(v), lineno=1, offset=o) + + +def test_calc_single_addition() -> None: + """1 + 2 evaluates to 3 and produces an AddC node.""" + result = _calc_parser.parse( + "expr", + [_num(1, 0), PLUS_C("+", lineno=1, offset=1), _num(2, 2)], + ) + assert isinstance(result, AddC) + assert isinstance(result.l, NumC) and result.l.value == 1 + assert isinstance(result.r, NumC) and result.r.value == 2 + assert eval_c(result) == 3 + + +def test_calc_precedence_mul_over_add() -> None: + """1 + 2 * 3: STAR (prec=2) beats PLUS (prec=1), so root is AddC, right child is MulC.""" + result = _calc_parser.parse( + "expr", + [ + _num(1, 0), + PLUS_C("+", lineno=1, offset=1), + _num(2, 2), + STAR_C("*", lineno=1, offset=3), + _num(3, 4), + ], + ) + assert isinstance(result, AddC), "'+' must be root because '*' binds tighter" + assert isinstance(result.r, MulC) + assert eval_c(result) == 7 + + +def test_calc_left_assoc_subtraction_chain() -> None: + """10 - 3 - 2: left-associativity produces SubC(SubC(10, 3), 2) = 5, not 9.""" + result = _calc_parser.parse( + "expr", + [ + _num(10, 0), + MINUS_C("-", lineno=1, offset=2), + _num(3, 3), + MINUS_C("-", lineno=1, offset=4), + _num(2, 5), + ], + ) + assert isinstance(result, SubC) + assert isinstance(result.l, SubC), "left-assoc: outer sub must have inner sub as left child" + assert eval_c(result) == 5 + + +def test_calc_integer_division() -> None: + """7 / 2 produces DivC and floor-divides to 3.""" + result = _calc_parser.parse( + "expr", + [_num(7, 0), SLASH_C("/", lineno=1, offset=1), _num(2, 2)], + ) + assert isinstance(result, DivC) + assert eval_c(result) == 3 + + +def test_calc_parentheses_override_precedence() -> None: + """2 * (3 + 4): parentheses force add before multiply despite STAR having higher prec.""" + result = _calc_parser.parse( + "expr", + [ + _num(2, 0), + STAR_C("*", lineno=1, offset=1), + LPAREN_C("(", lineno=1, offset=2), + _num(3, 3), + PLUS_C("+", lineno=1, offset=4), + _num(4, 5), + RPAREN_C(")", lineno=1, offset=6), + ], + ) + assert isinstance(result, MulC), "root must be Mul" + assert isinstance(result.r, AddC), "right child must be Add (grouped by parens)" + assert eval_c(result) == 14 + + +def test_calc_mixed_left_assoc_same_precedence() -> None: + """1 - 2 + 3: MINUS and PLUS share prec=1 with left-assoc; result is (1-2)+3 = 2.""" + result = _calc_parser.parse( + "expr", + [ + _num(1, 0), + MINUS_C("-", lineno=1, offset=1), + _num(2, 2), + PLUS_C("+", lineno=1, offset=3), + _num(3, 4), + ], + ) + assert isinstance(result, AddC), "outer op must be Add" + assert isinstance(result.l, SubC), "left child must be Sub (left-assoc)" + assert eval_c(result) == 2 From d02a0954b78ed134eb0d8bf9689b3422d06001fe Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:23:09 +0000 Subject: [PATCH 02/17] test: default S/R conflict is left-associative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit precedence=0 토큰들이 S/R 충돌 시 symbol.associative=="left" (기본값) 조건이 True가 되어 reduce 우선 → 좌결합 트리 생성. 기존 test_shift_reduce_resolution_prefers_shift는 체인 케이스 없음. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index e8a31c9..7927bfb 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -226,3 +226,65 @@ def test_calc_mixed_left_assoc_same_precedence() -> None: assert isinstance(result, AddC), "outer op must be Add" assert isinstance(result.l, SubC), "left child must be Sub (left-assoc)" assert eval_c(result) == 2 + + +# --------------------------------------------------------------------------- +# Section 2: Default S/R conflict is left-associative +# --------------------------------------------------------------------------- + + +class NUM_SR(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = int(value) + + +class PLUS_SR(Token): + pass # no precedence set → default 0, associative="left" + + +class NumSR: + def __init__(self, tok: NUM_SR) -> None: + self.value = tok.value + + +class AddSR: + def __init__(self, l: object, r: object) -> None: + self.l = l + self.r = r + + +def test_default_sr_conflict_is_left_associative() -> None: + """With no explicit precedence, S/R conflict resolves to reduce (left-associative). + + When both item.precedence and symbol.precedence are 0, the parser condition: + item.precedence == symbol.precedence and symbol.associative == "left" + is True (Token default associative="left"), so reduce wins. + + This means 1 + 2 + 3 produces a LEFT-leaning tree AddSR(AddSR(1,2), 3), + not a right-leaning one. The existing test_shift_reduce_resolution_prefers_shift + only tests the two-token case and makes no tree-shape assertion. + """ + p = Parser( + { + "expr": [ + (["expr", PLUS_SR, "expr"], AddSR, [0, 2]), + ([NUM_SR], NumSR, [0]), + ] + } + ) + result = p.parse( + "expr", + [ + NUM_SR("1", lineno=1, offset=0), + PLUS_SR("+", lineno=1, offset=1), + NUM_SR("2", lineno=1, offset=2), + PLUS_SR("+", lineno=1, offset=3), + NUM_SR("3", lineno=1, offset=4), + ], + ) + assert isinstance(result, AddSR) + assert isinstance(result.l, AddSR), "default S/R resolution must produce a left-leaning tree" + assert isinstance(result.l.l, NumSR) and result.l.l.value == 1 + assert isinstance(result.l.r, NumSR) and result.l.r.value == 2 + assert isinstance(result.r, NumSR) and result.r.value == 3 From c94564be89f2e2a5d2f2ffb2789395b723db5a5f Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:23:40 +0000 Subject: [PATCH 03/17] test: dangling else xfail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S/R 충돌에서 기본 reduce 동작으로 인해 else가 inner if가 아닌 outer if에 바인딩됨. C/Java 관례(inner if가 else를 가져야 함)와 다름. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 91 +++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index 7927bfb..2fc4383 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -288,3 +288,94 @@ def test_default_sr_conflict_is_left_associative() -> None: assert isinstance(result.l.l, NumSR) and result.l.l.value == 1 assert isinstance(result.l.r, NumSR) and result.l.r.value == 2 assert isinstance(result.r, NumSR) and result.r.value == 3 + + +# --------------------------------------------------------------------------- +# Section 3: Dangling else (xfail) +# --------------------------------------------------------------------------- + + +class IF_D(Token): + pass + + +class THEN_D(Token): + pass + + +class ELSE_D(Token): + pass + + +class BASE_D(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = value + + +class StmtD: + pass + + +class BaseStmtD(StmtD): + def __init__(self, tok: BASE_D) -> None: + self.value = tok.value + + +class IfThenD(StmtD): + def __init__(self, cond: BASE_D, then: StmtD) -> None: + self.cond = cond.value + self.then = then + + +class IfThenElseD(StmtD): + def __init__(self, cond: BASE_D, then: StmtD, else_: StmtD) -> None: + self.cond = cond.value + self.then = then + self.else_ = else_ + + +@pytest.mark.xfail( + reason=( + "Default S/R resolution reduces (not shifts) when both precedences are 0 " + "and associative='left': else binds to the outer if instead of the inner if " + "as most languages (C, Java) expect." + ) +) +def test_dangling_else_inner_if_gets_else() -> None: + """The dangling-else problem: 'if c1 then if c2 then s1 else s2'. + + Language convention (C, Java): else binds to the nearest (inner) if. + Expected parse: IfThenD(c1, IfThenElseD(c2, s1, s2)). + + Plare's default S/R resolution: when the parser has reduced 'if c2 then s1' + to a stmt and sees ELSE as lookahead, both production precedence and token + precedence are 0, and associative='left' → reduce wins → the inner if becomes + IfThenD (no else) and the outer if absorbs the else clause. + Actual parse: IfThenElseD(c1, IfThenD(c2, s1), s2). + """ + p = Parser( + { + "stmt": [ + ([IF_D, BASE_D, THEN_D, "stmt", ELSE_D, "stmt"], IfThenElseD, [1, 3, 5]), + ([IF_D, BASE_D, THEN_D, "stmt"], IfThenD, [1, 3]), + ([BASE_D], BaseStmtD, [0]), + ] + } + ) + tokens = [ + IF_D("if", lineno=1, offset=0), + BASE_D("c1", lineno=1, offset=3), + THEN_D("then", lineno=1, offset=6), + IF_D("if", lineno=1, offset=11), + BASE_D("c2", lineno=1, offset=14), + THEN_D("then", lineno=1, offset=17), + BASE_D("s1", lineno=1, offset=22), + ELSE_D("else", lineno=1, offset=25), + BASE_D("s2", lineno=1, offset=30), + ] + result = p.parse("stmt", tokens) + # inner if should absorb the else + assert isinstance(result, IfThenD), "outer if should have no else" + assert isinstance(result.then, IfThenElseD), "inner if should get the else clause" + assert result.then.cond == "c2" From 2300de02160c9b63416c6415f2b004ed78801d3d Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:24:12 +0000 Subject: [PATCH 04/17] test: three-level precedence with %prec unary minus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLUS(1) < STAR(2) < UMINUS_UP(3) 우선순위 계층. prec_token 오버라이드로 단항 마이너스가 곱셈보다 강하게 바인딩됨을 검증. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 111 ++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index 2fc4383..f23f588 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -379,3 +379,114 @@ def test_dangling_else_inner_if_gets_else() -> None: assert isinstance(result, IfThenD), "outer if should have no else" assert isinstance(result.then, IfThenElseD), "inner if should get the else clause" assert result.then.cond == "c2" + + +# --------------------------------------------------------------------------- +# Section 4: Three-level precedence with %prec (unary minus) +# --------------------------------------------------------------------------- + + +class NUM_UP(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = int(value) + + +class PLUS_UP(Token): + precedence = 1 + associative = "left" + + +class STAR_UP(Token): + precedence = 2 + associative = "left" + + +class MINUS_UP(Token): + """Unary minus prefix token (no binary minus in this grammar).""" + + pass + + +class UMINUS_UP(Token): + """Pseudo-token used only as a prec_token override; never emitted by any lexer.""" + + precedence = 3 + associative = "right" + + +class ExprUP: + pass + + +class NumUP(ExprUP): + def __init__(self, tok: NUM_UP) -> None: + self.value = tok.value + + +class NegUP(ExprUP): + def __init__(self, operand: ExprUP) -> None: + self.operand = operand + + +class AddUP(ExprUP): + def __init__(self, l: ExprUP, r: ExprUP) -> None: + self.l = l + self.r = r + + +class MulUP(ExprUP): + def __init__(self, l: ExprUP, r: ExprUP) -> None: + self.l = l + self.r = r + + +_unary_parser = Parser( + { + "expr": [ + ([NUM_UP], NumUP, [0]), + (["expr", PLUS_UP, "expr"], AddUP, [0, 2]), + (["expr", STAR_UP, "expr"], MulUP, [0, 2]), + ([MINUS_UP, "expr"], NegUP, [1], UMINUS_UP), # prec override → 3 + ] + } +) + + +def test_three_level_unary_tighter_than_mul() -> None: + """-2 * 3: unary minus (prec=3 via %prec) beats STAR (prec=2) → Mul(Neg(2), 3). + + Without the prec_token override the unary-minus production would inherit + prec=0 (MINUS_UP has no precedence), which is lower than STAR's 2, making + the parser shift STAR and produce Neg(Mul(2, 3)) instead. + """ + result = _unary_parser.parse( + "expr", + [ + MINUS_UP("-", lineno=1, offset=0), + NUM_UP("2", lineno=1, offset=1), + STAR_UP("*", lineno=1, offset=2), + NUM_UP("3", lineno=1, offset=3), + ], + ) + assert isinstance(result, MulUP), "root must be Mul" + assert isinstance(result.l, NegUP), "left child must be Neg (unary binds tightest)" + assert isinstance(result.l.operand, NumUP) and result.l.operand.value == 2 + assert isinstance(result.r, NumUP) and result.r.value == 3 + + +def test_three_level_unary_with_add() -> None: + """2 + -3: unary minus (prec=3) on right operand is reduced before add (prec=1).""" + result = _unary_parser.parse( + "expr", + [ + NUM_UP("2", lineno=1, offset=0), + PLUS_UP("+", lineno=1, offset=1), + MINUS_UP("-", lineno=1, offset=2), + NUM_UP("3", lineno=1, offset=3), + ], + ) + assert isinstance(result, AddUP), "root must be Add" + assert isinstance(result.l, NumUP) and result.l.value == 2 + assert isinstance(result.r, NegUP), "right child must be Neg" + assert isinstance(result.r.operand, NumUP) and result.r.operand.value == 3 From 43c96b6e4d7d34d7fd83bf4ed81f88641b449c0c Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:24:39 +0000 Subject: [PATCH 05/17] test: comma-separated list grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 리스트, 단일 요소, 3개 요소 케이스를 right-recursive 문법으로 검증. items 값 리스트를 직접 비교하여 파싱 정확성 확인. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 107 ++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index f23f588..74cf9d3 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -490,3 +490,110 @@ def test_three_level_unary_with_add() -> None: assert isinstance(result.l, NumUP) and result.l.value == 2 assert isinstance(result.r, NegUP), "right child must be Neg" assert isinstance(result.r.operand, NumUP) and result.r.operand.value == 3 + + +# --------------------------------------------------------------------------- +# Section 5: Comma-separated list grammar +# --------------------------------------------------------------------------- + + +class LBRACKET_L(Token): + pass + + +class RBRACKET_L(Token): + pass + + +class COMMA_L(Token): + pass + + +class NUM_L(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = int(value) + + +class ItemsL: + items: list[int] + + +class SingleItemL(ItemsL): + def __init__(self, head: NUM_L) -> None: + self.items = [head.value] + + +class ConsItemsL(ItemsL): + def __init__(self, head: NUM_L, tail: ItemsL) -> None: + self.items = [head.value] + tail.items + + +class ListL: + def __init__(self, items_node: ItemsL) -> None: + self.items = items_node.items + + +class EmptyListL: + def __init__(self) -> None: + self.items: list[int] = [] + + +_list_parser = Parser( + { + "list": [ + ([LBRACKET_L, "items", RBRACKET_L], ListL, [1]), + ([LBRACKET_L, RBRACKET_L], EmptyListL, []), + ], + "items": [ + ([NUM_L, COMMA_L, "items"], ConsItemsL, [0, 2]), + ([NUM_L], SingleItemL, [0]), + ], + } +) + + +def _num_l(v: int, o: int = 0) -> NUM_L: + return NUM_L(str(v), lineno=1, offset=o) + + +def test_list_empty() -> None: + """[] produces EmptyListL with items == [].""" + result = _list_parser.parse( + "list", + [LBRACKET_L("[", lineno=1, offset=0), RBRACKET_L("]", lineno=1, offset=1)], + ) + assert isinstance(result, EmptyListL) + assert result.items == [] + + +def test_list_single_element() -> None: + """[42] produces ListL with items == [42].""" + result = _list_parser.parse( + "list", + [ + LBRACKET_L("[", lineno=1, offset=0), + _num_l(42, 1), + RBRACKET_L("]", lineno=1, offset=3), + ], + ) + assert isinstance(result, ListL) + assert result.items == [42] + + +def test_list_three_elements() -> None: + """[1, 2, 3] produces ListL with items == [1, 2, 3].""" + result = _list_parser.parse( + "list", + [ + LBRACKET_L("[", lineno=1, offset=0), + _num_l(1, 1), + COMMA_L(",", lineno=1, offset=2), + _num_l(2, 4), + COMMA_L(",", lineno=1, offset=5), + _num_l(3, 7), + RBRACKET_L("]", lineno=1, offset=8), + ], + ) + assert isinstance(result, ListL) + assert result.items == [1, 2, 3] From 0c79062d33f99f6d16e79126fe0919dc36308f31 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:25:06 +0000 Subject: [PATCH 06/17] test: function call grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인수 없음/1개/3개 함수 호출 케이스. ID_F, LPAREN_F, RPAREN_F, COMMA_F, NUM_F 토큰 사용. 기존 테스트에 없는 함수 호출 구문 문법을 처음으로 검증. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 124 ++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index 74cf9d3..fde13e1 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -597,3 +597,127 @@ def test_list_three_elements() -> None: ) assert isinstance(result, ListL) assert result.items == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# Section 6: Function call grammar +# --------------------------------------------------------------------------- + + +class ID_F(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = value + + +class LPAREN_F(Token): + pass + + +class RPAREN_F(Token): + pass + + +class COMMA_F(Token): + pass + + +class NUM_F(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = int(value) + + +class ArgsF: + items: list[int] + + +class SingleArgF(ArgsF): + def __init__(self, head: NUM_F) -> None: + self.items = [head.value] + + +class ConsArgsF(ArgsF): + def __init__(self, head: NUM_F, tail: ArgsF) -> None: + self.items = [head.value] + tail.items + + +class CallF: + def __init__(self, name: ID_F, args: ArgsF) -> None: + self.name = name.value + self.args = args.items + + +class NoArgCallF: + def __init__(self, name: ID_F) -> None: + self.name = name.value + self.args: list[int] = [] + + +_call_parser = Parser( + { + "call": [ + ([ID_F, LPAREN_F, "args", RPAREN_F], CallF, [0, 2]), + ([ID_F, LPAREN_F, RPAREN_F], NoArgCallF, [0]), + ], + "args": [ + ([NUM_F, COMMA_F, "args"], ConsArgsF, [0, 2]), + ([NUM_F], SingleArgF, [0]), + ], + } +) + + +def _num_f(v: int, o: int = 0) -> NUM_F: + return NUM_F(str(v), lineno=1, offset=o) + + +def test_call_no_args() -> None: + """f() produces NoArgCallF with name='f' and args==[].""" + result = _call_parser.parse( + "call", + [ + ID_F("f", lineno=1, offset=0), + LPAREN_F("(", lineno=1, offset=1), + RPAREN_F(")", lineno=1, offset=2), + ], + ) + assert isinstance(result, NoArgCallF) + assert result.name == "f" + assert result.args == [] + + +def test_call_one_arg() -> None: + """f(1) produces CallF with name='f' and args==[1].""" + result = _call_parser.parse( + "call", + [ + ID_F("f", lineno=1, offset=0), + LPAREN_F("(", lineno=1, offset=1), + _num_f(1, 2), + RPAREN_F(")", lineno=1, offset=3), + ], + ) + assert isinstance(result, CallF) + assert result.name == "f" + assert result.args == [1] + + +def test_call_three_args() -> None: + """f(1, 2, 3) produces CallF with name='f' and args==[1, 2, 3].""" + result = _call_parser.parse( + "call", + [ + ID_F("f", lineno=1, offset=0), + LPAREN_F("(", lineno=1, offset=1), + _num_f(1, 2), + COMMA_F(",", lineno=1, offset=3), + _num_f(2, 5), + COMMA_F(",", lineno=1, offset=6), + _num_f(3, 8), + RPAREN_F(")", lineno=1, offset=9), + ], + ) + assert isinstance(result, CallF) + assert result.name == "f" + assert result.args == [1, 2, 3] From ff7ff5acbacfca3b2caa8c7cdc4739a6a5a0554d Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:25:28 +0000 Subject: [PATCH 07/17] test: deeply nested parentheses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100단계 중첩 괄호를 스택 오버플로 없이 파싱함을 검증. LR 스택은 Python 리스트(명시적)라 재귀 한계 무관. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index fde13e1..3a69f58 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -721,3 +721,22 @@ def test_call_three_args() -> None: assert isinstance(result, CallF) assert result.name == "f" assert result.args == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# Section 7: Deeply nested parentheses +# --------------------------------------------------------------------------- + + +def test_deeply_nested_parentheses() -> None: + """((…(1)…)) with 100 nesting levels parses correctly without stack overflow. + + The LR stack is an explicit Python list (not recursive function calls), + so it handles arbitrarily deep nesting without hitting Python's recursion limit. + """ + depth = 100 + tokens = [LPAREN_C("(", lineno=1, offset=i) for i in range(depth)] + tokens += [NUM_C("1", lineno=1, offset=depth)] + tokens += [RPAREN_C(")", lineno=1, offset=depth + 1 + i) for i in range(depth)] + result = _calc_parser.parse("expr", tokens) + assert eval_c(result) == 1 From b863e15ebe6c5ea015e203277f2598e5279f08a3 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:25:59 +0000 Subject: [PATCH 08/17] test: lexer multiline position tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 줄바꿈을 포함한 소스에서 각 토큰의 lineno/offset 정확성 검증. CALC_LEXER로 렉싱 후 파싱까지 통합 테스트. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index 3a69f58..7fe1121 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -740,3 +740,33 @@ def test_deeply_nested_parentheses() -> None: tokens += [RPAREN_C(")", lineno=1, offset=depth + 1 + i) for i in range(depth)] result = _calc_parser.parse("expr", tokens) assert eval_c(result) == 1 + + +# --------------------------------------------------------------------------- +# Section 8: Lexer integration — multiline position tracking +# --------------------------------------------------------------------------- + + +def test_lexer_multiline_position_tracking() -> None: + """Tokens on different lines get the correct lineno and offset=0. + + Source '1\\n+\\n2': each token is on its own line. + The lexer resets offset to 0 at each newline. + Whitespace (including '\\n') is consumed by the "start" state re-entry pattern + and lineno is incremented per newline encountered. + """ + tokens = list(CALC_LEXER.lex("start", "1\n+\n2")) + assert len(tokens) == 3 + + num1, plus, num2 = tokens + assert isinstance(num1, NUM_C) and num1.value == 1 + assert num1.lineno == 1 and num1.offset == 0 + + assert isinstance(plus, PLUS_C) + assert plus.lineno == 2 and plus.offset == 0 + + assert isinstance(num2, NUM_C) and num2.value == 2 + assert num2.lineno == 3 and num2.offset == 0 + + result = _calc_parser.parse("expr", tokens) + assert eval_c(result) == 3 From 8646969702ab5fcc4ef25c9c210029e7eeb7949d Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:26:19 +0000 Subject: [PATCH 09/17] test: error cases (unclosed paren, trailing tokens) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 괄호 미닫힘 및 여분 토큰 시 ParsingError 발생 검증. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index 7fe1121..a116a56 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -770,3 +770,41 @@ def test_lexer_multiline_position_tracking() -> None: result = _calc_parser.parse("expr", tokens) assert eval_c(result) == 3 + + +# --------------------------------------------------------------------------- +# Section 9: Error cases +# --------------------------------------------------------------------------- + + +def test_error_unclosed_paren() -> None: + """(1 + 2 with no closing paren raises ParsingError at end-of-input.""" + with pytest.raises(ParsingError): + _calc_parser.parse( + "expr", + [ + LPAREN_C("(", lineno=1, offset=0), + _num(1, 1), + PLUS_C("+", lineno=1, offset=2), + _num(2, 3), + # RPAREN_C missing + ], + ) + + +def test_error_trailing_tokens() -> None: + """1 + 2 followed by an extra token raises ParsingError. + + After the parser reduces to the top-level expr and sees EOS, it accepts. + But if extra tokens precede EOS, the accept state has no action for them. + """ + with pytest.raises(ParsingError): + _calc_parser.parse( + "expr", + [ + _num(1, 0), + PLUS_C("+", lineno=1, offset=1), + _num(2, 2), + _num(3, 3), # extra token — parser cannot accept with this pending + ], + ) From d531670ce76629b8c3108c3170a1439eb021ef0e Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:26:42 +0000 Subject: [PATCH 10/17] test: top-level epsilon production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit program → stmts → stmt stmts | ε 두 단계 문법. 빈 입력, 1개, 3개 구문 케이스 및 순서 보존 검증. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_grammar_logic.py | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index a116a56..191aed3 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -808,3 +808,79 @@ def test_error_trailing_tokens() -> None: _num(3, 3), # extra token — parser cannot accept with this pending ], ) + + +# --------------------------------------------------------------------------- +# Section 10: Top-level epsilon production +# --------------------------------------------------------------------------- + + +class STMT_E(Token): + def __init__(self, value: str, *, lineno: int, offset: int) -> None: + super().__init__(value, lineno=lineno, offset=offset) + self.value = value + + +class StmtsE: + items: list[STMT_E] + + +class EmptyStmtsE(StmtsE): + def __init__(self) -> None: + self.items = [] + + +class NonEmptyStmtsE(StmtsE): + def __init__(self, stmt: STMT_E, tail: StmtsE) -> None: + self.items = [stmt] + tail.items + + +class ProgramE: + def __init__(self, stmts: StmtsE) -> None: + self.stmts = stmts.items + + +_program_parser = Parser( + { + "program": [ + (["stmts"], ProgramE, [0]), + ], + "stmts": [ + ([STMT_E, "stmts"], NonEmptyStmtsE, [0, 1]), + ([], EmptyStmtsE, []), + ], + } +) + + +def test_empty_program() -> None: + """An empty token stream produces ProgramE with stmts == [].""" + result = _program_parser.parse("program", []) + assert isinstance(result, ProgramE) + assert result.stmts == [] + + +def test_single_statement_program() -> None: + """One STMT_E token produces ProgramE with len(stmts) == 1.""" + result = _program_parser.parse( + "program", + [STMT_E("x", lineno=1, offset=0)], + ) + assert isinstance(result, ProgramE) + assert len(result.stmts) == 1 + assert isinstance(result.stmts[0], STMT_E) + + +def test_three_statement_program() -> None: + """Three STMT_E tokens produce ProgramE with len(stmts) == 3 in order.""" + result = _program_parser.parse( + "program", + [ + STMT_E("a", lineno=1, offset=0), + STMT_E("b", lineno=1, offset=2), + STMT_E("c", lineno=1, offset=4), + ], + ) + assert isinstance(result, ProgramE) + assert len(result.stmts) == 3 + assert [s.value for s in result.stmts] == ["a", "b", "c"] From 3fecd2104e4f8272320eb414c5b5302750d64a4d Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:39:08 +0000 Subject: [PATCH 11/17] No private naming --- tests/test_grammar_logic.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index 191aed3..9b6d979 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -129,7 +129,7 @@ def eval_c(node: ExprC) -> int: } ) -_calc_parser = Parser(CALC_GRAMMAR) +calc_parser = Parser(CALC_GRAMMAR) def _num(v: int, o: int = 0) -> NUM_C: @@ -138,7 +138,7 @@ def _num(v: int, o: int = 0) -> NUM_C: def test_calc_single_addition() -> None: """1 + 2 evaluates to 3 and produces an AddC node.""" - result = _calc_parser.parse( + result = calc_parser.parse( "expr", [_num(1, 0), PLUS_C("+", lineno=1, offset=1), _num(2, 2)], ) @@ -150,7 +150,7 @@ def test_calc_single_addition() -> None: def test_calc_precedence_mul_over_add() -> None: """1 + 2 * 3: STAR (prec=2) beats PLUS (prec=1), so root is AddC, right child is MulC.""" - result = _calc_parser.parse( + result = calc_parser.parse( "expr", [ _num(1, 0), @@ -167,7 +167,7 @@ def test_calc_precedence_mul_over_add() -> None: def test_calc_left_assoc_subtraction_chain() -> None: """10 - 3 - 2: left-associativity produces SubC(SubC(10, 3), 2) = 5, not 9.""" - result = _calc_parser.parse( + result = calc_parser.parse( "expr", [ _num(10, 0), @@ -184,7 +184,7 @@ def test_calc_left_assoc_subtraction_chain() -> None: def test_calc_integer_division() -> None: """7 / 2 produces DivC and floor-divides to 3.""" - result = _calc_parser.parse( + result = calc_parser.parse( "expr", [_num(7, 0), SLASH_C("/", lineno=1, offset=1), _num(2, 2)], ) @@ -194,7 +194,7 @@ def test_calc_integer_division() -> None: def test_calc_parentheses_override_precedence() -> None: """2 * (3 + 4): parentheses force add before multiply despite STAR having higher prec.""" - result = _calc_parser.parse( + result = calc_parser.parse( "expr", [ _num(2, 0), @@ -213,7 +213,7 @@ def test_calc_parentheses_override_precedence() -> None: def test_calc_mixed_left_assoc_same_precedence() -> None: """1 - 2 + 3: MINUS and PLUS share prec=1 with left-assoc; result is (1-2)+3 = 2.""" - result = _calc_parser.parse( + result = calc_parser.parse( "expr", [ _num(1, 0), @@ -738,7 +738,7 @@ def test_deeply_nested_parentheses() -> None: tokens = [LPAREN_C("(", lineno=1, offset=i) for i in range(depth)] tokens += [NUM_C("1", lineno=1, offset=depth)] tokens += [RPAREN_C(")", lineno=1, offset=depth + 1 + i) for i in range(depth)] - result = _calc_parser.parse("expr", tokens) + result = calc_parser.parse("expr", tokens) assert eval_c(result) == 1 @@ -768,7 +768,7 @@ def test_lexer_multiline_position_tracking() -> None: assert isinstance(num2, NUM_C) and num2.value == 2 assert num2.lineno == 3 and num2.offset == 0 - result = _calc_parser.parse("expr", tokens) + result = calc_parser.parse("expr", tokens) assert eval_c(result) == 3 @@ -780,7 +780,7 @@ def test_lexer_multiline_position_tracking() -> None: def test_error_unclosed_paren() -> None: """(1 + 2 with no closing paren raises ParsingError at end-of-input.""" with pytest.raises(ParsingError): - _calc_parser.parse( + calc_parser.parse( "expr", [ LPAREN_C("(", lineno=1, offset=0), @@ -799,7 +799,7 @@ def test_error_trailing_tokens() -> None: But if extra tokens precede EOS, the accept state has no action for them. """ with pytest.raises(ParsingError): - _calc_parser.parse( + calc_parser.parse( "expr", [ _num(1, 0), From a1843349ff40dc8c9866a9d82f1e308444a1f7f5 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:40:04 +0000 Subject: [PATCH 12/17] Minor formatting --- tests/test_grammar_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index 9b6d979..1029243 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -261,7 +261,7 @@ def test_default_sr_conflict_is_left_associative() -> None: item.precedence == symbol.precedence and symbol.associative == "left" is True (Token default associative="left"), so reduce wins. - This means 1 + 2 + 3 produces a LEFT-leaning tree AddSR(AddSR(1,2), 3), + This means 1 + 2 + 3 produces a LEFT-leaning tree AddSR(AddSR(1, 2), 3), not a right-leaning one. The existing test_shift_reduce_resolution_prefers_shift only tests the two-token case and makes no tree-shape assertion. """ From 161f24e7e445e3b01ca3518009b9bef3b12a7e0b Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:40:53 +0000 Subject: [PATCH 13/17] No private naming --- tests/test_grammar_logic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index 1029243..e4beda5 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -441,7 +441,7 @@ def __init__(self, l: ExprUP, r: ExprUP) -> None: self.r = r -_unary_parser = Parser( +unary_parser = Parser( { "expr": [ ([NUM_UP], NumUP, [0]), @@ -460,7 +460,7 @@ def test_three_level_unary_tighter_than_mul() -> None: prec=0 (MINUS_UP has no precedence), which is lower than STAR's 2, making the parser shift STAR and produce Neg(Mul(2, 3)) instead. """ - result = _unary_parser.parse( + result = unary_parser.parse( "expr", [ MINUS_UP("-", lineno=1, offset=0), @@ -477,7 +477,7 @@ def test_three_level_unary_tighter_than_mul() -> None: def test_three_level_unary_with_add() -> None: """2 + -3: unary minus (prec=3) on right operand is reduced before add (prec=1).""" - result = _unary_parser.parse( + result = unary_parser.parse( "expr", [ NUM_UP("2", lineno=1, offset=0), From 52dc97321e387435f9fd92dec0133b91751f7b8b Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:44:15 +0000 Subject: [PATCH 14/17] No private naming --- tests/test_grammar_logic.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index e4beda5..ee21c9f 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -539,7 +539,7 @@ def __init__(self) -> None: self.items: list[int] = [] -_list_parser = Parser( +list_parser = Parser( { "list": [ ([LBRACKET_L, "items", RBRACKET_L], ListL, [1]), @@ -559,7 +559,7 @@ def _num_l(v: int, o: int = 0) -> NUM_L: def test_list_empty() -> None: """[] produces EmptyListL with items == [].""" - result = _list_parser.parse( + result = list_parser.parse( "list", [LBRACKET_L("[", lineno=1, offset=0), RBRACKET_L("]", lineno=1, offset=1)], ) @@ -569,7 +569,7 @@ def test_list_empty() -> None: def test_list_single_element() -> None: """[42] produces ListL with items == [42].""" - result = _list_parser.parse( + result = list_parser.parse( "list", [ LBRACKET_L("[", lineno=1, offset=0), @@ -583,7 +583,7 @@ def test_list_single_element() -> None: def test_list_three_elements() -> None: """[1, 2, 3] produces ListL with items == [1, 2, 3].""" - result = _list_parser.parse( + result = list_parser.parse( "list", [ LBRACKET_L("[", lineno=1, offset=0), @@ -654,7 +654,7 @@ def __init__(self, name: ID_F) -> None: self.args: list[int] = [] -_call_parser = Parser( +call_parser = Parser( { "call": [ ([ID_F, LPAREN_F, "args", RPAREN_F], CallF, [0, 2]), @@ -668,13 +668,13 @@ def __init__(self, name: ID_F) -> None: ) -def _num_f(v: int, o: int = 0) -> NUM_F: +def num_f(v: int, o: int = 0) -> NUM_F: return NUM_F(str(v), lineno=1, offset=o) def test_call_no_args() -> None: """f() produces NoArgCallF with name='f' and args==[].""" - result = _call_parser.parse( + result = call_parser.parse( "call", [ ID_F("f", lineno=1, offset=0), @@ -689,12 +689,12 @@ def test_call_no_args() -> None: def test_call_one_arg() -> None: """f(1) produces CallF with name='f' and args==[1].""" - result = _call_parser.parse( + result = call_parser.parse( "call", [ ID_F("f", lineno=1, offset=0), LPAREN_F("(", lineno=1, offset=1), - _num_f(1, 2), + num_f(1, 2), RPAREN_F(")", lineno=1, offset=3), ], ) @@ -705,16 +705,16 @@ def test_call_one_arg() -> None: def test_call_three_args() -> None: """f(1, 2, 3) produces CallF with name='f' and args==[1, 2, 3].""" - result = _call_parser.parse( + result = call_parser.parse( "call", [ ID_F("f", lineno=1, offset=0), LPAREN_F("(", lineno=1, offset=1), - _num_f(1, 2), + num_f(1, 2), COMMA_F(",", lineno=1, offset=3), - _num_f(2, 5), + num_f(2, 5), COMMA_F(",", lineno=1, offset=6), - _num_f(3, 8), + num_f(3, 8), RPAREN_F(")", lineno=1, offset=9), ], ) @@ -840,7 +840,7 @@ def __init__(self, stmts: StmtsE) -> None: self.stmts = stmts.items -_program_parser = Parser( +program_parser = Parser( { "program": [ (["stmts"], ProgramE, [0]), @@ -855,14 +855,14 @@ def __init__(self, stmts: StmtsE) -> None: def test_empty_program() -> None: """An empty token stream produces ProgramE with stmts == [].""" - result = _program_parser.parse("program", []) + result = program_parser.parse("program", []) assert isinstance(result, ProgramE) assert result.stmts == [] def test_single_statement_program() -> None: """One STMT_E token produces ProgramE with len(stmts) == 1.""" - result = _program_parser.parse( + result = program_parser.parse( "program", [STMT_E("x", lineno=1, offset=0)], ) @@ -873,7 +873,7 @@ def test_single_statement_program() -> None: def test_three_statement_program() -> None: """Three STMT_E tokens produce ProgramE with len(stmts) == 3 in order.""" - result = _program_parser.parse( + result = program_parser.parse( "program", [ STMT_E("a", lineno=1, offset=0), From 887487a0b8d897acb7187a058c07b459e0fbcb2e Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 15:46:50 +0000 Subject: [PATCH 15/17] No private naming --- tests/test_grammar_logic.py | 50 ++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index ee21c9f..93ce5e5 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -132,7 +132,7 @@ def eval_c(node: ExprC) -> int: calc_parser = Parser(CALC_GRAMMAR) -def _num(v: int, o: int = 0) -> NUM_C: +def num(v: int, o: int = 0) -> NUM_C: return NUM_C(str(v), lineno=1, offset=o) @@ -140,7 +140,7 @@ def test_calc_single_addition() -> None: """1 + 2 evaluates to 3 and produces an AddC node.""" result = calc_parser.parse( "expr", - [_num(1, 0), PLUS_C("+", lineno=1, offset=1), _num(2, 2)], + [num(1, 0), PLUS_C("+", lineno=1, offset=1), num(2, 2)], ) assert isinstance(result, AddC) assert isinstance(result.l, NumC) and result.l.value == 1 @@ -153,11 +153,11 @@ def test_calc_precedence_mul_over_add() -> None: result = calc_parser.parse( "expr", [ - _num(1, 0), + num(1, 0), PLUS_C("+", lineno=1, offset=1), - _num(2, 2), + num(2, 2), STAR_C("*", lineno=1, offset=3), - _num(3, 4), + num(3, 4), ], ) assert isinstance(result, AddC), "'+' must be root because '*' binds tighter" @@ -170,11 +170,11 @@ def test_calc_left_assoc_subtraction_chain() -> None: result = calc_parser.parse( "expr", [ - _num(10, 0), + num(10, 0), MINUS_C("-", lineno=1, offset=2), - _num(3, 3), + num(3, 3), MINUS_C("-", lineno=1, offset=4), - _num(2, 5), + num(2, 5), ], ) assert isinstance(result, SubC) @@ -186,7 +186,7 @@ def test_calc_integer_division() -> None: """7 / 2 produces DivC and floor-divides to 3.""" result = calc_parser.parse( "expr", - [_num(7, 0), SLASH_C("/", lineno=1, offset=1), _num(2, 2)], + [num(7, 0), SLASH_C("/", lineno=1, offset=1), num(2, 2)], ) assert isinstance(result, DivC) assert eval_c(result) == 3 @@ -197,12 +197,12 @@ def test_calc_parentheses_override_precedence() -> None: result = calc_parser.parse( "expr", [ - _num(2, 0), + num(2, 0), STAR_C("*", lineno=1, offset=1), LPAREN_C("(", lineno=1, offset=2), - _num(3, 3), + num(3, 3), PLUS_C("+", lineno=1, offset=4), - _num(4, 5), + num(4, 5), RPAREN_C(")", lineno=1, offset=6), ], ) @@ -216,11 +216,11 @@ def test_calc_mixed_left_assoc_same_precedence() -> None: result = calc_parser.parse( "expr", [ - _num(1, 0), + num(1, 0), MINUS_C("-", lineno=1, offset=1), - _num(2, 2), + num(2, 2), PLUS_C("+", lineno=1, offset=3), - _num(3, 4), + num(3, 4), ], ) assert isinstance(result, AddC), "outer op must be Add" @@ -553,7 +553,7 @@ def __init__(self) -> None: ) -def _num_l(v: int, o: int = 0) -> NUM_L: +def num_l(v: int, o: int = 0) -> NUM_L: return NUM_L(str(v), lineno=1, offset=o) @@ -573,7 +573,7 @@ def test_list_single_element() -> None: "list", [ LBRACKET_L("[", lineno=1, offset=0), - _num_l(42, 1), + num_l(42, 1), RBRACKET_L("]", lineno=1, offset=3), ], ) @@ -587,11 +587,11 @@ def test_list_three_elements() -> None: "list", [ LBRACKET_L("[", lineno=1, offset=0), - _num_l(1, 1), + num_l(1, 1), COMMA_L(",", lineno=1, offset=2), - _num_l(2, 4), + num_l(2, 4), COMMA_L(",", lineno=1, offset=5), - _num_l(3, 7), + num_l(3, 7), RBRACKET_L("]", lineno=1, offset=8), ], ) @@ -784,9 +784,9 @@ def test_error_unclosed_paren() -> None: "expr", [ LPAREN_C("(", lineno=1, offset=0), - _num(1, 1), + num(1, 1), PLUS_C("+", lineno=1, offset=2), - _num(2, 3), + num(2, 3), # RPAREN_C missing ], ) @@ -802,10 +802,10 @@ def test_error_trailing_tokens() -> None: calc_parser.parse( "expr", [ - _num(1, 0), + num(1, 0), PLUS_C("+", lineno=1, offset=1), - _num(2, 2), - _num(3, 3), # extra token — parser cannot accept with this pending + num(2, 2), + num(3, 3), # extra token — parser cannot accept with this pending ], ) From 91b444d6fb919b3bac8b27780e6a1b7366e2246e Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 16:27:29 +0000 Subject: [PATCH 16/17] Fix pyright strict type errors in grammar type annotations - Use Sequence (covariant) instead of list (invariant) for the RHS symbol sequence in Parser.__init__ grammar parameter type, resolving list variance errors when passing subclass token lists - Add GrammarDict[T] type alias and annotate grammar dicts with concrete AST node types so Parser[T] is inferred correctly - Add isinstance(result, ExprC) narrowing before eval_c() calls where parse() return type is ExprC | Token Co-Authored-By: Claude Sonnet 4.6 --- plare/parser.py | 7 ++- tests/test_grammar_logic.py | 95 ++++++++++++++++++++++--------------- 2 files changed, 61 insertions(+), 41 deletions(-) diff --git a/plare/parser.py b/plare/parser.py index fa7a69e..28c081b 100644 --- a/plare/parser.py +++ b/plare/parser.py @@ -17,6 +17,7 @@ from __future__ import annotations from collections import deque +from collections.abc import Sequence from itertools import chain from typing import Iterable, Protocol, TypeGuard @@ -835,8 +836,10 @@ def __init__( grammar: dict[ str, list[ - tuple[list[type[Token] | str], type[T] | None, list[int]] - | tuple[list[type[Token] | str], type[T] | None, list[int], type[Token]] + tuple[Sequence[type[Token] | str], type[T] | None, list[int]] + | tuple[ + Sequence[type[Token] | str], type[T] | None, list[int], type[Token] + ] ], ], ) -> None: diff --git a/tests/test_grammar_logic.py b/tests/test_grammar_logic.py index 93ce5e5..8773b8a 100644 --- a/tests/test_grammar_logic.py +++ b/tests/test_grammar_logic.py @@ -8,6 +8,8 @@ from __future__ import annotations +from collections.abc import Sequence + import pytest from plare.exception import ParsingError @@ -15,6 +17,14 @@ from plare.parser import Parser from plare.token import Token +type GrammarDict[T] = dict[ + str, + list[ + tuple[Sequence[type[Token] | str], type[T] | None, list[int]] + | tuple[Sequence[type[Token] | str], type[T] | None, list[int], type[Token]] + ], +] + # --------------------------------------------------------------------------- # Section 1: Arithmetic Calculator # --------------------------------------------------------------------------- @@ -103,7 +113,7 @@ def eval_c(node: ExprC) -> int: raise ValueError(f"Unknown node: {node}") -CALC_GRAMMAR: dict = { +CALC_GRAMMAR: GrammarDict[ExprC] = { "expr": [ ([NUM_C], NumC, [0]), (["expr", PLUS_C, "expr"], AddC, [0, 2]), @@ -178,7 +188,9 @@ def test_calc_left_assoc_subtraction_chain() -> None: ], ) assert isinstance(result, SubC) - assert isinstance(result.l, SubC), "left-assoc: outer sub must have inner sub as left child" + assert isinstance( + result.l, SubC + ), "left-assoc: outer sub must have inner sub as left child" assert eval_c(result) == 5 @@ -284,7 +296,9 @@ def test_default_sr_conflict_is_left_associative() -> None: ], ) assert isinstance(result, AddSR) - assert isinstance(result.l, AddSR), "default S/R resolution must produce a left-leaning tree" + assert isinstance( + result.l, AddSR + ), "default S/R resolution must produce a left-leaning tree" assert isinstance(result.l.l, NumSR) and result.l.l.value == 1 assert isinstance(result.l.r, NumSR) and result.l.r.value == 2 assert isinstance(result.r, NumSR) and result.r.value == 3 @@ -357,7 +371,11 @@ def test_dangling_else_inner_if_gets_else() -> None: p = Parser( { "stmt": [ - ([IF_D, BASE_D, THEN_D, "stmt", ELSE_D, "stmt"], IfThenElseD, [1, 3, 5]), + ( + [IF_D, BASE_D, THEN_D, "stmt", ELSE_D, "stmt"], + IfThenElseD, + [1, 3, 5], + ), ([IF_D, BASE_D, THEN_D, "stmt"], IfThenD, [1, 3]), ([BASE_D], BaseStmtD, [0]), ] @@ -539,18 +557,17 @@ def __init__(self) -> None: self.items: list[int] = [] -list_parser = Parser( - { - "list": [ - ([LBRACKET_L, "items", RBRACKET_L], ListL, [1]), - ([LBRACKET_L, RBRACKET_L], EmptyListL, []), - ], - "items": [ - ([NUM_L, COMMA_L, "items"], ConsItemsL, [0, 2]), - ([NUM_L], SingleItemL, [0]), - ], - } -) +LIST_GRAMMAR: GrammarDict[ListL | EmptyListL | ConsItemsL | SingleItemL] = { + "list": [ + ([LBRACKET_L, "items", RBRACKET_L], ListL, [1]), + ([LBRACKET_L, RBRACKET_L], EmptyListL, []), + ], + "items": [ + ([NUM_L, COMMA_L, "items"], ConsItemsL, [0, 2]), + ([NUM_L], SingleItemL, [0]), + ], +} +list_parser = Parser(LIST_GRAMMAR) def num_l(v: int, o: int = 0) -> NUM_L: @@ -654,18 +671,17 @@ def __init__(self, name: ID_F) -> None: self.args: list[int] = [] -call_parser = Parser( - { - "call": [ - ([ID_F, LPAREN_F, "args", RPAREN_F], CallF, [0, 2]), - ([ID_F, LPAREN_F, RPAREN_F], NoArgCallF, [0]), - ], - "args": [ - ([NUM_F, COMMA_F, "args"], ConsArgsF, [0, 2]), - ([NUM_F], SingleArgF, [0]), - ], - } -) +CALL_GRAMMAR: GrammarDict[CallF | NoArgCallF | ConsArgsF | SingleArgF] = { + "call": [ + ([ID_F, LPAREN_F, "args", RPAREN_F], CallF, [0, 2]), + ([ID_F, LPAREN_F, RPAREN_F], NoArgCallF, [0]), + ], + "args": [ + ([NUM_F, COMMA_F, "args"], ConsArgsF, [0, 2]), + ([NUM_F], SingleArgF, [0]), + ], +} +call_parser = Parser(CALL_GRAMMAR) def num_f(v: int, o: int = 0) -> NUM_F: @@ -739,6 +755,7 @@ def test_deeply_nested_parentheses() -> None: tokens += [NUM_C("1", lineno=1, offset=depth)] tokens += [RPAREN_C(")", lineno=1, offset=depth + 1 + i) for i in range(depth)] result = calc_parser.parse("expr", tokens) + assert isinstance(result, ExprC) assert eval_c(result) == 1 @@ -769,6 +786,7 @@ def test_lexer_multiline_position_tracking() -> None: assert num2.lineno == 3 and num2.offset == 0 result = calc_parser.parse("expr", tokens) + assert isinstance(result, ExprC) assert eval_c(result) == 3 @@ -840,17 +858,16 @@ def __init__(self, stmts: StmtsE) -> None: self.stmts = stmts.items -program_parser = Parser( - { - "program": [ - (["stmts"], ProgramE, [0]), - ], - "stmts": [ - ([STMT_E, "stmts"], NonEmptyStmtsE, [0, 1]), - ([], EmptyStmtsE, []), - ], - } -) +PROGRAM_GRAMMAR: GrammarDict[ProgramE | NonEmptyStmtsE | EmptyStmtsE] = { + "program": [ + (["stmts"], ProgramE, [0]), + ], + "stmts": [ + ([STMT_E, "stmts"], NonEmptyStmtsE, [0, 1]), + ([], EmptyStmtsE, []), + ], +} +program_parser = Parser(PROGRAM_GRAMMAR) def test_empty_program() -> None: From 2c82a7739991deda3a9a029a4c4bae132572d3e5 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 12 May 2026 16:36:40 +0000 Subject: [PATCH 17/17] Fix remaining pyright type errors across all test files - Change grammar parameter type from dict to Mapping (covariant in value), fixing invariance errors when existing Grammar/GrammarRules type aliases (using list) are passed from other test files - Add Sequence wrapper around the productions list for the same reason - Convert right (now Sequence) to list() internally to preserve the list[Symbol] contract expected by Rule and Item Co-Authored-By: Claude Sonnet 4.6 --- plare/parser.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plare/parser.py b/plare/parser.py index 28c081b..dc85131 100644 --- a/plare/parser.py +++ b/plare/parser.py @@ -17,7 +17,7 @@ from __future__ import annotations from collections import deque -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from itertools import chain from typing import Iterable, Protocol, TypeGuard @@ -833,9 +833,9 @@ class Parser[T]: def __init__( self, - grammar: dict[ + grammar: Mapping[ str, - list[ + Sequence[ tuple[Sequence[type[Token] | str], type[T] | None, list[int]] | tuple[ Sequence[type[Token] | str], type[T] | None, list[int], type[Token] @@ -867,10 +867,12 @@ def __init__( for entry in productions: if len(entry) == 4: right, action, args, prec_token = entry - norm_rights.append((right, action, args, prec_token.precedence)) + norm_rights.append( + (list(right), action, args, prec_token.precedence) + ) else: right, action, args = entry - norm_rights.append((right, action, args, None)) + norm_rights.append((list(right), action, args, None)) rules[left] = Rule[T](left, norm_rights, global_idx) start_var = StartVariable(left) augmented = Rule[T](start_var, [([left], None, [0], None)], 0)