The engine already anticipated $A1/A$1/$A$1 in its grammar comment, but the
lexer had no rule for '$' at all -- any formula using it failed to parse.
- lexer.ts: '$' is now a valid word character, so "$A$1" tokenizes as one
IDENT, same as any other bare word (the lexer still doesn't interpret it,
per its "dumb lexer" design -- the parser/evaluator stay untouched, since
evaluation is purely positional and $ has no effect on a value).
- refs.ts: parseA1()/toA1() now carry colAbs/rowAbs through the $ markers
(backward compatible -- existing {col,row} destructuring is unaffected).
Added cycleReferenceLock() for Excel/Numbers-style F4: relative ->
absolute -> row-locked -> col-locked -> relative, toggling whichever
reference sits at the caret (and the correct side of an A1:B2 range).
- document.ts: shiftFormula() now honors the lock on copy (a locked axis
stays exactly as typed) but not on cut (a moved cell's reference follows
it regardless of $, since cut relocates the cell itself rather than
filling a relative offset).
- TableGrid.vue's inline cell editor and FormulaBar.vue both wire up F4 via
the shared cycleReferenceLock() helper.
- Tests: lexer/parser coverage for $ tokenizing/parsing, a new refs.spec.ts
for parseA1/toA1 round-tripping and the F4 cycle (incl. range side-
picking), and document.spec.ts cases for locked-axis copy vs. cut.
- README.md / src/engine/README.md: documented the $ semantics, F4, and
corrected a couple of now-stale claims (test count, "$ is ignored, there's
no copy/fill", "active cell is always the last-clicked one").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
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);
|
|
});
|
|
});
|