◐ Off-By-One · answer catalog

js-precedence-climbing-associativity

1 answer(s)jsnode20

js-precedence-climbing-associativity

📦 Source in repository (JSON)

Answer

The bug lives in the climb loop's recursion threshold. For a binary operator of power p, the RHS must be parsed at:

The buggy loop recursed with the wrong threshold (a single >=-style rule, i.e. p for every operator / inverted associativity), so - became right-associative and ** became left-associative; and unary minus was given a power above **, so -2**2 became (-2)**2 = 4 instead of -(2**2) = -4.

Correct binding powers per operator:

const PREC        = { '+': 10, '-': 10, '*': 20, '/': 20, '**': 40 }; // higher binds tighter
const UNARY_PREC  = 30;   // looser than ** (40), tighter than * / (20)
const RIGHT_ASSOC = new Set(['**']);

The fixed climb loop (precedence-climbing.js):

expression(minPrec) {
  let lhs = this.prefix();                       // handles unary minus at UNARY_PREC
  while (true) {
    const tok = this.peek();
    if (!tok || tok.type !== 'op' || !(tok.value in PREC)) break;
    const prec = PREC[tok.value];
    if (prec < minPrec) break;                   // climb while prec >= minPrec
    this.next();
    // THE FIX: left-assoc -> p+1 (strictly greater ">"), right-assoc -> p (">=")
    const nextMin = RIGHT_ASSOC.has(tok.value) ? prec : prec + 1;
    const rhs = this.expression(nextMin);
    lhs = { type: 'binary', op: tok.value, lhs, rhs };
  }
  return lhs;
}

prefix() {
  if (peek() is '-') {
    next();
    const operand = this.expression(UNARY_PREC); // absorbs **, not * / — correct unary power
    return { type: 'unary', op: '-', operand };
  }
  return this.primary();
}

Evidence & signatures

Wrote `~/precedence-climbing.js` (tokenizer, fixed parser, tree printer, evaluator) and `~/test.js` (a buggy "before" variant plus 90 assertions). Ran `node test.js` → **90 passed, 0 failed**.

Before/after for the reported failures:

| expression | buggy (before) | fixed (after) |
|---|---|---|
| `8-3-2` | `(8 - (3 - 2))` = **7** ✗ | `((8 - 3) - 2)` = **3** ✓ |
| `2**3**2` | `((2 ** 3) ** 2)` = **64** ✗ | `(2 ** (3 ** 2))` = **512** ✓ |
| `-2**2` | `((-2) ** 2)` = **4** ✗ | `(-(2 ** 2))` = **-4** ✓ |
| `2**-3**2` | `((2 ** (-3)) ** 2)` = 0.015625 ✗ | `(2 ** (-(3 ** 2)))` = 0.001953125 ✓ |

Edge cases tested (tree shape *and* value, plus a JS cross-check — every printed tree is fully parenthesized, so evaluating it with `Function(...)` must agree):
- **Left-assoc chains**: `10-4-3-2` → `(((10-4)-3)-2)` = 1; `100/5/2` → 10; `8/4*2` → 4; `5-2-1` → 2; `1-2+3` → 2.
- **Right-assoc chains**: `2**2**3` → 256; `2**3**2**2` → `2**(3**(2**2))` = 2⁸¹.
- **Mixed precedence**: `2*3+4*5` → 26; `10/2+3*4` → 17; `2**3*4` → 32; `4*2**3` → 32.
- **Unary minus**: `2**-3` → 0.125; `-2*3` → `((-2)*3)` = -6; `2*-3` → `(2*(-3))` = -6; `--3` → 3; `--2**2` → 4; `-3-2` → -5; `3--2` → 5; `(-2)**2` → 4 (parentheses still win).
- **Malformed input rejected**: `""`, `2+`, `(2`, `2 3`, `2(3)`, `()` all throw.
{"model": "deepseek-v4-flash", "problem_class": "js-precedence-climbing-associativity", "result": "passed", "tests": 90}
Generated from the verified corpus · MIT licensedBack to the catalog