parser(step 30): expression grammar over the precedence table #8

Merged
crowmaki merged 1 commit from parser-step-30-expressions into main 2026-08-22 03:02:39 +00:00
Owner

Step 30 of docs/parser/: the expression grammar — a Pratt / precedence-climbing
reader over the binding-power table in language-spec.md ▸ Syntax ▸ Precedence
and Associativity.

What's here

  • infix_op() — the whole operator table in one place, so a transposed row is a
    one-line diff rather than a scattered edit.
  • parse_bp / parse_unary / parse_postfix / parse_primary, plus
    parse_tuple_or_group, parse_array_value, parse_new_value,
    parse_new_array, parse_call_args, parse_brace_elems.
  • No backtracking. A ( after a completed primary is a call; a ( starting a
    primary is a tuple or a group — position alone settles it. The one two-token
    peek ([ ] {, telling [] { 1, 2 } from []int) uses a new bounded
    Cursor::nth.
  • array_size is now shared by [N]T and new [N]T, so the two cannot
    disagree about the 64-bit limit.

Ranges and is are non-repeating per the grammar's [ ... ], so a..b..c and
x is A is B are refused at the second operator. Parenthesized, (a..b)..c
still parses.

Four conflicts I resolved rather than papered over

The step doc says to stop and flag rather than silently reorder, so these are
the calls worth reviewing:

  1. a??b??c. docs/parser/30-expressions.md asserted (?? a (?? b c)) and
    labelled it "left-assoc" — self-contradictory. The spec's table (level 16,
    Left-to-right) and language-grammar.md both give (?? (?? a b) c). I
    implemented the spec's reading and corrected the doc.
  2. BinOp::Is was unbuildable. is reads a type on the right, so it
    cannot be a BinOp over two Exprs. Replaced with
    Expr::TypeTest(Expr, TypeRef), symmetric with the existing Cast.
  3. Expr::Paren removed. With it, (1+2)*3 dumps (* ((+ 1 2)) 3),
    contradicting the step doc's own acceptance line. Parens now group without a
    node, exactly as (int) yields int in the type grammar.
  4. AssignOp gained &&= / ||=, which the spec's level-18 row lists and
    the lexer already emits. IntLit widened to u128 to match the scanner's
    payload — narrowing would have refused legal s128/u128 literals.

Plus one grammar fix: postfix made f() underivable, but
null-coalescing.cat writes obj?.DoMethod(). Now "(" [ call-args ] ")".

Deferred to step 50 (as the step doc says)

func-value lambdas, and this as a primary. this is a reserved keyword with
no AST node and no fixture — its only occurrences are inside ${...}
interpolation, which the lexer keeps verbatim. Inventing a node without a
fixture would break the fixture-first contract.

Verification

14 new unit tests, RED before GREEN: one per boundary between adjacent
precedence levels, both associativity directions, the -x as u16 case that
tells the two readings of as apart, postfix chains, call-args vs tuples,
malformed-input offsets, and span coverage.

  • cargo fmt --check — clean
  • cargo clippy --all --all-targets -- -D warnings — clean
  • cargo test --all — 56 passed, 0 failed

The parse-phase fixture driver stays #[ignore]d; it goes green at step 70,
per its own note.

Incidental finding

a.1 never reaches the member rule — the scanner reads .1 as a float literal.
Harmless today, since Catlang has no .0 tuple-field syntax (the fixtures index
tuples with point[0]). Noted in the test rather than worked around.

Step 30 of `docs/parser/`: the expression grammar — a Pratt / precedence-climbing reader over the binding-power table in `language-spec.md` ▸ Syntax ▸ Precedence and Associativity. ## What's here - `infix_op()` — the whole operator table in one place, so a transposed row is a one-line diff rather than a scattered edit. - `parse_bp` / `parse_unary` / `parse_postfix` / `parse_primary`, plus `parse_tuple_or_group`, `parse_array_value`, `parse_new_value`, `parse_new_array`, `parse_call_args`, `parse_brace_elems`. - No backtracking. A `(` after a completed primary is a call; a `(` starting a primary is a tuple or a group — position alone settles it. The one two-token peek (`[` `]` `{`, telling `[] { 1, 2 }` from `[]int`) uses a new bounded `Cursor::nth`. - `array_size` is now shared by `[N]T` and `new [N]T`, so the two cannot disagree about the 64-bit limit. Ranges and `is` are non-repeating per the grammar's `[ ... ]`, so `a..b..c` and `x is A is B` are refused at the second operator. Parenthesized, `(a..b)..c` still parses. ## Four conflicts I resolved rather than papered over The step doc says to stop and flag rather than silently reorder, so these are the calls worth reviewing: 1. **`a??b??c`.** `docs/parser/30-expressions.md` asserted `(?? a (?? b c))` and labelled it "left-assoc" — self-contradictory. The spec's table (level 16, Left-to-right) and `language-grammar.md` both give `(?? (?? a b) c)`. I implemented the spec's reading and corrected the doc. 2. **`BinOp::Is` was unbuildable.** `is` reads a *type* on the right, so it cannot be a `BinOp` over two `Expr`s. Replaced with `Expr::TypeTest(Expr, TypeRef)`, symmetric with the existing `Cast`. 3. **`Expr::Paren` removed.** With it, `(1+2)*3` dumps `(* ((+ 1 2)) 3)`, contradicting the step doc's own acceptance line. Parens now group without a node, exactly as `(int)` yields `int` in the type grammar. 4. **`AssignOp` gained `&&=` / `||=`**, which the spec's level-18 row lists and the lexer already emits. `IntLit` widened to `u128` to match the scanner's payload — narrowing would have refused legal `s128`/`u128` literals. Plus one grammar fix: `postfix` made `f()` underivable, but `null-coalescing.cat` writes `obj?.DoMethod()`. Now `"(" [ call-args ] ")"`. ## Deferred to step 50 (as the step doc says) `func-value` lambdas, and `this` as a primary. `this` is a reserved keyword with no AST node and no fixture — its only occurrences are inside `${...}` interpolation, which the lexer keeps verbatim. Inventing a node without a fixture would break the fixture-first contract. ## Verification 14 new unit tests, RED before GREEN: one per boundary between adjacent precedence levels, both associativity directions, the `-x as u16` case that tells the two readings of `as` apart, postfix chains, call-args vs tuples, malformed-input offsets, and span coverage. - `cargo fmt --check` — clean - `cargo clippy --all --all-targets -- -D warnings` — clean - `cargo test --all` — 56 passed, 0 failed The `parse`-phase fixture driver stays `#[ignore]`d; it goes green at step 70, per its own note. ## Incidental finding `a.1` never reaches the member rule — the scanner reads `.1` as a float literal. Harmless today, since Catlang has no `.0` tuple-field syntax (the fixtures index tuples with `point[0]`). Noted in the test rather than worked around.
Add `parse_expr` — a Pratt / precedence-climbing reader over the binding-power
table in `language-spec.md` ▸ Syntax ▸ Precedence and Associativity. Levels 3b
(`as`) through 18 (assignment) climb; level 2's suffixes and level 1's `::` are
read by `parse_postfix` and `parse_qualified_name`, which is where the spec puts
them.

`infix_op()` holds the whole table in one place, so a transposed row is a
one-line diff rather than a scattered edit. Paren disambiguation is by position
and needs no backtracking: a `(` after a completed primary is a call, a `(`
starting a primary is a tuple or a group. The one two-token peek (`[` `]` `{`,
telling `[] { 1, 2 }` from `[]int`) uses a new bounded `Cursor::nth`.

Four conflicts between the step doc, the AST and the spec, resolved rather than
papered over:

- `docs/parser/30-expressions.md` asserted `a??b??c` == `(?? a (?? b c))` and
  labelled it "left-assoc". The spec's table (level 16, Left-to-right) and
  `language-grammar.md` both say `(?? (?? a b) c)`. The spec wins; the doc is
  corrected.
- `BinOp::Is` was unbuildable: `is` reads a *type* on the right, so it cannot be
  a `BinOp` over two `Expr`s. Replaced with `Expr::TypeTest(Expr, TypeRef)`,
  symmetric with the existing `Cast`.
- `Expr::Paren` had to go. With it, `(1+2)*3` dumps `(* ((+ 1 2)) 3)`, which
  contradicts the step doc's own acceptance line. Parens now group without a
  node, exactly as `(int)` yields `int` in the type grammar.
- `AssignOp` had no `&&=` / `||=`, which the spec's level-18 row lists and the
  lexer already emits. Added. `IntLit` widened to `u128` to match the scanner's
  payload — narrowing would have refused legal `s128`/`u128` literals.

Also in `docs/prd/language-grammar.md`: `postfix` made `f()` underivable, but
`null-coalescing.cat` writes `obj?.DoMethod()`. Now `"(" [ call-args ] ")"`.

Ranges and `is` are non-repeating per the grammar's `[ ... ]`, so `a..b..c` and
`x is A is B` are refused at the second operator; parenthesized, `(a..b)..c`
still parses. `array_size` is now shared by `[N]T` and `new [N]T` so the two
cannot disagree about the 64-bit limit.

Deferred to step 50, as the step doc says: `func-value` lambdas, and `this` as
a primary (a reserved keyword with no AST node and no fixture — its only
occurrences are inside `${...}` interpolation, which the lexer keeps verbatim).

14 new unit tests: one per boundary between adjacent precedence levels, both
associativity directions, the `-x as u16` case that tells the two readings of
`as` apart, postfix chains, call-args vs tuples, and span coverage. Verified
with cargo fmt --check, cargo clippy --all --all-targets -- -D warnings, and
cargo test --all (56 tests).
crowmaki deleted branch parser-step-30-expressions 2026-08-22 03:02:39 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crowmaki/catlang!8
No description provided.