Skip to content

Commit fc23814

Browse files
committed
Add complex formula test cases and corresponding fixtures
1 parent 4e4e818 commit fc23814

File tree

3 files changed

+406
-15
lines changed

3 files changed

+406
-15
lines changed

cli/src/osf.ts

Lines changed: 272 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,207 @@ const ajv = new Ajv();
5555
ajv.addFormat('date', /^\d{4}-\d{2}-\d{2}$/);
5656
const validateOsf = ajv.compile(schema);
5757

58+
// Formula evaluator for spreadsheet calculations
59+
class FormulaEvaluator {
60+
private data: Record<string, any>;
61+
private formulas: Map<string, string>;
62+
private computed: Map<string, any>;
63+
private evaluating: Set<string>; // For circular reference detection
64+
65+
constructor(data: Record<string, any>, formulas: { cell: [number, number]; expr: string }[]) {
66+
this.data = { ...data };
67+
this.formulas = new Map();
68+
this.computed = new Map();
69+
this.evaluating = new Set();
70+
71+
// Convert formulas to map with string keys
72+
for (const formula of formulas) {
73+
const key = `${formula.cell[0]},${formula.cell[1]}`;
74+
this.formulas.set(key, formula.expr);
75+
}
76+
}
77+
78+
// Convert cell reference (like "A1", "B2") to row,col coordinates
79+
private cellRefToCoords(cellRef: string): [number, number] {
80+
const match = cellRef.match(/^([A-Z]+)(\d+)$/);
81+
if (!match) {
82+
throw new Error(`Invalid cell reference: ${cellRef}`);
83+
}
84+
85+
const colStr = match[1]!;
86+
const rowStr = match[2]!;
87+
88+
// Convert column letters to number (A=1, B=2, ..., Z=26, AA=27, etc.)
89+
let col = 0;
90+
for (let i = 0; i < colStr.length; i++) {
91+
col = col * 26 + (colStr.charCodeAt(i) - 64); // A=1, B=2, etc.
92+
}
93+
94+
const row = parseInt(rowStr, 10);
95+
return [row, col];
96+
}
97+
98+
// Convert row,col coordinates to cell reference
99+
private coordsToCellRef(row: number, col: number): string {
100+
let colStr = '';
101+
let temp = col;
102+
while (temp > 0) {
103+
temp--;
104+
colStr = String.fromCharCode(65 + (temp % 26)) + colStr;
105+
temp = Math.floor(temp / 26);
106+
}
107+
return `${colStr}${row}`;
108+
}
109+
110+
// Get cell value, evaluating formulas if needed
111+
getCellValue(row: number, col: number): any {
112+
const key = `${row},${col}`;
113+
114+
// Check for circular reference
115+
if (this.evaluating.has(key)) {
116+
throw new Error(`Circular reference detected at cell ${this.coordsToCellRef(row, col)}`);
117+
}
118+
119+
// Return cached computed value if available
120+
if (this.computed.has(key)) {
121+
return this.computed.get(key);
122+
}
123+
124+
// Check if there's a formula for this cell
125+
if (this.formulas.has(key)) {
126+
this.evaluating.add(key);
127+
try {
128+
const formula = this.formulas.get(key)!;
129+
const result = this.evaluateFormula(formula);
130+
this.computed.set(key, result);
131+
this.evaluating.delete(key);
132+
return result;
133+
} catch (error) {
134+
this.evaluating.delete(key);
135+
throw error;
136+
}
137+
}
138+
139+
// Return raw data value or empty string
140+
return this.data[key] ?? '';
141+
}
142+
143+
// Evaluate a formula expression
144+
private evaluateFormula(expr: string): any {
145+
// Remove leading = if present
146+
if (expr.startsWith('=')) {
147+
expr = expr.slice(1);
148+
}
149+
150+
// Replace cell references with actual values
151+
const cellRefRegex = /\b([A-Z]+\d+)\b/g;
152+
const processedExpr = expr.replace(cellRefRegex, (match) => {
153+
const [row, col] = this.cellRefToCoords(match);
154+
const value = this.getCellValue(row, col);
155+
156+
// Convert to number if possible, otherwise use as string
157+
if (typeof value === 'number') {
158+
return value.toString();
159+
} else if (typeof value === 'string' && !isNaN(Number(value))) {
160+
return value;
161+
} else {
162+
// For string values in formulas, wrap in quotes
163+
return `"${value}"`;
164+
}
165+
});
166+
167+
try {
168+
// Evaluate the mathematical expression
169+
return this.evaluateExpression(processedExpr);
170+
} catch (error) {
171+
throw new Error(`Formula evaluation error: ${(error as Error).message}`);
172+
}
173+
}
174+
175+
// Safe expression evaluator supporting basic arithmetic
176+
private evaluateExpression(expr: string): number {
177+
// Remove whitespace
178+
expr = expr.replace(/\s+/g, '');
179+
180+
// Handle parentheses first
181+
while (expr.includes('(')) {
182+
const innerMatch = expr.match(/\(([^()]+)\)/);
183+
if (!innerMatch) break;
184+
185+
const innerResult = this.evaluateExpression(innerMatch[1]!);
186+
expr = expr.replace(innerMatch[0]!, innerResult.toString());
187+
}
188+
189+
// Handle multiplication and division (left to right, same precedence)
190+
expr = this.evaluateOperatorsLeftToRight(expr, ['*', '/']);
191+
192+
// Handle addition and subtraction (left to right, same precedence)
193+
expr = this.evaluateOperatorsLeftToRight(expr, ['+', '-']);
194+
195+
// Parse final result
196+
const result = parseFloat(expr);
197+
if (isNaN(result)) {
198+
throw new Error(`Invalid expression: ${expr}`);
199+
}
200+
201+
return result;
202+
}
203+
204+
// Evaluate operators with left-to-right precedence
205+
private evaluateOperatorsLeftToRight(expr: string, operators: string[]): string {
206+
// Create a regex that matches any of the operators
207+
const opPattern = operators.map(op => `\\${op}`).join('|');
208+
const regex = new RegExp(`(-?\\d+(?:\\.\\d+)?)(${opPattern})(-?\\d+(?:\\.\\d+)?)`, 'g');
209+
210+
// Keep evaluating until no more matches
211+
while (regex.test(expr)) {
212+
regex.lastIndex = 0; // Reset regex
213+
expr = expr.replace(regex, (_, left, op, right) => {
214+
const leftNum = parseFloat(left);
215+
const rightNum = parseFloat(right);
216+
let result: number;
217+
218+
switch (op) {
219+
case '+': result = leftNum + rightNum; break;
220+
case '-': result = leftNum - rightNum; break;
221+
case '*': result = leftNum * rightNum; break;
222+
case '/':
223+
if (rightNum === 0) throw new Error('Division by zero');
224+
result = leftNum / rightNum;
225+
break;
226+
default: throw new Error(`Unknown operator: ${op}`);
227+
}
228+
229+
return result.toString();
230+
});
231+
}
232+
233+
return expr;
234+
}
235+
236+
// Get all computed values for a sheet
237+
getAllComputedValues(maxRow: number, maxCol: number): Record<string, any> {
238+
const result: Record<string, any> = {};
239+
240+
for (let r = 1; r <= maxRow; r++) {
241+
for (let c = 1; c <= maxCol; c++) {
242+
const key = `${r},${c}`;
243+
try {
244+
const value = this.getCellValue(r, c);
245+
if (value !== '') {
246+
result[key] = value;
247+
}
248+
} catch (error) {
249+
// Store error message for cells that can't be computed
250+
result[key] = `#ERROR: ${(error as Error).message}`;
251+
}
252+
}
253+
}
254+
255+
return result;
256+
}
257+
}
258+
58259
function showHelp(): void {
59260
console.log('OmniScript Format (OSF) CLI v0.5.0');
60261
console.log('Universal document DSL for LLMs and Git-native workflows\n');
@@ -130,6 +331,8 @@ function renderHtml(doc: OSFDocument): string {
130331
' table { border-collapse: collapse; width: 100%; margin: 20px 0; }',
131332
' th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }',
132333
' th { background-color: #f5f5f5; font-weight: 600; }',
334+
' .computed { background-color: #f0f8ff; font-style: italic; }',
335+
' .error { background-color: #ffe6e6; color: #d00; }',
133336
' </style>',
134337
'</head>',
135338
'<body>',
@@ -197,18 +400,33 @@ function renderHtml(doc: OSFDocument): string {
197400
}
198401

199402
if (sheet.data) {
200-
parts.push(' <tbody>');
201-
const rows: Record<string, any> = sheet.data;
202-
const coords = Object.keys(rows).map(k => k.split(',').map(Number));
203-
const maxRow = Math.max(...coords.map(c => c[0]!));
204-
const maxCol = Math.max(...coords.map(c => c[1]!));
403+
// Evaluate formulas
404+
const evaluator = new FormulaEvaluator(sheet.data, sheet.formulas || []);
405+
406+
// Calculate dimensions including formula cells
407+
const dataCoords = Object.keys(sheet.data).map(k => k.split(',').map(Number));
408+
const formulaCoords = (sheet.formulas || []).map((f: any) => f.cell);
409+
const allCoords = [...dataCoords, ...formulaCoords];
410+
411+
const maxRow = Math.max(...allCoords.map(c => c[0]!));
412+
const maxCol = Math.max(...allCoords.map(c => c[1]!));
413+
414+
// Get all computed values including formulas
415+
const allValues = evaluator.getAllComputedValues(maxRow, maxCol);
205416

417+
parts.push(' <tbody>');
206418
for (let r = 1; r <= maxRow; r++) {
207419
parts.push(' <tr>');
208420
for (let c = 1; c <= maxCol; c++) {
209421
const key = `${r},${c}`;
210-
const val = rows[key] ?? '';
211-
parts.push(` <td>${val}</td>`);
422+
const val = allValues[key] ?? '';
423+
const hasFormula = sheet.formulas?.some((f: any) => f.cell[0] === r && f.cell[1] === c);
424+
const isError = typeof val === 'string' && val.startsWith('#ERROR:');
425+
426+
const cssClass = isError ? 'error' : (hasFormula ? 'computed' : '');
427+
const cellContent = isError ? val.replace('#ERROR: ', '') : val;
428+
429+
parts.push(` <td class="${cssClass}">${cellContent}</td>`);
212430
}
213431
parts.push(' </tr>');
214432
}
@@ -305,17 +523,33 @@ function exportMarkdown(doc: OSFDocument): string {
305523
}
306524

307525
if (sheet.data) {
308-
const rows: Record<string, any> = sheet.data;
309-
const coords = Object.keys(rows).map(k => k.split(',').map(Number));
310-
const maxRow = Math.max(...coords.map(c => c[0]!));
311-
const maxCol = Math.max(...coords.map(c => c[1]!));
526+
// Evaluate formulas
527+
const evaluator = new FormulaEvaluator(sheet.data, sheet.formulas || []);
528+
529+
// Calculate dimensions including formula cells
530+
const dataCoords = Object.keys(sheet.data).map(k => k.split(',').map(Number));
531+
const formulaCoords = (sheet.formulas || []).map((f: any) => f.cell);
532+
const allCoords = [...dataCoords, ...formulaCoords];
533+
534+
const maxRow = Math.max(...allCoords.map(c => c[0]!));
535+
const maxCol = Math.max(...allCoords.map(c => c[1]!));
536+
537+
// Get all computed values including formulas
538+
const allValues = evaluator.getAllComputedValues(maxRow, maxCol);
312539

313540
for (let r = 1; r <= maxRow; r++) {
314541
const cells: string[] = [];
315542
for (let c = 1; c <= maxCol; c++) {
316543
const key = `${r},${c}`;
317-
const val = rows[key] ?? '';
318-
cells.push(String(val));
544+
const val = allValues[key] ?? '';
545+
const hasFormula = sheet.formulas?.some((f: any) => f.cell[0] === r && f.cell[1] === c);
546+
547+
if (hasFormula && typeof val === 'number') {
548+
// Show computed value with indication it's calculated
549+
cells.push(`${val} *(calc)*`);
550+
} else {
551+
cells.push(String(val));
552+
}
319553
}
320554
out.push('| ' + cells.join(' | ') + ' |');
321555
}
@@ -349,12 +583,35 @@ function exportJson(doc: OSFDocument): string {
349583
case 'sheet': {
350584
const sheet: any = { ...block };
351585
delete sheet.type;
586+
352587
if (sheet.data) {
353-
sheet.data = Object.entries(sheet.data).map(([cell, value]) => {
588+
// Evaluate formulas and include computed values
589+
const evaluator = new FormulaEvaluator(sheet.data, sheet.formulas || []);
590+
591+
// Calculate dimensions including formula cells
592+
const dataCoords = Object.keys(sheet.data).map((k: string) => k.split(',').map(Number));
593+
const formulaCoords = (sheet.formulas || []).map((f: any) => f.cell);
594+
const allCoords = [...dataCoords, ...formulaCoords];
595+
596+
const maxRow = Math.max(...allCoords.map(c => c[0]!));
597+
const maxCol = Math.max(...allCoords.map(c => c[1]!));
598+
599+
// Get all computed values including formulas
600+
const allValues = evaluator.getAllComputedValues(maxRow, maxCol);
601+
602+
// Convert to array format with computed values
603+
sheet.data = Object.entries(allValues).map(([cell, value]) => {
354604
const [r, c] = cell.split(',').map(Number);
355-
return { row: r, col: c, value };
605+
const hasFormula = sheet.formulas?.some((f: any) => f.cell[0] === r && f.cell[1] === c);
606+
return {
607+
row: r,
608+
col: c,
609+
value,
610+
computed: hasFormula
611+
};
356612
});
357613
}
614+
358615
if (sheet.formulas) {
359616
sheet.formulas = sheet.formulas.map((f: any) => ({
360617
row: f.cell[0],

0 commit comments

Comments
 (0)