Skip to content

Commit de548ae

Browse files
committed
Add cross-sheet reference and range support
1 parent ee7be4b commit de548ae

File tree

5 files changed

+190
-17
lines changed

5 files changed

+190
-17
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ Formulas begin with an equals sign (`=`), and can contain:
6565
`R-1C-1` to select the cell in the bottom right corner of the sheet, and
6666
`R1C0:R1C-1` to select all of row 1
6767
- Ranges such as `R[-3]C:R[-1]C`
68+
- References and ranges across sheets like `S1!R1C1` and `S[1]!R2C2:R2C-1`
6869
- Function calls (case insensitive) containing expressions as arguments such as
6970
`sum(RC0:RC[-1])`, `sLiDeR(0, 10, 1)`, and `DOLLARS(PRODUCT(1 * 2 + 3, 4, 3,
7071
R[-1]C))`

src/classes.svelte.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,12 @@ export class Sheet {
330330

331331
try {
332332
const parsed = formula.parse(cell.formula);
333-
const computed = parsed.compute(this.cells, row, col);
333+
const computed = parsed.compute(
334+
this.globals,
335+
this.globals.sheets.indexOf(this),
336+
row,
337+
col,
338+
);
334339
cell.value.rederive(
335340
flattenArgs(computed),
336341
(dependencyValues, set, update) => {
@@ -387,8 +392,10 @@ export class Sheet {
387392
} catch (e) {
388393
if (!(e instanceof ParseError)) {
389394
cell.errorText = `Error: ${e.message}`;
395+
cell.value.rederive([], (_, set) => set(undefined));
396+
} else {
397+
cell.value.rederive([], (_, set) => set(cell.formula));
390398
}
391-
cell.value.rederive([], (_, set) => set(cell.formula));
392399
}
393400
});
394401
});

src/formula.js

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class Expression {
2121
// Return a concrete value from an expression given the values in the other
2222
// rows and columns.
2323
/* v8 ignore next 3 */
24-
compute(rows, r, c) {
24+
compute(globals, sheet, r, c) {
2525
throw new Error("Not yet implemented");
2626
}
2727
}
@@ -60,13 +60,13 @@ class Function extends Expression {
6060
this.args = Array.from(args);
6161
}
6262

63-
compute(rows, r, c) {
63+
compute(globals, sheet, r, c) {
6464
const name = this.name.toLocaleLowerCase();
6565
const f = functions[name];
6666
if (f == null) {
6767
throw new Error(`"${name}" is not a function`);
6868
}
69-
const refs = this.args.map((a) => a.compute(rows, r, c));
69+
const refs = this.args.map((a) => a.compute(globals, sheet, r, c));
7070
return new ExpressionValue(singleton(f), refs);
7171
}
7272
}
@@ -107,7 +107,7 @@ class BinaryOperation extends Expression {
107107
this.ast = Array.from(ast);
108108
}
109109

110-
compute(rows, r, c) {
110+
compute(globals, sheet, r, c) {
111111
const thunk = (...args) => {
112112
this.ast
113113
.filter((x) => typeof x === "string")
@@ -121,7 +121,7 @@ class BinaryOperation extends Expression {
121121
};
122122
const refs = this.ast
123123
.filter((x) => x?.compute)
124-
.map((x) => x.compute(rows, r, c));
124+
.map((x) => x.compute(globals, sheet, r, c));
125125
return new ExpressionValue(undefinedArgsToIdentity(thunk), refs);
126126
}
127127
}
@@ -141,24 +141,41 @@ class UnaryOperation extends Expression {
141141
this.operand = operand;
142142
}
143143

144-
compute(rows, r, c) {
144+
compute(globals, sheet, r, c) {
145145
const thunk = (x) => [this.operator(x)];
146-
const refs = [this.operand.compute(rows, r, c)];
146+
const refs = [this.operand.compute(globals, sheet, r, c)];
147147
return new ExpressionValue(undefinedArgsToIdentity(thunk), refs);
148148
}
149149
}
150150

151151
class Ref extends Expression {
152+
s;
152153
r;
153154
c;
154155

155-
constructor(r, c) {
156+
constructor(s, r, c) {
156157
super();
158+
this.s = s;
157159
this.r = r;
158160
this.c = c;
159161
}
160162

161-
compute(rows, r, c) {
163+
compute(globals, s, r, c) {
164+
let sheet;
165+
if (this.s == null) {
166+
sheet = s;
167+
} else if (this.s.relative == null) {
168+
if (this.s.absolute < 0) {
169+
sheet =
170+
(this.s.absolute + globals.sheets.length) % globals.sheets.length;
171+
} else {
172+
sheet = this.s.absolute;
173+
}
174+
} else {
175+
sheet = s + this.s.relative;
176+
}
177+
const rows = globals.sheets[sheet].cells;
178+
162179
let row;
163180
if (this.r == null) {
164181
row = r;
@@ -188,20 +205,37 @@ class Ref extends Expression {
188205
}
189206

190207
class Range extends Expression {
208+
s;
191209
r1;
192210
c1;
193211
r2;
194212
c2;
195213

196-
constructor(r1, c1, r2, c2) {
214+
constructor(s, r1, c1, r2, c2) {
197215
super();
216+
this.s = s;
198217
this.r1 = r1;
199218
this.c1 = c1;
200219
this.r2 = r2;
201220
this.c2 = c2;
202221
}
203222

204-
compute(rows, r, c) {
223+
compute(globals, s, r, c) {
224+
let sheet;
225+
if (this.s == null) {
226+
sheet = s;
227+
} else if (this.s.relative == null) {
228+
if (this.s.absolute < 0) {
229+
sheet =
230+
(this.s.absolute + globals.sheets.length) % globals.sheets.length;
231+
} else {
232+
sheet = this.s.absolute;
233+
}
234+
} else {
235+
sheet = s + this.s.relative;
236+
}
237+
const rows = globals.sheets[sheet].cells;
238+
205239
let startRow;
206240
if (this.r1 == null) {
207241
startRow = r;
@@ -330,12 +364,16 @@ const absNum = cellDigits.map((n) => ({ absolute: n }));
330364
const cellNum = relNum.or(absNum).optional();
331365
const r = regex(/[rR]/);
332366
const c = regex(/[cC]/);
367+
const s = regex(/[sS]/);
333368

334-
const ref = seq(r.then(cellNum), c.then(cellNum)).map(
335-
(args) => new Ref(...args),
336-
);
369+
const ref = seq(
370+
s.then(cellNum).skip(str("!")).optional(),
371+
r.then(cellNum),
372+
c.then(cellNum),
373+
).map((args) => new Ref(...args));
337374

338375
const range = seq(
376+
s.then(cellNum).skip(str("!")).optional(),
339377
r.then(cellNum),
340378
c.then(cellNum).skip(lex(":")),
341379
r.then(cellNum),

test/sheet-and-formula.test.js

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { State } from "../src/classes.svelte.js";
1+
import { Sheet, State } from "../src/classes.svelte.js";
22
import { test, expect, beforeEach } from "vitest";
33
import { evalCode, functions } from "../src/formula-functions.svelte.js";
44

@@ -401,3 +401,129 @@ test("Formula this object has everything it's supposed to", async () => {
401401
const state = createSheet([cases]);
402402
await expectSheet(state.currentSheet, [cases.map(() => true)]);
403403
});
404+
405+
test("Cross-sheet formula references", async () => {
406+
const state = createSheet([
407+
[
408+
"10",
409+
"=S0!RC[-1]",
410+
"=S[1]!R0C1",
411+
"=S1!R2C2",
412+
"=S[1]!R[99]C99",
413+
"=S[-1]!R1C2",
414+
],
415+
]);
416+
await expectSheet(state.sheets[0], [
417+
[10, 10, undefined, undefined, undefined, undefined],
418+
]);
419+
420+
state.sheets.push(
421+
new Sheet("Sheet 2", 3, 10, (i, j) => `${i * j}`, undefined, state),
422+
);
423+
await expectSheet(
424+
state.sheets[1],
425+
new Array(3)
426+
.fill()
427+
.map((_, i) => new Array(10).fill().map((_, j) => i * j)),
428+
);
429+
await expectSheet(state.sheets[0], [[10, 10, 0, 4, undefined, undefined]]);
430+
431+
state.sheets.unshift(
432+
new Sheet(
433+
"Sheet 0",
434+
4,
435+
4,
436+
(i, j) => `=(${i} + 1) / (${j} + 1)`,
437+
undefined,
438+
state,
439+
),
440+
);
441+
await expectSheet(
442+
state.sheets[0],
443+
new Array(4)
444+
.fill()
445+
.map((_, i) => new Array(4).fill().map((_, j) => (i + 1) / (j + 1))),
446+
);
447+
await expectSheet(state.sheets[1], [[10, 1, 0, undefined, undefined, 2 / 3]]);
448+
449+
state.sheets[0].cells[0][0].formula = "=S-1!R2C5";
450+
await expectSheet(
451+
state.sheets[0],
452+
new Array(4)
453+
.fill()
454+
.map((_, i) =>
455+
new Array(4)
456+
.fill()
457+
.map((_, j) => (i == 0 && j == 0 ? 10 : (i + 1) / (j + 1))),
458+
),
459+
);
460+
await expectSheet(state.sheets[1], [
461+
[10, 10, 0, undefined, undefined, 2 / 3],
462+
]);
463+
});
464+
465+
test("Cross-sheet formula ranges", async () => {
466+
const state = createSheet([
467+
["=s-1!R1C0:R1C-1", "=s0!R1C0:R1C-1", "=s!R1C0:R1C-1"],
468+
["1", "2", "3"],
469+
]);
470+
await expectSheet(state.sheets[0], [
471+
[
472+
[1, 2, 3],
473+
[1, 2, 3],
474+
[1, 2, 3],
475+
],
476+
[1, 2, 3],
477+
]);
478+
479+
state.sheets.unshift(
480+
new Sheet("Sheet 0", 4, 4, (i, j) => `=${i} + ${j}`, undefined, state),
481+
);
482+
await expectSheet(
483+
state.sheets[0],
484+
new Array(4).fill().map((_, i) => new Array(4).fill().map((_, j) => i + j)),
485+
);
486+
await expectSheet(state.sheets[1], [
487+
[
488+
[1, 2, 3],
489+
[1, 2, 3, 4],
490+
[1, 2, 3],
491+
],
492+
[1, 2, 3],
493+
]);
494+
495+
state.sheets.push(
496+
new Sheet(
497+
"Sheet 2",
498+
4,
499+
4,
500+
(i, j) => `=(${i} + 1) * ${j}`,
501+
undefined,
502+
state,
503+
),
504+
);
505+
await expectSheet(
506+
state.sheets[2],
507+
new Array(4)
508+
.fill()
509+
.map((_, i) => new Array(4).fill().map((_, j) => (i + 1) * j)),
510+
);
511+
await expectSheet(state.sheets[1], [
512+
[
513+
[0, 2, 4, 6],
514+
[1, 2, 3, 4],
515+
[1, 2, 3],
516+
],
517+
[1, 2, 3],
518+
]);
519+
520+
state.sheets[1].cells[0][0].formula = "=s[1]!R1C0:R1C-1";
521+
await expectSheet(state.sheets[1], [
522+
[
523+
[0, 2, 4, 6],
524+
[1, 2, 3, 4],
525+
[1, 2, 3],
526+
],
527+
[1, 2, 3],
528+
]);
529+
});

todo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
- Testing
8585
- Playwright
8686
- Integrate Playwright coverage with vitest coverage
87+
- Test parsers and classes in formulas
8788
- Documentation
8889
- README
8990
- Description and introduction

0 commit comments

Comments
 (0)