Support $-locked references: relative, mixed and absolute
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>
This commit is contained in:
@@ -56,6 +56,11 @@ describe('lexer', () => {
|
||||
expect(tokenize('"a\\"b"')[0]).toMatchObject({ type: 'STRING', value: 'a"b' });
|
||||
});
|
||||
|
||||
it('reads $-locked cell references as one IDENT', () => {
|
||||
expect(types('$A$1 A$1 $A1')).toEqual(['IDENT', 'IDENT', 'IDENT', 'EOF']);
|
||||
expect(values('$A$1 A$1 $A1')).toEqual(['$A$1', 'A$1', '$A1', '']);
|
||||
});
|
||||
|
||||
it('skips whitespace', () => {
|
||||
expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']);
|
||||
});
|
||||
|
||||
@@ -56,6 +56,13 @@ describe('parser references', () => {
|
||||
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)');
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseA1, toA1, cycleReferenceLock } from '../refs';
|
||||
|
||||
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
|
||||
it('parseA1 reports which axes are $-locked', () => {
|
||||
expect(parseA1('A1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
|
||||
expect(parseA1('$A$1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: true });
|
||||
expect(parseA1('A$1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: true });
|
||||
expect(parseA1('$A1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: false });
|
||||
});
|
||||
|
||||
it('toA1 round-trips the lock markers', () => {
|
||||
expect(toA1(0, 0)).toBe('A1');
|
||||
expect(toA1(0, 0, true, true)).toBe('$A$1');
|
||||
expect(toA1(0, 0, false, true)).toBe('A$1');
|
||||
expect(toA1(0, 0, true, false)).toBe('$A1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycleReferenceLock (F4)', () => {
|
||||
it('cycles relative -> absolute -> row-locked -> col-locked -> relative', () => {
|
||||
let f = '=A1';
|
||||
let caret = 3;
|
||||
for (const expected of ['=$A$1', '=A$1', '=$A1', '=A1']) {
|
||||
const res = cycleReferenceLock(f, caret)!;
|
||||
expect(res.text).toBe(expected);
|
||||
f = res.text;
|
||||
caret = res.caret;
|
||||
}
|
||||
});
|
||||
|
||||
it('toggles only the reference the caret sits in/against', () => {
|
||||
const res = cycleReferenceLock('=A1+B2', 2)!; // caret inside "A1"
|
||||
expect(res.text).toBe('=$A$1+B2');
|
||||
const res2 = cycleReferenceLock('=A1+B2', 6)!; // caret inside "B2"
|
||||
expect(res2.text).toBe('=A1+$B$2');
|
||||
});
|
||||
|
||||
it('picks the right side of a range depending on the caret', () => {
|
||||
const left = cycleReferenceLock('=A1:B2', 2)!;
|
||||
expect(left.text).toBe('=$A$1:B2');
|
||||
const right = cycleReferenceLock('=A1:B2', 6)!;
|
||||
expect(right.text).toBe('=A1:$B$2');
|
||||
});
|
||||
|
||||
it('returns null when the caret is not on a reference, or the text is not a formula', () => {
|
||||
expect(cycleReferenceLock('=SUM(1;2)', 5)).toBeNull();
|
||||
expect(cycleReferenceLock('42', 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user