···11+/**
22+ * Cross-sheet reference resolution.
33+ *
44+ * Syntax: Sheet2!A1, Sheet2!A1:B5, 'Sheet Name'!A1
55+ * The `!` separator divides sheet name from cell reference.
66+ * Quoted names (single quotes) allow spaces and special characters.
77+ */
88+99+/**
1010+ * Parse a cross-sheet reference string.
1111+ * @param {string} ref - e.g. "Sheet2!A1", "'My Sheet'!A1:B5"
1212+ * @returns {{ sheetName: string, ref: string } | null}
1313+ */
1414+export function parseCrossSheetRef(ref) {
1515+ // Quoted sheet name: 'Sheet Name'!A1 or 'Sheet Name'!A1:B5
1616+ const quotedMatch = ref.match(/^'([^']+)'!(.+)$/);
1717+ if (quotedMatch) {
1818+ return { sheetName: quotedMatch[1], ref: quotedMatch[2] };
1919+ }
2020+2121+ // Unquoted sheet name: Sheet2!A1 or Sheet2!A1:B5
2222+ // Sheet name is anything before `!` that isn't just a cell ref
2323+ const unquotedMatch = ref.match(/^([A-Za-z_][A-Za-z0-9_]*)!(.+)$/);
2424+ if (unquotedMatch) {
2525+ return { sheetName: unquotedMatch[1], ref: unquotedMatch[2] };
2626+ }
2727+2828+ return null;
2929+}
3030+3131+/**
3232+ * Resolve a cross-sheet cell reference using a resolver.
3333+ * @param {string} sheetName - Target sheet name
3434+ * @param {string} cellRef - Cell reference (e.g. "A1")
3535+ * @param {object} resolver - Has getSheetCellValue(sheetName, cellRef), sheetExists(name)
3636+ * @returns {any} Cell value or '#REF!' if sheet doesn't exist
3737+ */
3838+export function resolveCrossSheetRef(sheetName, cellRef, resolver) {
3939+ if (!resolver.sheetExists(sheetName)) {
4040+ return '#REF!';
4141+ }
4242+ return resolver.getSheetCellValue(sheetName, cellRef);
4343+}
+74
src/sheets/formula-tracer.js
···11+/**
22+ * Formula tracer — visualize cell dependencies.
33+ *
44+ * Precedents: cells that a formula references (its inputs).
55+ * Dependents: cells that reference a given cell (its outputs).
66+ *
77+ * The tracer works on the formula text using extractRefs from formulas.js,
88+ * without evaluating formulas.
99+ */
1010+1111+import { extractRefs } from './formulas.js';
1212+1313+/**
1414+ * Trace the precedents (inputs) of a cell.
1515+ * Returns the set of cell IDs that the cell's formula directly references.
1616+ *
1717+ * @param {string} cellId - The cell to trace (e.g. "C1")
1818+ * @param {object} cellData - Map of cellId -> { v, f } where f is formula string
1919+ * @returns {Set<string>} Set of precedent cell IDs
2020+ */
2121+export function tracePrecedents(cellId, cellData) {
2222+ const cell = cellData[cellId];
2323+ if (!cell || !cell.f) return new Set();
2424+ return extractRefs(cell.f);
2525+}
2626+2727+/**
2828+ * Trace the dependents (outputs) of a cell.
2929+ * Returns the set of cell IDs whose formulas reference the given cell.
3030+ *
3131+ * @param {string} cellId - The cell to trace (e.g. "A1")
3232+ * @param {object} cellData - Map of cellId -> { v, f }
3333+ * @returns {Set<string>} Set of dependent cell IDs
3434+ */
3535+export function traceDependents(cellId, cellData) {
3636+ const dependents = new Set();
3737+ for (const [id, cell] of Object.entries(cellData)) {
3838+ if (!cell.f) continue;
3939+ const refs = extractRefs(cell.f);
4040+ if (refs.has(cellId)) {
4141+ dependents.add(id);
4242+ }
4343+ }
4444+ return dependents;
4545+}
4646+4747+/**
4848+ * Build a full dependency graph for all cells.
4949+ * Returns both precedent and dependent maps.
5050+ *
5151+ * @param {object} cellData - Map of cellId -> { v, f }
5252+ * @returns {{ precedents: Map<string, Set<string>>, dependents: Map<string, Set<string>> }}
5353+ */
5454+export function buildDependencyGraph(cellData) {
5555+ const precedents = new Map(); // cellId -> Set of cells it depends on
5656+ const dependents = new Map(); // cellId -> Set of cells that depend on it
5757+5858+ for (const [id, cell] of Object.entries(cellData)) {
5959+ if (!cell.f) continue;
6060+ const refs = extractRefs(cell.f);
6161+ if (refs.size === 0) continue;
6262+6363+ precedents.set(id, refs);
6464+6565+ for (const ref of refs) {
6666+ if (!dependents.has(ref)) {
6767+ dependents.set(ref, new Set());
6868+ }
6969+ dependents.get(ref).add(id);
7070+ }
7171+ }
7272+7373+ return { precedents, dependents };
7474+}
+159-11
src/sheets/formulas.js
···1111 STRING: 'STRING',
1212 BOOLEAN: 'BOOLEAN',
1313 CELL_REF: 'CELL_REF',
1414+ CROSS_SHEET_REF: 'CROSS_SHEET_REF', // e.g. Sheet2!A1
1415 RANGE: 'RANGE',
1516 FUNCTION: 'FUNCTION',
1717+ IDENTIFIER: 'IDENTIFIER', // named range or unknown identifier
1618 OPERATOR: 'OPERATOR',
1719 LPAREN: 'LPAREN',
1820 RPAREN: 'RPAREN',
1921 COMMA: 'COMMA',
2022 COLON: 'COLON',
2323+ BANG: 'BANG',
2124 EOF: 'EOF',
2225};
2326···3033 // Whitespace
3134 if (s[i] === ' ' || s[i] === '\t') { i++; continue; }
32353636+ // Quoted sheet name: 'Sheet Name'!A1
3737+ if (s[i] === "'") {
3838+ let sheetName = '';
3939+ i++; // skip opening quote
4040+ while (i < s.length && s[i] !== "'") {
4141+ sheetName += s[i++];
4242+ }
4343+ i++; // skip closing quote
4444+ // Expect ! after quoted sheet name
4545+ if (i < s.length && s[i] === '!') {
4646+ i++; // skip !
4747+ // Read the cell ref after !
4848+ let cellRef = '';
4949+ while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) {
5050+ cellRef += s[i++];
5151+ }
5252+ tokens.push({ type: TokenType.CROSS_SHEET_REF, value: { sheetName, ref: cellRef.toUpperCase().replace(/\$/g, '') } });
5353+ }
5454+ continue;
5555+ }
5656+3357 // String literal
3458 if (s[i] === '"') {
3559 let str = '';
···5680 continue;
5781 }
58825959- // Cell ref or function or boolean
8383+ // Cell ref, function, boolean, identifier, or cross-sheet ref (Name!A1)
6084 if (/[A-Za-z$_]/.test(s[i])) {
6185 let word = '';
6262- let start = i;
6363- // Allow $ for absolute refs
6464- while (i < s.length && /[A-Za-z0-9$_]/.test(s[i])) {
8686+ // Allow $ for absolute refs, . for named range names like sales.q1
8787+ while (i < s.length && /[A-Za-z0-9$_.]/.test(s[i])) {
6588 word += s[i++];
6689 }
6790···77100 continue;
78101 }
79102103103+ // Check for cross-sheet ref: word!cellRef (e.g. Sheet2!A1)
104104+ if (i < s.length && s[i] === '!') {
105105+ i++; // skip !
106106+ let cellRef = '';
107107+ while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) {
108108+ cellRef += s[i++];
109109+ }
110110+ tokens.push({ type: TokenType.CROSS_SHEET_REF, value: { sheetName: word, ref: cellRef.toUpperCase().replace(/\$/g, '') } });
111111+ continue;
112112+ }
113113+80114 // Check if next non-space char is '(' → function
81115 let peek = i;
82116 while (peek < s.length && s[peek] === ' ') peek++;
···92126 continue;
93127 }
941289595- // Unknown identifier — treat as error
9696- tokens.push({ type: TokenType.STRING, value: word });
129129+ // Unknown identifier — could be a named range
130130+ tokens.push({ type: TokenType.IDENTIFIER, value: word });
97131 continue;
98132 }
99133···125159 if (s[i] === ')') { tokens.push({ type: TokenType.RPAREN }); i++; continue; }
126160 if (s[i] === ',') { tokens.push({ type: TokenType.COMMA }); i++; continue; }
127161 if (s[i] === ':') { tokens.push({ type: TokenType.COLON }); i++; continue; }
162162+ if (s[i] === '!') { tokens.push({ type: TokenType.BANG }); i++; continue; }
128163129164 // Unknown character
130165 i++;
···136171137172// --- Parser (recursive descent) ---
138173class Parser {
139139- constructor(tokens, getCellValue) {
174174+ constructor(tokens, getCellValue, crossSheetResolver, namedRanges) {
140175 this.tokens = tokens;
141176 this.pos = 0;
142177 this.getCellValue = getCellValue;
178178+ this.crossSheetResolver = crossSheetResolver;
179179+ this.namedRanges = namedRanges || {};
143180 }
144181145182 peek() { return this.tokens[this.pos]; }
···234271 return this.primary();
235272 }
236273237237- // primary → NUMBER | STRING | BOOLEAN | CELL_REF (':' CELL_REF)? | FUNCTION '(' args ')' | '(' expression ')'
274274+ // primary → NUMBER | STRING | BOOLEAN | CELL_REF (':' CELL_REF)? | CROSS_SHEET_REF | IDENTIFIER | FUNCTION '(' args ')' | '(' expression ')'
238275 primary() {
239276 const t = this.peek();
240277···251288 if (t.type === TokenType.BOOLEAN) {
252289 this.advance();
253290 return t.value;
291291+ }
292292+293293+ // Cross-sheet reference: Sheet2!A1 or 'Sheet Name'!A1:B5
294294+ if (t.type === TokenType.CROSS_SHEET_REF) {
295295+ this.advance();
296296+ const { sheetName, ref } = t.value;
297297+ // Check for range in the ref (e.g. A1:B5 was captured as one string)
298298+ if (ref.includes(':')) {
299299+ return this.resolveCrossSheetRange(sheetName, ref);
300300+ }
301301+ return this.resolveCrossSheetCell(sheetName, ref);
254302 }
255303256304 if (t.type === TokenType.CELL_REF) {
···264312 return this.getCellValue(t.value);
265313 }
266314315315+ // Named range identifier (not a function, not a cell ref)
316316+ if (t.type === TokenType.IDENTIFIER) {
317317+ this.advance();
318318+ return this.resolveNamedRange(t.value);
319319+ }
320320+267321 if (t.type === TokenType.FUNCTION) {
268322 this.advance();
269323 this.expect(TokenType.LPAREN);
···289343 throw new Error(`Unexpected token: ${t.type}`);
290344 }
291345292292- // Function args can be ranges (CELL_REF:CELL_REF) or expressions
346346+ // Function args can be ranges (CELL_REF:CELL_REF), cross-sheet ranges, named ranges, or expressions
293347 parseFunctionArg() {
348348+ // Cross-sheet ref in function arg (range already parsed in tokenizer)
349349+ if (this.peek().type === TokenType.CROSS_SHEET_REF) {
350350+ const saved = this.pos;
351351+ const t = this.advance();
352352+ const { sheetName, ref } = t.value;
353353+ if (ref.includes(':')) {
354354+ return this.resolveCrossSheetRange(sheetName, ref);
355355+ }
356356+ // Single cross-sheet cell ref; could be part of an expression, backtrack
357357+ this.pos = saved;
358358+ return this.expression();
359359+ }
294360 // Peek ahead for range pattern: CELL_REF COLON CELL_REF
295361 if (this.peek().type === TokenType.CELL_REF) {
296362 const saved = this.pos;
···305371 // Not a range — backtrack and parse as expression
306372 this.pos = saved;
307373 }
374374+ // Named range identifier in function arg
375375+ if (this.peek().type === TokenType.IDENTIFIER) {
376376+ const saved = this.pos;
377377+ const t = this.advance();
378378+ const resolved = this.resolveNamedRange(t.value);
379379+ if (Array.isArray(resolved)) {
380380+ return resolved;
381381+ }
382382+ // Not a named range, backtrack
383383+ this.pos = saved;
384384+ }
308385 return this.expression();
309386 }
310387···326403 values._rangeRows = rowMax - rowMin + 1;
327404 values._rangeCols = colMax - colMin + 1;
328405 return values;
406406+ }
407407+408408+ // Resolve a cross-sheet single cell reference
409409+ resolveCrossSheetCell(sheetName, cellRef) {
410410+ if (!this.crossSheetResolver) return '#REF!';
411411+ if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!';
412412+ return this.crossSheetResolver.getSheetCellValue(sheetName, cellRef);
413413+ }
414414+415415+ // Resolve a cross-sheet range reference (e.g. Sheet2!A1:B5)
416416+ resolveCrossSheetRange(sheetName, rangeStr) {
417417+ if (!this.crossSheetResolver) return '#REF!';
418418+ if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!';
419419+ const parts = rangeStr.split(':');
420420+ if (parts.length !== 2) return '#REF!';
421421+ const start = parseRef(parts[0]);
422422+ const end = parseRef(parts[1]);
423423+ if (!start || !end) return '#REF!';
424424+ const values = [];
425425+ const rowMin = Math.min(start.row, end.row);
426426+ const rowMax = Math.max(start.row, end.row);
427427+ const colMin = Math.min(start.col, end.col);
428428+ const colMax = Math.max(start.col, end.col);
429429+ for (let r = rowMin; r <= rowMax; r++) {
430430+ for (let c = colMin; c <= colMax; c++) {
431431+ const ref = colToLetter(c) + r;
432432+ values.push(this.crossSheetResolver.getSheetCellValue(sheetName, ref));
433433+ }
434434+ }
435435+ values._rangeRows = rowMax - rowMin + 1;
436436+ values._rangeCols = colMax - colMin + 1;
437437+ return values;
438438+ }
439439+440440+ // Resolve a named range identifier to its values
441441+ resolveNamedRange(name) {
442442+ const key = name.toLowerCase();
443443+ const entry = this.namedRanges[key];
444444+ if (!entry) {
445445+ return `#NAME? (${name})`;
446446+ }
447447+ const rangeStr = entry.range;
448448+ // Determine the getCellValue to use — could be cross-sheet or same-sheet
449449+ const parts = rangeStr.split(':');
450450+ if (parts.length === 2) {
451451+ return this.resolveRange(parts[0], parts[1]);
452452+ }
453453+ // Single cell named range
454454+ return this.getCellValue(rangeStr);
329455 }
330456}
331457···608734 * Evaluate a formula string.
609735 * @param {string} formula - The formula (without leading '=')
610736 * @param {(ref: string) => any} getCellValue - Resolver for cell references
737737+ * @param {object} [crossSheetResolver] - Optional resolver for cross-sheet refs
738738+ * @param {object} [namedRanges] - Optional map of lowercase name -> { range, sheet }
611739 * @returns {any} The computed value
612740 */
613613-export function evaluate(formula, getCellValue) {
741741+export function evaluate(formula, getCellValue, crossSheetResolver, namedRanges) {
614742 try {
615743 const tokens = tokenize(formula);
616616- const parser = new Parser(tokens, getCellValue);
744744+ const parser = new Parser(tokens, getCellValue, crossSheetResolver, namedRanges);
617745 return parser.parse();
618746 } catch (err) {
619747 return '#ERROR!';
···622750623751/**
624752 * Extract cell references from a formula for dependency tracking.
753753+ * Cross-sheet refs are returned as 'SheetName!CellId'.
625754 */
626755export function extractRefs(formula) {
627756 const refs = new Set();
628757 const tokens = tokenize(formula);
629758 for (let i = 0; i < tokens.length; i++) {
630759 const t = tokens[i];
760760+ if (t.type === TokenType.CROSS_SHEET_REF) {
761761+ const { sheetName, ref } = t.value;
762762+ if (ref.includes(':')) {
763763+ // Cross-sheet range
764764+ const parts = ref.split(':');
765765+ const start = parseRef(parts[0]);
766766+ const end = parseRef(parts[1]);
767767+ if (start && end) {
768768+ for (let r = Math.min(start.row, end.row); r <= Math.max(start.row, end.row); r++) {
769769+ for (let c = Math.min(start.col, end.col); c <= Math.max(start.col, end.col); c++) {
770770+ refs.add(sheetName + '!' + colToLetter(c) + r);
771771+ }
772772+ }
773773+ }
774774+ } else {
775775+ refs.add(sheetName + '!' + ref);
776776+ }
777777+ continue;
778778+ }
631779 if (t.type === TokenType.CELL_REF) {
632780 // Check for range
633781 if (tokens[i + 1]?.type === TokenType.COLON && tokens[i + 2]?.type === TokenType.CELL_REF) {
+110
src/sheets/named-ranges.js
···11+/**
22+ * Named ranges management.
33+ *
44+ * Named ranges map human-friendly names to cell ranges for cleaner formulas.
55+ * Example: =SUM(Sales) instead of =SUM(A2:A100)
66+ *
77+ * Rules:
88+ * - Names are case-insensitive (stored lowercase internally)
99+ * - Must start with a letter or underscore
1010+ * - Can contain letters, digits, underscores, periods
1111+ * - Must not collide with cell references (e.g. "A1")
1212+ * - Must not be TRUE/FALSE
1313+ */
1414+1515+const CELL_REF_PATTERN = /^[A-Z]{1,3}[0-9]+$/i;
1616+const VALID_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/;
1717+const RESERVED_WORDS = ['TRUE', 'FALSE'];
1818+1919+/**
2020+ * Validate a named range name.
2121+ * @param {string} name
2222+ * @returns {{ valid: boolean, reason?: string }}
2323+ */
2424+export function validateRangeName(name) {
2525+ if (!name || !name.trim()) {
2626+ return { valid: false, reason: 'Name cannot be empty' };
2727+ }
2828+2929+ const trimmed = name.trim();
3030+3131+ if (RESERVED_WORDS.includes(trimmed.toUpperCase())) {
3232+ return { valid: false, reason: `"${trimmed}" is a reserved word` };
3333+ }
3434+3535+ if (!VALID_NAME_PATTERN.test(trimmed)) {
3636+ return { valid: false, reason: 'Name must start with a letter or underscore and contain only letters, digits, underscores, and periods' };
3737+ }
3838+3939+ // Check it doesn't look like a cell ref (e.g. A1, AB123)
4040+ if (CELL_REF_PATTERN.test(trimmed)) {
4141+ return { valid: false, reason: `"${trimmed}" looks like a cell reference` };
4242+ }
4343+4444+ return { valid: true };
4545+}
4646+4747+/**
4848+ * Create or update a named range.
4949+ * @param {object} store - Map-like store (Yjs Map or mock)
5050+ * @param {string} name - Range name
5151+ * @param {string} range - Cell range string (e.g. "A1:A10")
5252+ * @param {string} sheet - Sheet name
5353+ * @throws {Error} if name is invalid
5454+ */
5555+export function createNamedRange(store, name, range, sheet) {
5656+ const validation = validateRangeName(name);
5757+ if (!validation.valid) {
5858+ throw new Error(`Invalid range name "${name}": ${validation.reason}`);
5959+ }
6060+6161+ const key = name.toLowerCase();
6262+ store.set(key, JSON.stringify({ name, range, sheet }));
6363+}
6464+6565+/**
6666+ * Get a named range by name (case-insensitive).
6767+ * @param {object} store
6868+ * @param {string} name
6969+ * @returns {{ name: string, range: string, sheet: string } | null}
7070+ */
7171+export function getNamedRange(store, name) {
7272+ const key = name.toLowerCase();
7373+ if (!store.has(key)) return null;
7474+ return JSON.parse(store.get(key));
7575+}
7676+7777+/**
7878+ * Delete a named range (case-insensitive).
7979+ * @param {object} store
8080+ * @param {string} name
8181+ */
8282+export function deleteNamedRange(store, name) {
8383+ const key = name.toLowerCase();
8484+ store.delete(key);
8585+}
8686+8787+/**
8888+ * List all named ranges.
8989+ * @param {object} store
9090+ * @returns {Array<{ name: string, range: string, sheet: string }>}
9191+ */
9292+export function listNamedRanges(store) {
9393+ const result = [];
9494+ store.forEach((val) => {
9595+ result.push(JSON.parse(val));
9696+ });
9797+ return result;
9898+}
9999+100100+/**
101101+ * Resolve a named range name to its range and sheet.
102102+ * @param {object} store
103103+ * @param {string} name
104104+ * @returns {{ range: string, sheet: string } | null}
105105+ */
106106+export function resolveNamedRange(store, name) {
107107+ const entry = getNamedRange(store, name);
108108+ if (!entry) return null;
109109+ return { range: entry.range, sheet: entry.sheet };
110110+}