import { describe, it, expect } from 'vitest'; import { parseFormula, collectRefs, ParseError, type Node } from '../parser'; /** Compact s-expression rendering of an AST for precedence assertions. */ function sexpr(n: Node): string { switch (n.kind) { case 'number': return String(n.value); case 'string': return JSON.stringify(n.value); case 'unary': return `(${n.op} ${sexpr(n.operand)})`; case 'binary': return `(${n.op} ${sexpr(n.left)} ${sexpr(n.right)})`; case 'func': return `(${n.name} ${n.args.map(sexpr).join(' ')})`; case 'cell': return `${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`; case 'range': return `${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`; } } const parse = (s: string) => sexpr(parseFormula(s)); describe('parser precedence & associativity', () => { it('* / bind tighter than + -', () => { expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))'); expect(parse('=1-2/2')).toBe('(- 1 (/ 2 2))'); }); it('+ - are left-associative', () => { expect(parse('=1-2-3')).toBe('(- (- 1 2) 3)'); }); it('^ binds tighter than * / and is right-associative', () => { expect(parse('=2*3^2')).toBe('(* 2 (^ 3 2))'); expect(parse('=2^3^2')).toBe('(^ 2 (^ 3 2))'); }); it('unary minus binds weaker than ^ (-2^2 = -(2^2))', () => { expect(parse('=-2^2')).toBe('(- (^ 2 2))'); expect(parse('=2^-3')).toBe('(^ 2 (- 3))'); }); it('parentheses override precedence', () => { expect(parse('=(1+2)*3')).toBe('(* (+ 1 2) 3)'); expect(parse('=(B2+C2)*2-D2/4')).toBe( '(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))', ); }); }); describe('parser references', () => { it('parses local cells and ranges', () => { expect(parse('=A1')).toBe('cell(0,0)'); expect(parse('=A1:C5')).toBe('range(0,0:2,4)'); }); it('parses $-locked (absolute/mixed) references to the same coords -- evaluation is positional', () => { expect(parse('=$A$1')).toBe('cell(0,0)'); expect(parse('=A$1')).toBe('cell(0,0)'); expect(parse('=$A1')).toBe('cell(0,0)'); expect(parse('=$A$1:B$2')).toBe('range(0,0:1,1)'); }); it('parses table-qualified refs, bare and quoted', () => { expect(parse('=Umsatz!B2')).toBe('Umsatz!cell(1,1)'); expect(parse("='Kosten Q3'!B2:E2")).toBe('Kosten Q3!range(1,1:4,1)'); }); it('accepts the Numbers-style "::" qualifier interchangeably with "!"', () => { expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)'); expect(parse("='Kosten Q3'::B2:E2")).toBe('Kosten Q3!range(1,1:4,1)'); expect(parse('=Umsatz::B2')).toBe(parse('=Umsatz!B2')); }); it('parses function calls with ; separator and mixed args', () => { expect(parse('=SUM(Umsatz!B2:E2; 100; A1)')).toBe( '(SUM Umsatz!range(1,1:4,1) 100 cell(0,0))', ); }); it('collectRefs finds every reference', () => { const refs = collectRefs(parseFormula('=SUM(A1:A3; Kosten!B2) + C4')); expect(refs).toHaveLength(3); }); }); describe('parser errors', () => { it('throws ParseError on a dangling operator', () => { expect(() => parseFormula('=A1+')).toThrow(ParseError); }); it('throws on an unbalanced paren', () => { expect(() => parseFormula('=SUM(A1')).toThrow(ParseError); }); it('throws on a non-cell identifier used as a reference', () => { expect(() => parseFormula('=foo + 1')).toThrow(ParseError); }); });