import { describe, it, expect } from 'vitest'; import { parseFormula, completeFormula, parseFunctionBlock, parseMacroBlock, collectRefs, collectRefsInBlock, ParseError, type Node, type Statement, type BlockNode, } 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 'errorliteral': return n.code; 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.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`; case 'range': return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`; case 'param': return `param(${n.name})`; case 'namedref': return `namedref(${n.name})`; case 'blockcall': // never appears in freshly-parsed input (see parser.ts's comment on // BlockCallNode) -- exhaustiveness only, no test exercises this. return `blockcall(${n.label})`; case 'omitted': return 'omitted'; case 'json': // never produced by the parser (see parser.ts's comment on // JsonLiteralNode) -- exhaustiveness only, no test exercises this. return `json(${JSON.stringify(n.value)})`; } } const parse = (s: string) => sexpr(parseFormula(s)); const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames)); /** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */ function sexprStmt(s: Statement): string { switch (s.kind) { case 'assign': return `${s.name}=${sexpr(s.value)}`; case 'return': return `RETURN ${sexpr(s.value)}`; case 'for': return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`; case 'ifstmt': return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`; case 'action': return `ACTION(${s.name};${s.args.map(sexpr).join(';')})`; } } function sexprBlock(b: BlockNode): string { return `{${b.statements.map(sexprStmt).join('; ')}}`; } const parseBlock = (s: string, paramNames: readonly string[]) => sexprBlock(parseFunctionBlock(s, paramNames)); 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))', ); }); it('& binds looser than + - but tighter than a comparison', () => { expect(parse('="a"&1+2')).toBe('(& "a" (+ 1 2))'); expect(parse('=A1&B1="ab"')).toBe('(= (& cell(0,0) cell(1,0)) "ab")'); }); it('& is left-associative', () => { expect(parse('="a"&"b"&"c"')).toBe('(& (& "a" "b") "c")'); }); }); 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); }); it('an empty argument slot parses as "omitted" -- leading, middle, trailing', () => { expect(parse('=XLOOKUP(1;A1;B1;;-1)')).toBe( '(XLOOKUP 1 cell(0,0) cell(1,0) omitted (- 1))', ); expect(parse('=SUM(;5)')).toBe('(SUM omitted 5)'); expect(parse('=SUM(5;)')).toBe('(SUM 5 omitted)'); expect(parse('=SUM()')).toBe('(SUM )'); }); }); describe('parser whole-column / whole-row references', () => { const range = (src: string) => parseFormula(src) as Extract; it('B:B, A:C and $B:$B are whole columns', () => { expect(range('B:B')).toMatchObject({ kind: 'range', whole: 'col', qualifier: null, start: { col: 1, row: 0 } }); expect(range('A:C')).toMatchObject({ whole: 'col', start: { col: 0 }, end: { col: 2 } }); expect(range('$B:$B')).toMatchObject({ whole: 'col', start: { col: 1 }, end: { col: 1 } }); expect(range('aa:ab')).toMatchObject({ whole: 'col', start: { col: 26 }, end: { col: 27 } }); }); it('1:1, 2:5 and $3:$3 are whole rows (1-based in the text)', () => { expect(range('1:1')).toMatchObject({ kind: 'range', whole: 'row', start: { col: 0, row: 0 }, end: { row: 0 } }); expect(range('2:5')).toMatchObject({ whole: 'row', start: { row: 1 }, end: { row: 4 } }); expect(range('$3:$3')).toMatchObject({ whole: 'row', start: { row: 2 }, end: { row: 2 } }); }); it('take table and sheet qualifiers', () => { expect(range('Umsatz!B:B')).toMatchObject({ qualifier: 'Umsatz', whole: 'col' }); expect(range('Umsatz::1:1')).toMatchObject({ qualifier: 'Umsatz', whole: 'row' }); expect(range("'Blatt 2'!Kosten::A:C")).toMatchObject({ sheetQualifier: 'Blatt 2', qualifier: 'Kosten', whole: 'col' }); }); it('work as function arguments and are collected as refs', () => { const ast = parseFormula('SUM(B:B; 2:3) + 1'); expect(collectRefs(ast).map((r) => (r.kind === 'range' ? r.whole : r.kind))).toEqual(['col', 'row']); }); it('reject mixed or invalid shapes', () => { expect(() => parseFormula('A:1')).toThrow(); expect(() => parseFormula('0:0')).toThrow(); // a plain number still is one expect(parseFormula('12')).toEqual({ kind: 'number', value: 12 }); }); }); describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => { it('parses the explicit 3-part form, bare and quoted', () => { expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)'); expect(parse("='Meine Analyse'!Umsatz::B2")).toBe('Meine Analyse!Umsatz!cell(1,1)'); expect(parse("=Analyse!'Kosten Q3'::B2:E2")).toBe('Analyse!Kosten Q3!range(1,1:4,1)'); expect(parse("='Meine Analyse'!'Kosten Q3'::B2")).toBe('Meine Analyse!Kosten Q3!cell(1,1)'); }); it('is unaffected in a range end / function call', () => { expect(parse('=SUM(Analyse!Umsatz::B2:E2; 1)')).toBe('(SUM Analyse!Umsatz!range(1,1:4,1) 1)'); }); it('a bare "X!Y" (no further "::") keeps its original, table-only meaning -- fully backward-compatible', () => { // no "::" ever follows -> "Analyse" is read as the TABLE, "A1" the cell, // exactly as parseFormula() behaved before the sheet qualifier existed. expect(parse('=Analyse!A1')).toBe('Analyse!cell(0,0)'); expect(parse('=Umsatz!B2')).toBe(parse('=Umsatz!B2')); // unchanged from the existing suite }); it('"X::Y" (no leading sheet) is still the plain table qualifier, never a sheet one', () => { expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)'); }); it('"X!Y!Z" (a second "!" instead of "::") does not form a sheet qualifier -- "!" only introduces a sheet name when followed by NAME "::"', () => { // no "::" anywhere ahead of "Y" -> "X!" is read as an ordinary (old-style) // table qualifier, leaving "Y" to be parsed as the cell -- which it isn't // shaped like (no digits) -> ParseError, same as e.g. "=X!Y" always was. expect(() => parseFormula('=X!Y!Z1')).toThrow(ParseError); }); it('rejects a malformed 3-part reference (missing cell after "::")', () => { expect(() => parseFormula('=Analyse!Umsatz::')).toThrow(ParseError); }); }); describe('parseFormula(src, paramNames) -- user-defined function bodies', () => { it('a name in paramNames parses as a param reference, case-insensitively', () => { expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))'); expect(parse2('=REVENUE * 2', ['revenue'])).toBe('(* param(REVENUE) 2)'); }); it('a bare name not in paramNames still behaves exactly as without the list', () => { expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError); }); it('without paramNames, a bare non-cell identifier is a named-range reference, not a parse error', () => { // same string, no param list -- "revenue" doesn't look like a cell, but // this is now an ordinary top-level formula, where that's a named-range // candidate (see the "named ranges" describe block below) rather than a // ParseError -- unlike inside a function body (the case just above), // where the fallback is deliberately off. expect(parse('=revenue')).toBe('namedref(revenue)'); }); it('a param can mix with real cell references in the same formula', () => { expect(parse2('=x + A1', ['x'])).toBe('(+ param(x) cell(0,0))'); }); it('a param is never qualified or part of a range -- those still need a real cell', () => { expect(() => parseFormula('=Umsatz!x', ['x'])).toThrow(ParseError); expect(() => parseFormula('=x:B2', ['x'])).toThrow(ParseError); }); }); 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); }); }); describe('parser named-range references (NamedRefNode)', () => { it('a bare, unqualified, non-cell identifier parses as a namedref', () => { expect(parse('=foo + 1')).toBe('(+ namedref(foo) 1)'); expect(parse('=Umsatz_Q1')).toBe('namedref(Umsatz_Q1)'); }); it('mixes with real cell/table references and functions', () => { expect(parse('=SUM(Umsatz_Q1; A1; Umsatz!B2)')).toBe( '(SUM namedref(Umsatz_Q1) cell(0,0) Umsatz!cell(1,1))', ); }); it('a qualified non-cell identifier is still a ParseError -- a named range is always unqualified', () => { expect(() => parseFormula('=Umsatz!foo')).toThrow(ParseError); expect(() => parseFormula("='Kosten Q3'!foo")).toThrow(ParseError); }); it('never a range-start: "Name:B2" is not supported', () => { expect(() => parseFormula('=foo:B2')).toThrow(ParseError); }); it('inside a function body, a bare non-cell/non-param name is still a hard ParseError (no named-range fallback there)', () => { expect(() => parseFormula('=foo', ['bar'])).toThrow(ParseError); expect(() => parseFunctionBlock('RETURN foo', ['bar'])).toThrow(ParseError); }); }); describe('parser error literals (ErrorLiteralNode)', () => { it('every error code parses as a leaf', () => { expect(parse('=#REF')).toBe('#REF'); expect(parse('=#DIV/0!')).toBe('#DIV/0!'); expect(parse('=#NAME?')).toBe('#NAME?'); expect(parse('=#ERR')).toBe('#ERR'); expect(parse('=#CIRC')).toBe('#CIRC'); expect(parse('=#VALUE!')).toBe('#VALUE!'); expect(parse('=#N/A')).toBe('#N/A'); }); it('composes with operators/functions like any other literal', () => { expect(parse('=1+#REF')).toBe('(+ 1 #REF)'); expect(parse('=IF(1=0;#N/A;5)')).toBe('(IF (= 1 0) #N/A 5)'); }); }); describe('parser comparison operators (available in every formula, not just block IF conditions)', () => { it('parses all six comparison operators', () => { expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))'); expect(parse('=A1<>B1')).toBe('(<> cell(0,0) cell(1,0))'); expect(parse('=A1>B1')).toBe('(> cell(0,0) cell(1,0))'); expect(parse('=A1=B1')).toBe('(>= cell(0,0) cell(1,0))'); expect(parse('=A1<=B1')).toBe('(<= cell(0,0) cell(1,0))'); }); it('binds looser than + -', () => { expect(parse('=A1+1=B1')).toBe('(= (+ cell(0,0) 1) cell(1,0))'); }); it('is not chainable -- "a < b < c" is a parse error', () => { expect(() => parseFormula('=1<2<3')).toThrow(ParseError); }); }); describe('parseFunctionBlock -- statement grammar for "block" function bodies', () => { it('a single FOR loop, accumulator pattern (DO ... END, one statement)', () => { const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis'; expect(parseBlock(src, ['bereich'])).toBe( '{ergebnis=1; FOR(x IN param(bereich) DO {ergebnis=(* param(ergebnis) param(x))}); RETURN param(ergebnis)}', ); }); it('a FOR body with multiple statements', () => { const src = 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total'; expect(parseBlock(src, ['bereich'])).toBe( '{total=0; FOR(x IN param(bereich) DO {y=(* param(x) 2); total=(+ param(total) param(y))}); RETURN param(total)}', ); }); it('multiple independent FOR loops in sequence, sharing an accumulator', () => { const src = 'total = 0 FOR a IN r1 DO total = total + a END FOR b IN r2 DO total = total + b END RETURN total'; expect(parseBlock(src, ['r1', 'r2'])).toBe( '{total=0; FOR(a IN param(r1) DO {total=(+ param(total) param(a))}); ' + 'FOR(b IN param(r2) DO {total=(+ param(total) param(b))}); RETURN param(total)}', ); }); it('IF ... THEN ... END, no ELSE', () => { const src = 'x = 1 IF x > 0 THEN x = x + 1 END RETURN x'; expect(parseBlock(src, [])).toBe('{x=1; IF((> param(x) 0) THEN {x=(+ param(x) 1)}); RETURN param(x)}'); }); it('IF ... THEN ... ELSE ... END', () => { const src = 'IF x > 0 THEN y = 1 ELSE y = -1 END RETURN y'; expect(parseBlock(src, ['x'])).toBe( '{IF((> param(x) 0) THEN {y=1} ELSE {y=(- 1)}); RETURN param(y)}', ); }); it('IF nested inside a FOR body, with an early RETURN -- "if kann in jedem block vorkommen"', () => { const src = 'total = 0 FOR x IN bereich DO IF x > 100 THEN RETURN x END total = total + x END RETURN total'; expect(parseBlock(src, ['bereich'])).toBe( '{total=0; FOR(x IN param(bereich) DO {IF((> param(x) 100) THEN {RETURN param(x)}); ' + 'total=(+ param(total) param(x))}); RETURN param(total)}', ); }); it('a FOR loop nested inside another FOR loop\'s body', () => { const src = 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total'; expect(parseBlock(src, ['r1', 'r2'])).toBe( '{total=0; FOR(a IN param(r1) DO {FOR(b IN param(r2) DO ' + '{total=(+ param(total) (* param(a) param(b)))})}); RETURN param(total)}', ); }); it('RETURN may appear anywhere, not just at the end, and may be any expression', () => { expect(parseBlock('RETURN 42', [])).toBe('{RETURN 42}'); expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5; RETURN (* param(x) 2)}'); }); it('a plain assignment can reference an already-declared variable', () => { expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe('{a=1; b=(+ param(a) 1); RETURN param(b)}'); }); it('the FOR source may itself be a cell/range reference, not just a parameter', () => { expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x END RETURN total', [])).toBe( '{total=0; FOR(x IN range(0,0:0,4) DO {total=(+ param(total) param(x))}); RETURN param(total)}', ); }); it('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => { expect(parseBlock('x = 1', [])).toBe('{x=1}'); }); it('throws on FOR without a matching END', () => { expect(() => parseFunctionBlock('FOR x IN bereich DO y = x RETURN y', ['bereich'])).toThrow( ParseError, ); }); it('throws on FOR without a matching DO', () => { expect(() => parseFunctionBlock('FOR x IN bereich RETURN x', ['bereich'])).toThrow(ParseError); }); it('throws on FOR without IN', () => { expect(() => parseFunctionBlock('FOR x bereich DO y = x END RETURN y', ['bereich'])).toThrow( ParseError, ); }); it('throws on IF without THEN', () => { expect(() => parseFunctionBlock('IF x > 0 x = 1 END RETURN x', ['x'])).toThrow(ParseError); }); it('throws on IF without a matching END', () => { expect(() => parseFunctionBlock('IF x > 0 THEN x = 1 RETURN x', ['x'])).toThrow(ParseError); }); it('throws when an assignment is missing "="', () => { expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError); }); it('a name is only usable after its own declaring statement (self-reference in its own initializer fails)', () => { expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError); }); }); describe('parseMacroBlock -- write-action statements (ActionNode), macro-only', () => { const parseMacro = (s: string) => sexprBlock(parseMacroBlock(s)); it('a bare NAME(args) at statement position parses as an action call', () => { expect(parseMacro('SETVALUE(Umsatz::A1; 42)')).toBe( '{ACTION(SETVALUE;Umsatz!cell(0,0);42)}', ); }); it('an action with no arguments', () => { expect(parseMacro('ADDSHEET("Neu")')).toBe('{ACTION(ADDSHEET;"Neu")}'); }); it('";"-separated arguments, including an omitted middle slot', () => { expect(parseMacro('SETVALUE(Umsatz::A1;;)')).toBe('{ACTION(SETVALUE;Umsatz!cell(0,0);omitted;omitted)}'); }); it('an action call composes with local variables and & concatenation', () => { expect(parseMacro('n = "Blatt" ADDSHEET("Neu " & n)')).toBe( '{n="Blatt"; ACTION(ADDSHEET;(& "Neu " param(n)))}', ); }); it('an action call can appear inside FOR/IF bodies', () => { expect( parseMacro('FOR c IN Umsatz::A1:A3 DO SETVALUE(c; 0) END'), ).toBe('{FOR(c IN Umsatz!range(0,0:0,2) DO {ACTION(SETVALUE;param(c);0)})}'); }); it('assignment (IDENT "=" expr) still wins over an action call -- disambiguated by the token after the name', () => { expect(parseMacro('x = 1 RETURN x')).toBe('{x=1; RETURN param(x)}'); }); it('a bare NAME(args) at statement position is only recognised in a macro body, never a plain function body', () => { // Same source: a function body has no `allowWrites`, so "SETVALUE(...)" // at statement start falls through to parseAssignment(), which then // fails immediately for lack of "=". expect(() => parseFunctionBlock('SETVALUE(A1; 1) RETURN 1', [])).toThrow(ParseError); }); it('rejects an action call missing its closing ")"', () => { expect(() => parseMacroBlock('SETVALUE(Umsatz::A1; 1')).toThrow(ParseError); }); }); describe('collectRefsInBlock -- reference walk used by the macro save-time qualifier check', () => { it('collects a SETVALUE action\'s reference arguments too', () => { const block = parseMacroBlock('SETVALUE(Umsatz::A1; Kosten::B2)'); const refs = collectRefsInBlock(block); expect(refs.map((r) => r.kind)).toEqual(['cell', 'cell']); expect(refs.map((r) => (r as { qualifier: string | null }).qualifier)).toEqual(['Umsatz', 'Kosten']); }); it('collects refs from every action argument, not just the first', () => { const block = parseMacroBlock('INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A1; Kosten::B2 + 1)'); const refs = collectRefsInBlock(block); // INSERTROW's args are plain strings/numbers -- no refs from that // statement; SETVALUE contributes both its target and its value's ref. expect(refs).toHaveLength(2); }); }); describe('completeFormula -- closing what Enter left open', () => { it('appends missing closing parentheses', () => { expect(completeFormula('=SUMME(A1:A5')).toBe('=SUMME(A1:A5)'); expect(completeFormula('=RUNDEN(SUMME(A1;B1)*(2+3')).toBe('=RUNDEN(SUMME(A1;B1)*(2+3))'); expect(completeFormula('=SUMME(A1 ')).toBe('=SUMME(A1 )'); }); it('closes an open text literal before the parentheses', () => { expect(completeFormula('=WENN(A1>0;"ja')).toBe('=WENN(A1>0;"ja")'); }); it('leaves valid formulas, plain text and unfixable input alone', () => { expect(completeFormula('=SUMME(A1:A5)')).toBe('=SUMME(A1:A5)'); expect(completeFormula('Hallo (Welt')).toBe('Hallo (Welt'); expect(completeFormula('=SUMME(A1+')).toBe('=SUMME(A1+'); expect(completeFormula('=A1)')).toBe('=A1)'); expect(completeFormula('=(')).toBe('=('); }); });