@molecule/api-formula-engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Spreadsheet-style formula engine for molecule.dev.
3
+ *
4
+ * Pure-function parser + evaluator + dependency tracker for
5
+ * A1-notation formulas (`=SUM(A1:B5)`, `=IF(A1>0, "y", "n")`, etc.).
6
+ * Designed for the spreadsheet flagship app and for ad-hoc line-item
7
+ * math (e.g. invoice/billing computed columns).
8
+ *
9
+ * Capabilities:
10
+ * - **Arithmetic**: `+ - * / % ^` (with Excel-compatible coercions).
11
+ * - **Comparison**: `= <> < <= > >=` (numbers, strings, dates, booleans).
12
+ * - **String**: `&` and `CONCAT()` / `CONCATENATE()`.
13
+ * - **References**: `A1`, `$A$1`, `BC42`, ranges `A1:B5`.
14
+ * - **Aggregations**: `SUM`, `AVERAGE`/`AVG`, `MIN`, `MAX`, `COUNT`,
15
+ * `COUNTA`, `COUNTIF`, `SUMIF`.
16
+ * - **Conditionals**: `IF`, `AND`, `OR`, `NOT`, `IFS`, `SWITCH`,
17
+ * `IFERROR`, `ISERROR`, `ISNUMBER`, `ISBLANK`.
18
+ * - **Math**: `ROUND`, `ABS`, `MOD`, `POWER`, `SQRT`, `INT`, `CEILING`,
19
+ * `FLOOR`.
20
+ * - **Dates**: `DATE`, `TODAY`, `NOW`, `YEAR`, `MONTH`, `DAY`,
21
+ * `DATEDIFF`/`DATEDIF` (units: D/M/Y/H/MIN/S).
22
+ * - **Text**: `LEFT`, `RIGHT`, `MID`, `LEN`, `TRIM`, `UPPER`, `LOWER`,
23
+ * `SUBSTITUTE`.
24
+ * - **Errors**: `#DIV/0!`, `#VALUE!`, `#REF!`, `#NAME?`, `#NUM!`,
25
+ * `#N/A`, plus engine-emitted `#CIRC!` for circular references.
26
+ *
27
+ * The `Sheet` class wraps the lower-level helpers and provides
28
+ * incremental recompute (only the transitive dependents of a changed
29
+ * cell are re-evaluated). Use `parseFormula` + `evaluate` directly for
30
+ * stateless one-shot math.
31
+ *
32
+ * Pure functions, zero I/O, zero DB access, zero hardcoded UI text.
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * import { Sheet, parseFormula, evaluate } from '@molecule/api-formula-engine'
37
+ *
38
+ * // Stateful: a small in-memory spreadsheet.
39
+ * const sheet = new Sheet()
40
+ * sheet.setValue('A1', 10)
41
+ * sheet.setValue('A2', 20)
42
+ * sheet.setFormula('A3', '=SUM(A1:A2)')
43
+ * sheet.getValue('A3') // → 30
44
+ *
45
+ * sheet.setValue('A1', 100)
46
+ * sheet.getValue('A3') // → 120 (auto-recomputed)
47
+ *
48
+ * // Stateless: ad-hoc evaluation against a lookup function.
49
+ * const ast = parseFormula('=A1 * 1.0875')
50
+ * const lineTotal = evaluate(ast, (coord) =>
51
+ * coord.col === 0 && coord.row === 0 ? 49.99 : null,
52
+ * )
53
+ * ```
54
+ *
55
+ * @module
56
+ */
57
+ export * from './browser-guard.js';
58
+ export * from './dependencies.js';
59
+ export * from './evaluator.js';
60
+ export * from './functions.js';
61
+ export * from './parser.js';
62
+ export * from './references.js';
63
+ export * from './sheet.js';
64
+ export * from './tokenizer.js';
65
+ export * from './types.js';
66
+ export * from './values.js';
67
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,mBAAmB,CAAA;AACjC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,aAAa,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Spreadsheet-style formula engine for molecule.dev.
3
+ *
4
+ * Pure-function parser + evaluator + dependency tracker for
5
+ * A1-notation formulas (`=SUM(A1:B5)`, `=IF(A1>0, "y", "n")`, etc.).
6
+ * Designed for the spreadsheet flagship app and for ad-hoc line-item
7
+ * math (e.g. invoice/billing computed columns).
8
+ *
9
+ * Capabilities:
10
+ * - **Arithmetic**: `+ - * / % ^` (with Excel-compatible coercions).
11
+ * - **Comparison**: `= <> < <= > >=` (numbers, strings, dates, booleans).
12
+ * - **String**: `&` and `CONCAT()` / `CONCATENATE()`.
13
+ * - **References**: `A1`, `$A$1`, `BC42`, ranges `A1:B5`.
14
+ * - **Aggregations**: `SUM`, `AVERAGE`/`AVG`, `MIN`, `MAX`, `COUNT`,
15
+ * `COUNTA`, `COUNTIF`, `SUMIF`.
16
+ * - **Conditionals**: `IF`, `AND`, `OR`, `NOT`, `IFS`, `SWITCH`,
17
+ * `IFERROR`, `ISERROR`, `ISNUMBER`, `ISBLANK`.
18
+ * - **Math**: `ROUND`, `ABS`, `MOD`, `POWER`, `SQRT`, `INT`, `CEILING`,
19
+ * `FLOOR`.
20
+ * - **Dates**: `DATE`, `TODAY`, `NOW`, `YEAR`, `MONTH`, `DAY`,
21
+ * `DATEDIFF`/`DATEDIF` (units: D/M/Y/H/MIN/S).
22
+ * - **Text**: `LEFT`, `RIGHT`, `MID`, `LEN`, `TRIM`, `UPPER`, `LOWER`,
23
+ * `SUBSTITUTE`.
24
+ * - **Errors**: `#DIV/0!`, `#VALUE!`, `#REF!`, `#NAME?`, `#NUM!`,
25
+ * `#N/A`, plus engine-emitted `#CIRC!` for circular references.
26
+ *
27
+ * The `Sheet` class wraps the lower-level helpers and provides
28
+ * incremental recompute (only the transitive dependents of a changed
29
+ * cell are re-evaluated). Use `parseFormula` + `evaluate` directly for
30
+ * stateless one-shot math.
31
+ *
32
+ * Pure functions, zero I/O, zero DB access, zero hardcoded UI text.
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * import { Sheet, parseFormula, evaluate } from '@molecule/api-formula-engine'
37
+ *
38
+ * // Stateful: a small in-memory spreadsheet.
39
+ * const sheet = new Sheet()
40
+ * sheet.setValue('A1', 10)
41
+ * sheet.setValue('A2', 20)
42
+ * sheet.setFormula('A3', '=SUM(A1:A2)')
43
+ * sheet.getValue('A3') // → 30
44
+ *
45
+ * sheet.setValue('A1', 100)
46
+ * sheet.getValue('A3') // → 120 (auto-recomputed)
47
+ *
48
+ * // Stateless: ad-hoc evaluation against a lookup function.
49
+ * const ast = parseFormula('=A1 * 1.0875')
50
+ * const lineTotal = evaluate(ast, (coord) =>
51
+ * coord.col === 0 && coord.row === 0 ? 49.99 : null,
52
+ * )
53
+ * ```
54
+ *
55
+ * @module
56
+ */
57
+ export * from './browser-guard.js';
58
+ export * from './dependencies.js';
59
+ export * from './evaluator.js';
60
+ export * from './functions.js';
61
+ export * from './parser.js';
62
+ export * from './references.js';
63
+ export * from './sheet.js';
64
+ export * from './tokenizer.js';
65
+ export * from './types.js';
66
+ export * from './values.js';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Recursive-descent parser for formula expressions.
3
+ *
4
+ * Produces an AST consumable by the evaluator. Operator precedence
5
+ * follows Excel:
6
+ *
7
+ * 1. `:` (range) — handled at tokenizer level
8
+ * 2. unary `+`/`-`
9
+ * 3. `%` (postfix)
10
+ * 4. `^`
11
+ * 5. `*`, `/`
12
+ * 6. `+`, `-`
13
+ * 7. `&`
14
+ * 8. `=`, `<>`, `<`, `<=`, `>`, `>=`
15
+ *
16
+ * Pure function — no I/O, no globals.
17
+ *
18
+ * @module
19
+ */
20
+ import type { AstNode } from './types.js';
21
+ /**
22
+ * Parse a formula string into an AST.
23
+ *
24
+ * @param input - Formula text (with or without a leading `=`).
25
+ * @returns Root AST node.
26
+ */
27
+ export declare function parseFormula(input: string): AstNode;
28
+ //# sourceMappingURL=parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAKH,OAAO,KAAK,EAAE,OAAO,EAAkB,MAAM,YAAY,CAAA;AAEzD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAcnD"}
package/dist/parser.js ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Recursive-descent parser for formula expressions.
3
+ *
4
+ * Produces an AST consumable by the evaluator. Operator precedence
5
+ * follows Excel:
6
+ *
7
+ * 1. `:` (range) — handled at tokenizer level
8
+ * 2. unary `+`/`-`
9
+ * 3. `%` (postfix)
10
+ * 4. `^`
11
+ * 5. `*`, `/`
12
+ * 6. `+`, `-`
13
+ * 7. `&`
14
+ * 8. `=`, `<>`, `<`, `<=`, `>`, `>=`
15
+ *
16
+ * Pure function — no I/O, no globals.
17
+ *
18
+ * @module
19
+ */
20
+ import { parseCellRange, parseCellReference } from './references.js';
21
+ import { tokenize } from './tokenizer.js';
22
+ /**
23
+ * Parse a formula string into an AST.
24
+ *
25
+ * @param input - Formula text (with or without a leading `=`).
26
+ * @returns Root AST node.
27
+ */
28
+ export function parseFormula(input) {
29
+ const body = input.trimStart().startsWith('=') ? input.trimStart().slice(1) : input;
30
+ const tokens = tokenize(body);
31
+ if (tokens.length === 0) {
32
+ throw new Error('Empty formula');
33
+ }
34
+ const parser = new Parser(tokens);
35
+ const node = parser.parseExpression();
36
+ if (!parser.atEnd()) {
37
+ throw new Error(`Unexpected token '${parser.peek().value}' at position ${parser.peek().position}`);
38
+ }
39
+ return node;
40
+ }
41
+ /**
42
+ * Recursive-descent parser that converts a token stream into an AST.
43
+ */
44
+ class Parser {
45
+ tokens;
46
+ pos = 0;
47
+ constructor(tokens) {
48
+ this.tokens = tokens;
49
+ }
50
+ /**
51
+ * Returns true when all tokens have been consumed.
52
+ */
53
+ atEnd() {
54
+ return this.pos >= this.tokens.length;
55
+ }
56
+ /**
57
+ * Returns the current token without advancing the position.
58
+ */
59
+ peek() {
60
+ return this.tokens[this.pos];
61
+ }
62
+ /**
63
+ * Advances past the current token and returns it, throwing if the stream is exhausted.
64
+ */
65
+ consume() {
66
+ const t = this.tokens[this.pos];
67
+ if (!t)
68
+ throw new Error('Unexpected end of formula');
69
+ this.pos++;
70
+ return t;
71
+ }
72
+ /**
73
+ * Entry point for expression parsing; delegates to the lowest-precedence level.
74
+ */
75
+ parseExpression() {
76
+ return this.parseComparison();
77
+ }
78
+ /**
79
+ * Parses comparison operators (`=`, `<>`, `<`, `<=`, `>`, `>=`).
80
+ */
81
+ parseComparison() {
82
+ let left = this.parseConcat();
83
+ while (true) {
84
+ const t = this.peek();
85
+ if (t?.kind === 'op' &&
86
+ (t.value === '=' ||
87
+ t.value === '<>' ||
88
+ t.value === '<' ||
89
+ t.value === '<=' ||
90
+ t.value === '>' ||
91
+ t.value === '>=')) {
92
+ this.consume();
93
+ const right = this.parseConcat();
94
+ left = { kind: 'binary', op: t.value, left, right };
95
+ }
96
+ else {
97
+ break;
98
+ }
99
+ }
100
+ return left;
101
+ }
102
+ /**
103
+ * Parses the string concatenation operator (`&`).
104
+ */
105
+ parseConcat() {
106
+ let left = this.parseAddSub();
107
+ while (true) {
108
+ const t = this.peek();
109
+ if (t?.kind === 'op' && t.value === '&') {
110
+ this.consume();
111
+ const right = this.parseAddSub();
112
+ left = { kind: 'binary', op: '&', left, right };
113
+ }
114
+ else {
115
+ break;
116
+ }
117
+ }
118
+ return left;
119
+ }
120
+ /**
121
+ * Parses addition and subtraction operators (`+`, `-`).
122
+ */
123
+ parseAddSub() {
124
+ let left = this.parseMulDiv();
125
+ while (true) {
126
+ const t = this.peek();
127
+ if (t?.kind === 'op' && (t.value === '+' || t.value === '-')) {
128
+ this.consume();
129
+ const right = this.parseMulDiv();
130
+ left = { kind: 'binary', op: t.value, left, right };
131
+ }
132
+ else {
133
+ break;
134
+ }
135
+ }
136
+ return left;
137
+ }
138
+ /**
139
+ * Parses multiplication and division operators (`*`, `/`).
140
+ */
141
+ parseMulDiv() {
142
+ let left = this.parsePower();
143
+ while (true) {
144
+ const t = this.peek();
145
+ if (t?.kind === 'op' && (t.value === '*' || t.value === '/')) {
146
+ this.consume();
147
+ const right = this.parsePower();
148
+ left = { kind: 'binary', op: t.value, left, right };
149
+ }
150
+ else {
151
+ break;
152
+ }
153
+ }
154
+ return left;
155
+ }
156
+ /**
157
+ * Parses the right-associative exponentiation operator (`^`).
158
+ */
159
+ parsePower() {
160
+ const left = this.parsePercent();
161
+ const t = this.peek();
162
+ if (t?.kind === 'op' && t.value === '^') {
163
+ this.consume();
164
+ const right = this.parsePower(); // right-associative
165
+ return { kind: 'binary', op: '^', left, right };
166
+ }
167
+ return left;
168
+ }
169
+ /**
170
+ * Parses the postfix percent operator (`%`).
171
+ */
172
+ parsePercent() {
173
+ const operand = this.parseUnary();
174
+ const t = this.peek();
175
+ if (t?.kind === 'op' && t.value === '%') {
176
+ this.consume();
177
+ return { kind: 'unary', op: '%', operand };
178
+ }
179
+ return operand;
180
+ }
181
+ /**
182
+ * Parses unary prefix operators (`+`, `-`).
183
+ */
184
+ parseUnary() {
185
+ const t = this.peek();
186
+ if (t?.kind === 'op' && (t.value === '+' || t.value === '-')) {
187
+ this.consume();
188
+ const operand = this.parseUnary();
189
+ return { kind: 'unary', op: t.value === '+' ? '+' : '-', operand };
190
+ }
191
+ return this.parsePrimary();
192
+ }
193
+ /**
194
+ * Parses atomic values: literals, references, ranges, function calls, and parenthesised sub-expressions.
195
+ */
196
+ parsePrimary() {
197
+ const t = this.consume();
198
+ switch (t.kind) {
199
+ case 'number':
200
+ return { kind: 'number', value: Number.parseFloat(t.value) };
201
+ case 'string':
202
+ return { kind: 'string', value: t.value };
203
+ case 'boolean':
204
+ return { kind: 'boolean', value: t.value === 'TRUE' };
205
+ case 'error':
206
+ return { kind: 'errorLiteral', code: t.errorCode };
207
+ case 'reference': {
208
+ const coord = parseCellReference(t.value);
209
+ if (!coord) {
210
+ throw new Error(`Invalid reference '${t.value}' at position ${t.position}`);
211
+ }
212
+ return { kind: 'reference', coord, text: t.value };
213
+ }
214
+ case 'range': {
215
+ const range = parseCellRange(t.value);
216
+ if (!range) {
217
+ throw new Error(`Invalid range '${t.value}' at position ${t.position}`);
218
+ }
219
+ return { kind: 'range', range, text: t.value };
220
+ }
221
+ case 'identifier': {
222
+ // Function call: identifier followed by '('
223
+ const next = this.peek();
224
+ if (next?.kind === 'lparen') {
225
+ this.consume(); // (
226
+ const args = [];
227
+ if (this.peek()?.kind !== 'rparen') {
228
+ args.push(this.parseExpression());
229
+ while (this.peek()?.kind === 'comma') {
230
+ this.consume();
231
+ args.push(this.parseExpression());
232
+ }
233
+ }
234
+ const close = this.consume();
235
+ if (close.kind !== 'rparen') {
236
+ throw new Error(`Expected ')' at position ${close.position}`);
237
+ }
238
+ return { kind: 'call', name: t.value.toUpperCase(), args };
239
+ }
240
+ // Bare identifier — treat as #NAME? at evaluation time
241
+ return { kind: 'call', name: t.value.toUpperCase(), args: [] };
242
+ }
243
+ case 'lparen': {
244
+ const inner = this.parseExpression();
245
+ const close = this.consume();
246
+ if (close.kind !== 'rparen') {
247
+ throw new Error(`Expected ')' at position ${close.position}`);
248
+ }
249
+ return inner;
250
+ }
251
+ default:
252
+ throw new Error(`Unexpected token '${t.value}' at position ${t.position}`);
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Cell-reference parsing and conversion utilities.
3
+ *
4
+ * @module
5
+ */
6
+ import type { CellCoord, CellRange } from './types.js';
7
+ /**
8
+ * Convert a column letter (`A`, `Z`, `AA`, `XFD`) to a 0-indexed column.
9
+ *
10
+ * @param letters - Column letters (case-insensitive).
11
+ * @returns 0-indexed column number.
12
+ */
13
+ export declare function columnLettersToIndex(letters: string): number;
14
+ /**
15
+ * Convert a 0-indexed column to letters (`0`→`A`, `25`→`Z`, `26`→`AA`).
16
+ *
17
+ * @param index - 0-indexed column.
18
+ * @returns Column letters (uppercase).
19
+ */
20
+ export declare function columnIndexToLetters(index: number): string;
21
+ /**
22
+ * Parse a single cell reference (e.g. `A1`, `$A$1`, `BC42`).
23
+ *
24
+ * @param ref - Reference text.
25
+ * @returns Parsed cell coordinate, or `null` if invalid.
26
+ */
27
+ export declare function parseCellReference(ref: string): CellCoord | null;
28
+ /**
29
+ * Parse a cell range (e.g. `A1:B5`). Normalizes so `start` is top-left
30
+ * and `end` is bottom-right regardless of input ordering.
31
+ *
32
+ * @param ref - Range text.
33
+ * @returns Parsed range, or `null` if invalid.
34
+ */
35
+ export declare function parseCellRange(ref: string): CellRange | null;
36
+ /**
37
+ * Format a cell coordinate as A1-style text (`{col:0,row:0}` → `A1`).
38
+ *
39
+ * @param coord - Cell coordinate.
40
+ * @returns A1-style reference text.
41
+ */
42
+ export declare function formatCellReference(coord: CellCoord): string;
43
+ /**
44
+ * Iterate every coordinate in a range. Yields top-to-bottom, left-to-right.
45
+ *
46
+ * @param range - Cell range.
47
+ * @yields {CellCoord} Each cell coordinate inside the range, inclusive of both corners.
48
+ */
49
+ export declare function iterateRange(range: CellRange): Generator<CellCoord>;
50
+ /**
51
+ * Encode a coordinate to a cache-friendly string key (`"col,row"`).
52
+ *
53
+ * @param coord - Cell coordinate.
54
+ * @returns Stable string key.
55
+ */
56
+ export declare function coordKey(coord: CellCoord): string;
57
+ //# sourceMappingURL=references.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"references.d.ts","sourceRoot":"","sources":["../src/references.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAEtD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAW5D;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAY1D;AAID;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAOhE;AAID;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAe5D;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAE5D;AAED;;;;;GAKG;AACH,wBAAiB,YAAY,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,CAAC,SAAS,CAAC,CAMpE;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAEjD"}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Cell-reference parsing and conversion utilities.
3
+ *
4
+ * @module
5
+ */
6
+ /**
7
+ * Convert a column letter (`A`, `Z`, `AA`, `XFD`) to a 0-indexed column.
8
+ *
9
+ * @param letters - Column letters (case-insensitive).
10
+ * @returns 0-indexed column number.
11
+ */
12
+ export function columnLettersToIndex(letters) {
13
+ let n = 0;
14
+ const upper = letters.toUpperCase();
15
+ for (let i = 0; i < upper.length; i++) {
16
+ const code = upper.charCodeAt(i) - 64; // 'A' = 65
17
+ if (code < 1 || code > 26) {
18
+ throw new Error(`Invalid column letter: ${letters}`);
19
+ }
20
+ n = n * 26 + code;
21
+ }
22
+ return n - 1;
23
+ }
24
+ /**
25
+ * Convert a 0-indexed column to letters (`0`→`A`, `25`→`Z`, `26`→`AA`).
26
+ *
27
+ * @param index - 0-indexed column.
28
+ * @returns Column letters (uppercase).
29
+ */
30
+ export function columnIndexToLetters(index) {
31
+ if (!Number.isInteger(index) || index < 0) {
32
+ throw new Error(`Invalid column index: ${index}`);
33
+ }
34
+ let n = index + 1;
35
+ let s = '';
36
+ while (n > 0) {
37
+ const r = (n - 1) % 26;
38
+ s = String.fromCharCode(65 + r) + s;
39
+ n = Math.floor((n - 1) / 26);
40
+ }
41
+ return s;
42
+ }
43
+ const REF_RE = /^\$?([A-Za-z]+)\$?(\d+)$/;
44
+ /**
45
+ * Parse a single cell reference (e.g. `A1`, `$A$1`, `BC42`).
46
+ *
47
+ * @param ref - Reference text.
48
+ * @returns Parsed cell coordinate, or `null` if invalid.
49
+ */
50
+ export function parseCellReference(ref) {
51
+ const m = REF_RE.exec(ref);
52
+ if (!m)
53
+ return null;
54
+ const col = columnLettersToIndex(m[1]);
55
+ const row = Number.parseInt(m[2], 10) - 1;
56
+ if (row < 0)
57
+ return null;
58
+ return { col, row };
59
+ }
60
+ const RANGE_RE = /^(\$?[A-Za-z]+\$?\d+):(\$?[A-Za-z]+\$?\d+)$/;
61
+ /**
62
+ * Parse a cell range (e.g. `A1:B5`). Normalizes so `start` is top-left
63
+ * and `end` is bottom-right regardless of input ordering.
64
+ *
65
+ * @param ref - Range text.
66
+ * @returns Parsed range, or `null` if invalid.
67
+ */
68
+ export function parseCellRange(ref) {
69
+ const m = RANGE_RE.exec(ref);
70
+ if (!m)
71
+ return null;
72
+ const a = parseCellReference(m[1]);
73
+ const b = parseCellReference(m[2]);
74
+ if (!a || !b)
75
+ return null;
76
+ const start = {
77
+ col: Math.min(a.col, b.col),
78
+ row: Math.min(a.row, b.row),
79
+ };
80
+ const end = {
81
+ col: Math.max(a.col, b.col),
82
+ row: Math.max(a.row, b.row),
83
+ };
84
+ return { start, end };
85
+ }
86
+ /**
87
+ * Format a cell coordinate as A1-style text (`{col:0,row:0}` → `A1`).
88
+ *
89
+ * @param coord - Cell coordinate.
90
+ * @returns A1-style reference text.
91
+ */
92
+ export function formatCellReference(coord) {
93
+ return `${columnIndexToLetters(coord.col)}${coord.row + 1}`;
94
+ }
95
+ /**
96
+ * Iterate every coordinate in a range. Yields top-to-bottom, left-to-right.
97
+ *
98
+ * @param range - Cell range.
99
+ * @yields {CellCoord} Each cell coordinate inside the range, inclusive of both corners.
100
+ */
101
+ export function* iterateRange(range) {
102
+ for (let r = range.start.row; r <= range.end.row; r++) {
103
+ for (let c = range.start.col; c <= range.end.col; c++) {
104
+ yield { col: c, row: r };
105
+ }
106
+ }
107
+ }
108
+ /**
109
+ * Encode a coordinate to a cache-friendly string key (`"col,row"`).
110
+ *
111
+ * @param coord - Cell coordinate.
112
+ * @returns Stable string key.
113
+ */
114
+ export function coordKey(coord) {
115
+ return `${coord.col},${coord.row}`;
116
+ }