Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'refactor(sheets): decompose formulas.ts into focused function modules' (#289) from refactor/formulas-decompose into main

scott 53fcaaad f0a2f835

+1288 -1303
+168
src/sheets/formula-array.ts
··· 1 + /** 2 + * Dynamic array formula functions. 3 + * 4 + * FILTER, SORT, UNIQUE, SEQUENCE. 5 + */ 6 + 7 + import type { RangeArray } from './types.js'; 8 + import { toNum } from './formula-helpers.js'; 9 + 10 + export function callArrayFunction(name: string, args: unknown[]): unknown | undefined { 11 + switch (name) { 12 + case 'FILTER': { 13 + // FILTER(array, include, [if_empty]) 14 + const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 15 + const include = Array.isArray(args[1]) ? args[1] : [args[1]]; 16 + const ifEmpty = args[2] !== undefined ? args[2] : '#N/A'; 17 + const rows = (source as RangeArray)._rangeRows || source.length; 18 + const cols = (source as RangeArray)._rangeCols || 1; 19 + 20 + const filtered: unknown[] = []; 21 + for (let r = 0; r < rows; r++) { 22 + const keep = include[r]; 23 + // Truthy = include (booleans, non-zero numbers) 24 + if (keep === true || (typeof keep === 'number' && keep !== 0)) { 25 + if (cols > 1) { 26 + for (let c = 0; c < cols; c++) { 27 + filtered.push(source[r * cols + c]); 28 + } 29 + } else { 30 + filtered.push(source[r]); 31 + } 32 + } 33 + } 34 + if (filtered.length === 0) return ifEmpty; 35 + const result: RangeArray = filtered as RangeArray; 36 + const filteredRows = cols > 1 ? filtered.length / cols : filtered.length; 37 + result._rangeRows = filteredRows; 38 + result._rangeCols = cols; 39 + return result; 40 + } 41 + 42 + case 'SORT': { 43 + // SORT(array, [sort_index], [sort_order], [by_col]) 44 + const source = Array.isArray(args[0]) ? [...args[0]] : [args[0]]; 45 + const sortIndex = args[1] !== undefined ? toNum(args[1]) : 1; 46 + const sortOrder = args[2] !== undefined ? toNum(args[2]) : 1; // 1=asc, -1=desc 47 + const rows = (args[0] as RangeArray)?._rangeRows || source.length; 48 + const cols = (args[0] as RangeArray)?._rangeCols || 1; 49 + 50 + if (cols <= 1) { 51 + // Single column — sort values directly 52 + const sorted = source.filter(v => v !== '' && v !== null && v !== undefined); 53 + sorted.sort((a, b) => { 54 + const na = typeof a === 'number' ? a : NaN; 55 + const nb = typeof b === 'number' ? b : NaN; 56 + if (!isNaN(na) && !isNaN(nb)) return (na - nb) * sortOrder; 57 + return String(a ?? '').localeCompare(String(b ?? '')) * sortOrder; 58 + }); 59 + const result: RangeArray = sorted as RangeArray; 60 + result._rangeRows = sorted.length; 61 + result._rangeCols = 1; 62 + return result; 63 + } 64 + 65 + // Multi-column: sort rows by sort_index column 66 + const rowArrays: unknown[][] = []; 67 + for (let r = 0; r < rows; r++) { 68 + const row: unknown[] = []; 69 + for (let c = 0; c < cols; c++) { 70 + row.push(source[r * cols + c]); 71 + } 72 + rowArrays.push(row); 73 + } 74 + const si = Math.max(0, sortIndex - 1); // 1-based to 0-based 75 + rowArrays.sort((a, b) => { 76 + const va = a[si]; 77 + const vb = b[si]; 78 + const na = typeof va === 'number' ? va : NaN; 79 + const nb = typeof vb === 'number' ? vb : NaN; 80 + if (!isNaN(na) && !isNaN(nb)) return (na - nb) * sortOrder; 81 + return String(va ?? '').localeCompare(String(vb ?? '')) * sortOrder; 82 + }); 83 + const flatResult: RangeArray = rowArrays.flat() as RangeArray; 84 + flatResult._rangeRows = rows; 85 + flatResult._rangeCols = cols; 86 + return flatResult; 87 + } 88 + 89 + case 'UNIQUE': { 90 + // UNIQUE(array, [by_col], [exactly_once]) 91 + const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 92 + const exactlyOnce = args[2] === true; 93 + const rows = (args[0] as RangeArray)?._rangeRows || source.length; 94 + const cols = (args[0] as RangeArray)?._rangeCols || 1; 95 + 96 + if (cols <= 1) { 97 + // Single column 98 + const seen = new Map<string, { value: unknown; count: number }>(); 99 + for (const v of source) { 100 + if (v === '' || v === null || v === undefined) continue; 101 + const key = String(v); 102 + const existing = seen.get(key); 103 + if (existing) { 104 + existing.count++; 105 + } else { 106 + seen.set(key, { value: v, count: 1 }); 107 + } 108 + } 109 + const values = exactlyOnce 110 + ? [...seen.values()].filter(e => e.count === 1).map(e => e.value) 111 + : [...seen.values()].map(e => e.value); 112 + const result: RangeArray = values as RangeArray; 113 + result._rangeRows = values.length; 114 + result._rangeCols = 1; 115 + return result.length === 0 ? '#N/A' : result; 116 + } 117 + 118 + // Multi-column: unique rows 119 + const rowKeys = new Map<string, { row: unknown[]; count: number }>(); 120 + const orderedKeys: string[] = []; 121 + for (let r = 0; r < rows; r++) { 122 + const row: unknown[] = []; 123 + for (let c = 0; c < cols; c++) { 124 + row.push(source[r * cols + c]); 125 + } 126 + const key = row.map(v => String(v ?? '')).join('\0'); 127 + const existing = rowKeys.get(key); 128 + if (existing) { 129 + existing.count++; 130 + } else { 131 + rowKeys.set(key, { row, count: 1 }); 132 + orderedKeys.push(key); 133 + } 134 + } 135 + const uniqueRows = exactlyOnce 136 + ? orderedKeys.filter(k => rowKeys.get(k)!.count === 1).map(k => rowKeys.get(k)!.row) 137 + : orderedKeys.map(k => rowKeys.get(k)!.row); 138 + if (uniqueRows.length === 0) return '#N/A'; 139 + const flatResult: RangeArray = uniqueRows.flat() as RangeArray; 140 + flatResult._rangeRows = uniqueRows.length; 141 + flatResult._rangeCols = cols; 142 + return flatResult; 143 + } 144 + 145 + case 'SEQUENCE': { 146 + // SEQUENCE(rows, [cols], [start], [step]) 147 + const seqRows = Math.max(1, Math.floor(toNum(args[0]))); 148 + const seqCols = args.length > 1 ? Math.max(1, Math.floor(toNum(args[1]))) : 1; 149 + if (seqRows * seqCols > 10000) return '#VALUE!'; 150 + const seqStart = args.length > 2 ? toNum(args[2]) : 1; 151 + const seqStep = args.length > 3 ? toNum(args[3]) : 1; 152 + const seqValues: unknown[] = []; 153 + let seqCurrent = seqStart; 154 + for (let r = 0; r < seqRows; r++) { 155 + for (let c = 0; c < seqCols; c++) { 156 + seqValues.push(seqCurrent); 157 + seqCurrent += seqStep; 158 + } 159 + } 160 + const seqResult: RangeArray = seqValues as RangeArray; 161 + seqResult._rangeRows = seqRows; 162 + seqResult._rangeCols = seqCols; 163 + return seqResult; 164 + } 165 + 166 + default: return undefined; 167 + } 168 + }
+72
src/sheets/formula-date.ts
··· 1 + /** 2 + * Date and time formula functions. 3 + * 4 + * NOW, TODAY, DATE, YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, etc. 5 + */ 6 + 7 + import { toNum } from './formula-helpers.js'; 8 + 9 + export function callDateFunction(name: string, args: unknown[]): unknown | undefined { 10 + switch (name) { 11 + case 'NOW': return new Date(); 12 + case 'TODAY': { const d = new Date(); d.setHours(0, 0, 0, 0); return d; } 13 + case 'DATE': return new Date(toNum(args[0]), toNum(args[1]) - 1, toNum(args[2])); 14 + case 'YEAR': { const yd = new Date(args[0] as string | number | Date); return isNaN(yd.getTime()) ? '#VALUE!' : yd.getFullYear(); } 15 + case 'MONTH': { const md = new Date(args[0] as string | number | Date); return isNaN(md.getTime()) ? '#VALUE!' : md.getMonth() + 1; } 16 + case 'DAY': { const dd = new Date(args[0] as string | number | Date); return isNaN(dd.getTime()) ? '#VALUE!' : dd.getDate(); } 17 + 18 + case 'HOUR': return new Date(args[0] as string | number | Date).getHours(); 19 + case 'MINUTE': return new Date(args[0] as string | number | Date).getMinutes(); 20 + case 'SECOND': return new Date(args[0] as string | number | Date).getSeconds(); 21 + case 'WEEKDAY': { 22 + const wdDate = new Date(args[0] as string | number | Date); 23 + const wdType = args[1] !== undefined ? toNum(args[1]) : 1; 24 + const wdDay = wdDate.getDay(); 25 + if (wdType === 1) return wdDay + 1; 26 + if (wdType === 2) return wdDay === 0 ? 7 : wdDay; 27 + if (wdType === 3) return wdDay === 0 ? 6 : wdDay - 1; 28 + return wdDay + 1; 29 + } 30 + case 'EDATE': { 31 + const edDate = new Date(args[0] as string | number | Date); 32 + const edMonths = toNum(args[1]); 33 + const edOrigDay = edDate.getDate(); 34 + edDate.setMonth(edDate.getMonth() + edMonths); 35 + // Clamp day-of-month if it rolled over (e.g. Jan 31 + 1 month -> Mar 3 should be Feb 28) 36 + if (edDate.getDate() !== edOrigDay) { 37 + edDate.setDate(0); // go to last day of previous month 38 + } 39 + return edDate; 40 + } 41 + case 'EOMONTH': { 42 + const emDate = new Date(args[0] as string | number | Date); 43 + const emMonths = toNum(args[1]); 44 + emDate.setMonth(emDate.getMonth() + emMonths + 1, 0); // day 0 = last day of target month 45 + return emDate; 46 + } 47 + case 'DAYS': { 48 + const dEnd = new Date(args[0] as string | number | Date); 49 + const dStart = new Date(args[1] as string | number | Date); 50 + return Math.round((dEnd.getTime() - dStart.getTime()) / 86400000); 51 + } 52 + case 'NETWORKDAYS': { 53 + const nwStart = new Date(args[0] as string | number | Date); 54 + const nwEnd = new Date(args[1] as string | number | Date); 55 + if (isNaN(nwStart.getTime()) || isNaN(nwEnd.getTime())) return '#VALUE!'; 56 + // Safety: cap at ~200 years to prevent infinite/very-long loops 57 + const nwDaySpan = Math.abs(nwEnd.getTime() - nwStart.getTime()) / 86400000; 58 + if (nwDaySpan > 73000) return '#VALUE!'; 59 + let nwCount = 0; 60 + const nwStep = nwStart <= nwEnd ? 1 : -1; 61 + const nwCur = new Date(nwStart); 62 + while ((nwStep > 0 && nwCur <= nwEnd) || (nwStep < 0 && nwCur >= nwEnd)) { 63 + const nwDay = nwCur.getDay(); 64 + if (nwDay !== 0 && nwDay !== 6) nwCount++; 65 + nwCur.setDate(nwCur.getDate() + nwStep); 66 + } 67 + return nwStep > 0 ? nwCount : -nwCount; 68 + } 69 + 70 + default: return undefined; 71 + } 72 + }
+72
src/sheets/formula-financial.ts
··· 1 + /** 2 + * Financial formula functions. 3 + * 4 + * PMT, FV, PV, NPV, IRR. 5 + */ 6 + 7 + import { toNum } from './formula-helpers.js'; 8 + 9 + export function callFinancialFunction(name: string, args: unknown[]): unknown | undefined { 10 + switch (name) { 11 + case 'PMT': { 12 + const pmtRate = toNum(args[0]); 13 + const pmtNper = toNum(args[1]); 14 + const pmtPv = toNum(args[2]); 15 + const pmtFv = args[3] !== undefined ? toNum(args[3]) : 0; 16 + const pmtType = args[4] !== undefined ? toNum(args[4]) : 0; 17 + if (pmtRate === 0) return -(pmtPv + pmtFv) / pmtNper; 18 + const pmtPvif = Math.pow(1 + pmtRate, pmtNper); 19 + return -(pmtRate * (pmtPv * pmtPvif + pmtFv)) / (pmtPvif - 1) / (1 + pmtRate * pmtType); 20 + } 21 + case 'FV': { 22 + const fvRate = toNum(args[0]); 23 + const fvNper = toNum(args[1]); 24 + const fvPmt = toNum(args[2]); 25 + const fvPv = args[3] !== undefined ? toNum(args[3]) : 0; 26 + const fvType = args[4] !== undefined ? toNum(args[4]) : 0; 27 + if (fvRate === 0) return -(fvPv + fvPmt * fvNper); 28 + const fvPvif = Math.pow(1 + fvRate, fvNper); 29 + return -(fvPv * fvPvif + fvPmt * (1 + fvRate * fvType) * ((fvPvif - 1) / fvRate)); 30 + } 31 + case 'PV': { 32 + const pvRate = toNum(args[0]); 33 + const pvNper = toNum(args[1]); 34 + const pvPmt = toNum(args[2]); 35 + const pvFv = args[3] !== undefined ? toNum(args[3]) : 0; 36 + const pvType = args[4] !== undefined ? toNum(args[4]) : 0; 37 + if (pvRate === 0) return -(pvFv + pvPmt * pvNper); 38 + const pvPvif = Math.pow(1 + pvRate, pvNper); 39 + return -(pvFv + pvPmt * (1 + pvRate * pvType) * ((pvPvif - 1) / pvRate)) / pvPvif; 40 + } 41 + case 'NPV': { 42 + const npvRate = toNum(args[0]); 43 + let npvSum = 0; 44 + let npvPeriod = 1; 45 + for (let i = 1; i < args.length; i++) { 46 + const npvVals = Array.isArray(args[i]) ? (args[i] as unknown[]).map(toNum) : [toNum(args[i])]; 47 + for (const nvItem of npvVals) { 48 + npvSum += nvItem / Math.pow(1 + npvRate, npvPeriod); 49 + npvPeriod++; 50 + } 51 + } 52 + return npvSum; 53 + } 54 + case 'IRR': { 55 + const irrVals = Array.isArray(args[0]) ? (args[0] as unknown[]).map(toNum) : [toNum(args[0])]; 56 + let irrGuess = args[1] !== undefined ? toNum(args[1]) : 0.1; 57 + for (let iter = 0; iter < 100; iter++) { 58 + let irrNpv = 0, irrDnpv = 0; 59 + for (let i = 0; i < irrVals.length; i++) { 60 + irrNpv += irrVals[i] / Math.pow(1 + irrGuess, i); 61 + irrDnpv -= i * irrVals[i] / Math.pow(1 + irrGuess, i + 1); 62 + } 63 + if (Math.abs(irrNpv) < 1e-7) return irrGuess; 64 + if (irrDnpv === 0) return '#NUM!'; 65 + irrGuess = irrGuess - irrNpv / irrDnpv; 66 + } 67 + return '#NUM!'; 68 + } 69 + 70 + default: return undefined; 71 + } 72 + }
+229
src/sheets/formula-helpers.ts
··· 1 + /** 2 + * Shared helper functions for the formula engine. 3 + * 4 + * Used by the parser, function implementations, and callFunction dispatcher. 5 + */ 6 + 7 + import type { CellRef } from './types.js'; 8 + 9 + // --- Cell reference utilities --- 10 + 11 + export function parseRef(ref: string): CellRef | null { 12 + const match = ref.match(/^([A-Z]+)(\d+)$/); 13 + if (!match) return null; 14 + return { col: letterToCol(match[1]), row: parseInt(match[2]) }; 15 + } 16 + 17 + export function colToLetter(col: number): string { 18 + let result = ''; 19 + while (col > 0) { 20 + col--; 21 + result = String.fromCharCode(65 + (col % 26)) + result; 22 + col = Math.floor(col / 26); 23 + } 24 + return result; 25 + } 26 + 27 + export function letterToCol(letter: string): number { 28 + let col = 0; 29 + for (let i = 0; i < letter.length; i++) { 30 + col = col * 26 + (letter.charCodeAt(i) - 64); 31 + } 32 + return col; 33 + } 34 + 35 + export function cellId(col: number, row: number): string { 36 + return colToLetter(col) + row; 37 + } 38 + 39 + // --- Value coercion --- 40 + 41 + /** Coerce any cell value to a number (Excel-style). */ 42 + export function toNum(v: unknown): number { 43 + if (v === '' || v === null || v === undefined) return 0; 44 + if (typeof v === 'boolean') return v ? 1 : 0; 45 + if (typeof v === 'number') return v; 46 + const n = Number(v); 47 + return isNaN(n) ? 0 : n; 48 + } 49 + 50 + /** Excel-style boolean coercion: TRUE/FALSE, nonzero=true, zero/empty=false, non-numeric strings=false */ 51 + export function toBool(v: unknown): boolean { 52 + if (typeof v === 'boolean') return v; 53 + if (typeof v === 'number') return v !== 0; 54 + if (v === '' || v === null || v === undefined) return false; 55 + if (typeof v === 'string') { 56 + const upper = v.toUpperCase(); 57 + if (upper === 'TRUE') return true; 58 + if (upper === 'FALSE') return false; 59 + const n = Number(v); 60 + if (!isNaN(n)) return n !== 0; 61 + return false; // non-numeric strings are not valid booleans 62 + } 63 + return Boolean(v); 64 + } 65 + 66 + // --- String/regex helpers --- 67 + 68 + export function escapeRegex(s: string): string { 69 + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 70 + } 71 + 72 + /** Convert wildcard pattern (* and ?) to a RegExp */ 73 + export function wildcardToRegex(pattern: string): RegExp { 74 + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 75 + const regexStr = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); 76 + return new RegExp('^' + regexStr + '$', 'i'); 77 + } 78 + 79 + // --- Comparison helpers --- 80 + 81 + /** Excel-style comparison coercion: numbers vs numeric strings, case-insensitive strings, bool<->number */ 82 + export function coerceForComparison(a: unknown, b: unknown): [unknown, unknown] { 83 + // Booleans coerce to numbers for comparison with numbers/numeric strings 84 + if (typeof a === 'boolean') a = a ? 1 : 0; 85 + if (typeof b === 'boolean') b = b ? 1 : 0; 86 + 87 + // If both are strings, try numeric comparison first; fall back to case-insensitive string compare 88 + if (typeof a === 'string' && typeof b === 'string') { 89 + const na = Number(a), nb = Number(b); 90 + if (!isNaN(na) && a !== '' && !isNaN(nb) && b !== '') return [na, nb]; 91 + return [a.toUpperCase(), b.toUpperCase()]; 92 + } 93 + 94 + // Mixed number/string: coerce the string to a number if possible 95 + if (typeof a === 'number' && typeof b === 'string') { 96 + const nb = Number(b); 97 + if (!isNaN(nb) && b !== '') return [a, nb]; 98 + } 99 + if (typeof b === 'number' && typeof a === 'string') { 100 + const na = Number(a); 101 + if (!isNaN(na) && a !== '') return [na, b]; 102 + } 103 + 104 + return [a, b]; 105 + } 106 + 107 + export function compareValues(a: unknown, b: unknown): number { 108 + if (typeof a === 'number' && typeof b === 'number') return a - b; 109 + return String(a).toLowerCase().localeCompare(String(b).toLowerCase()); 110 + } 111 + 112 + export function valuesEqual(a: unknown, b: unknown): boolean { 113 + if (typeof a === 'number' && typeof b === 'number') return a === b; 114 + if (typeof a === 'number' || typeof b === 'number') { 115 + const na = toNum(a), nb = toNum(b); 116 + if (a !== '' && b !== '' && !isNaN(Number(a)) && !isNaN(Number(b))) return na === nb; 117 + } 118 + return String(a).toLowerCase() === String(b).toLowerCase(); 119 + } 120 + 121 + // --- Criteria matching --- 122 + 123 + export function matchCriteria(value: unknown, criteria: unknown): boolean { 124 + if (typeof criteria === 'string') { 125 + if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2)); 126 + if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2)); 127 + if (criteria.startsWith('<>')) { 128 + const cv = criteria.slice(2); 129 + const nv = Number(cv); 130 + if (!isNaN(nv) && cv !== '') return toNum(value) !== nv; 131 + return String(value).toLowerCase() !== cv.toLowerCase(); 132 + } 133 + if (criteria.startsWith('>')) return toNum(value) > toNum(criteria.slice(1)); 134 + if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1)); 135 + if (criteria.startsWith('=')) { 136 + const cv = criteria.slice(1); 137 + const nv = Number(cv); 138 + if (!isNaN(nv) && cv !== '') return toNum(value) === nv; 139 + return String(value).toLowerCase() === cv.toLowerCase(); 140 + } 141 + return String(value).toLowerCase() === String(criteria).toLowerCase(); 142 + } 143 + return value === criteria; 144 + } 145 + 146 + /** matchCriteria with wildcard support for SUMIFS/COUNTIFS/AVERAGEIFS */ 147 + export function matchCriteriaWild(value: unknown, criteria: unknown): boolean { 148 + if (typeof criteria === 'string') { 149 + if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2)); 150 + if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2)); 151 + if (criteria.startsWith('<>')) { 152 + const cv = criteria.slice(2); 153 + const nv = Number(cv); 154 + if (!isNaN(nv) && cv !== '') return toNum(value) !== nv; 155 + return String(value).toLowerCase() !== cv.toLowerCase(); 156 + } 157 + if (criteria.startsWith('>')) return toNum(value) > toNum(criteria.slice(1)); 158 + if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1)); 159 + if (criteria.startsWith('=')) { 160 + const cv = criteria.slice(1); 161 + const nv = Number(cv); 162 + if (!isNaN(nv) && cv !== '') return toNum(value) === nv; 163 + return String(value).toLowerCase() === cv.toLowerCase(); 164 + } 165 + // Check for wildcards 166 + if (criteria.includes('*') || criteria.includes('?')) { 167 + return wildcardToRegex(criteria).test(String(value)); 168 + } 169 + // Empty criteria matches empty cells 170 + if (criteria === '') return value === '' || value === null || value === undefined; 171 + return String(value).toLowerCase() === String(criteria).toLowerCase(); 172 + } 173 + if (typeof criteria === 'number') { 174 + return toNum(value) === criteria; 175 + } 176 + return value === criteria; 177 + } 178 + 179 + // --- Formatting --- 180 + 181 + export function formatValue(num: number, fmt: string): string { 182 + // Percentage formats 183 + if (fmt.endsWith('%')) { 184 + const inner = fmt.slice(0, -1); 185 + const dotPos = inner.indexOf('.'); 186 + const decimals = dotPos === -1 ? 0 : inner.length - dotPos - 1; 187 + return (num * 100).toFixed(decimals) + '%'; 188 + } 189 + 190 + // Scientific notation: 0.00E+00 (check before dot/comma to avoid false match) 191 + if (/e\+/i.test(fmt)) { 192 + const dotE = fmt.toLowerCase().indexOf('e'); 193 + const decPart = fmt.slice(0, dotE); 194 + const decDot = decPart.indexOf('.'); 195 + const decimals = decDot === -1 ? 0 : decPart.length - decDot - 1; 196 + return num.toExponential(decimals).toUpperCase(); 197 + } 198 + 199 + // Comma-separated with optional decimals: #,##0 or #,##0.00 etc. 200 + if (fmt.includes(',')) { 201 + const dotPos = fmt.indexOf('.'); 202 + const decimals = dotPos === -1 ? 0 : fmt.length - dotPos - 1; 203 + return num.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); 204 + } 205 + 206 + // Fixed-point: 0.0, 0.00, 0.000, etc. 207 + const dotPos = fmt.indexOf('.'); 208 + if (dotPos !== -1) { 209 + const decimals = fmt.length - dotPos - 1; 210 + return num.toFixed(decimals); 211 + } 212 + 213 + // Plain integer format 214 + if (fmt === '0' || fmt === '#') return Math.round(num).toString(); 215 + 216 + return num.toString(); 217 + } 218 + 219 + // --- Array helpers --- 220 + 221 + /** Flatten range arrays and filter out empty values. */ 222 + export function flat(arr: unknown[]): unknown[] { 223 + return (arr as unknown[]).flat(Infinity).filter(v => v !== '' && v !== null && v !== undefined); 224 + } 225 + 226 + /** Flatten range arrays, convert to numbers, and filter NaN. */ 227 + export function nums(arr: unknown[]): number[] { 228 + return flat(arr).map(toNum).filter(v => !isNaN(v)); 229 + }
+66
src/sheets/formula-logical.ts
··· 1 + /** 2 + * Logical and information formula functions. 3 + * 4 + * IF, AND, OR, NOT, IFERROR, SWITCH, ISNUMBER, ISTEXT, ISBLANK, etc. 5 + */ 6 + 7 + import { toNum, toBool, flat, valuesEqual } from './formula-helpers.js'; 8 + 9 + export function callLogicalFunction(name: string, args: unknown[]): unknown | undefined { 10 + switch (name) { 11 + case 'IF': return toBool(args[0]) ? args[1] : (args[2] ?? false); 12 + case 'AND': return flat(args).every(toBool); 13 + case 'OR': return flat(args).some(toBool); 14 + case 'NOT': return !toBool(args[0]); 15 + case 'IFERROR': { 16 + const val = args[0]; 17 + if (typeof val === 'string' && val.startsWith('#')) return args[1] ?? ''; 18 + return val; 19 + } 20 + 21 + case 'SWITCH': { 22 + // SWITCH(expression, case1, value1, [case2, value2, ...], [default]) 23 + const expr = args[0]; 24 + const pairs = args.slice(1); 25 + const hasDefault = pairs.length % 2 === 1; 26 + const pairCount = Math.floor(pairs.length / 2); 27 + for (let i = 0; i < pairCount; i++) { 28 + if (valuesEqual(expr, pairs[i * 2])) return pairs[i * 2 + 1]; 29 + } 30 + return hasDefault ? pairs[pairs.length - 1] : '#N/A'; 31 + } 32 + 33 + // --- Information Functions --- 34 + case 'ISNUMBER': return typeof args[0] === 'number'; 35 + case 'ISTEXT': return typeof args[0] === 'string' && !String(args[0]).startsWith('#'); 36 + case 'ISBLANK': return args[0] === null || args[0] === undefined || args[0] === ''; 37 + case 'ISERROR': { 38 + const ev = args[0]; 39 + return typeof ev === 'string' && (ev === '#REF!' || ev === '#VALUE!' || ev === '#DIV/0!' || ev === '#N/A' || ev === '#ERROR!' || ev === '#NUM!' || (ev as string).startsWith('#NAME?')); 40 + } 41 + case 'ISNA': return args[0] === '#N/A'; 42 + case 'ISLOGICAL': return typeof args[0] === 'boolean'; 43 + case 'TYPE': { 44 + const tv = args[0]; 45 + if (typeof tv === 'number') return 1; 46 + if (typeof tv === 'string' && tv.startsWith('#')) return 16; 47 + if (typeof tv === 'string') return 2; 48 + if (typeof tv === 'boolean') return 4; 49 + if (Array.isArray(tv)) return 64; 50 + return 1; 51 + } 52 + case 'N': { 53 + const nv = args[0]; 54 + if (typeof nv === 'number') return nv; 55 + if (typeof nv === 'boolean') return nv ? 1 : 0; 56 + if (nv instanceof Date) return nv.getTime(); 57 + return 0; 58 + } 59 + case 'T': { 60 + const tv2 = args[0]; 61 + return typeof tv2 === 'string' && !String(tv2).startsWith('#') ? tv2 : ''; 62 + } 63 + 64 + default: return undefined; 65 + } 66 + }
+199
src/sheets/formula-lookup.ts
··· 1 + /** 2 + * Lookup and reference formula functions. 3 + * 4 + * VLOOKUP, HLOOKUP, INDEX, MATCH, XLOOKUP, ADDRESS, CHOOSE, etc. 5 + */ 6 + 7 + import type { RangeArray } from './types.js'; 8 + import { toNum, compareValues, valuesEqual, wildcardToRegex, colToLetter } from './formula-helpers.js'; 9 + 10 + // --- VLOOKUP / HLOOKUP helpers --- 11 + 12 + function vlookup(needle: unknown, flatRange: RangeArray, rows: number, cols: number, colIdx: number, rangeLookup: boolean): unknown { 13 + if (rangeLookup) { 14 + let bestRow = -1; 15 + for (let r = 0; r < rows; r++) { 16 + const val = flatRange[r * cols]; 17 + const cmp = compareValues(val, needle); 18 + if (cmp <= 0) bestRow = r; 19 + else break; 20 + } 21 + if (bestRow === -1) return '#N/A'; 22 + return flatRange[bestRow * cols + (colIdx - 1)]; 23 + } else { 24 + for (let r = 0; r < rows; r++) { 25 + const val = flatRange[r * cols]; 26 + if (valuesEqual(val, needle)) { 27 + return flatRange[r * cols + (colIdx - 1)]; 28 + } 29 + } 30 + return '#N/A'; 31 + } 32 + } 33 + 34 + function hlookup(needle: unknown, flatRange: RangeArray, rows: number, cols: number, rowIdx: number, rangeLookup: boolean): unknown { 35 + if (rangeLookup) { 36 + let bestCol = -1; 37 + for (let c = 0; c < cols; c++) { 38 + const val = flatRange[c]; 39 + const cmp = compareValues(val, needle); 40 + if (cmp <= 0) bestCol = c; 41 + else break; 42 + } 43 + if (bestCol === -1) return '#N/A'; 44 + return flatRange[(rowIdx - 1) * cols + bestCol]; 45 + } else { 46 + for (let c = 0; c < cols; c++) { 47 + const val = flatRange[c]; 48 + if (valuesEqual(val, needle)) { 49 + return flatRange[(rowIdx - 1) * cols + c]; 50 + } 51 + } 52 + return '#N/A'; 53 + } 54 + } 55 + 56 + export function callLookupFunction(name: string, args: unknown[]): unknown | undefined { 57 + switch (name) { 58 + case 'VLOOKUP': { 59 + const needle = args[0]; 60 + const range = args[1]; 61 + const colIdx = toNum(args[2]); 62 + const rangeLookup = args[3] !== undefined ? Boolean(args[3]) : true; 63 + if (!Array.isArray(range)) return '#VALUE!'; 64 + const rows = (range as RangeArray)._rangeRows || range.length; 65 + const cols = (range as RangeArray)._rangeCols || 1; 66 + if (colIdx < 1 || colIdx > cols) return '#REF!'; 67 + return vlookup(needle, range as RangeArray, rows, cols, colIdx, rangeLookup); 68 + } 69 + 70 + case 'HLOOKUP': { 71 + const needle = args[0]; 72 + const range = args[1]; 73 + const rowIdx = toNum(args[2]); 74 + const rangeLookup = args[3] !== undefined ? Boolean(args[3]) : true; 75 + if (!Array.isArray(range)) return '#VALUE!'; 76 + const rows = (range as RangeArray)._rangeRows || 1; 77 + const cols = (range as RangeArray)._rangeCols || range.length; 78 + if (rowIdx < 1 || rowIdx > rows) return '#REF!'; 79 + return hlookup(needle, range as RangeArray, rows, cols, rowIdx, rangeLookup); 80 + } 81 + 82 + case 'INDEX': { 83 + const range = Array.isArray(args[0]) ? args[0] : [args[0]]; 84 + const row = toNum(args[1]) - 1; 85 + const col = args[2] != null ? toNum(args[2]) - 1 : 0; 86 + const idxCols = (range as RangeArray)._rangeCols || range.length; 87 + const idxRows = (range as RangeArray)._rangeRows || 1; 88 + if (row < 0 || row >= idxRows || col < 0 || col >= idxCols) return '#REF!'; 89 + return range[row * idxCols + col] ?? '#REF!'; 90 + } 91 + 92 + case 'MATCH': { 93 + const needle = args[0]; 94 + const range = Array.isArray(args[1]) ? args[1] : [args[1]]; 95 + const matchType = args[2] != null ? toNum(args[2]) : 1; 96 + if (matchType === 0) { 97 + // Exact match 98 + const idx = range.findIndex(v => v === needle || String(v) === String(needle)); 99 + return idx === -1 ? '#N/A' : idx + 1; 100 + } else if (matchType === 1) { 101 + // Sorted ascending: find largest value <= needle 102 + const nv = toNum(needle); 103 + let bestIdx = -1; 104 + for (let i = 0; i < range.length; i++) { 105 + const rv = toNum(range[i]); 106 + if (rv <= nv) bestIdx = i; else break; 107 + } 108 + return bestIdx === -1 ? '#N/A' : bestIdx + 1; 109 + } else { 110 + // match_type = -1: sorted descending: find smallest value >= needle 111 + const nv = toNum(needle); 112 + let bestIdx = -1; 113 + for (let i = 0; i < range.length; i++) { 114 + const rv = toNum(range[i]); 115 + if (rv >= nv) bestIdx = i; else break; 116 + } 117 + return bestIdx === -1 ? '#N/A' : bestIdx + 1; 118 + } 119 + } 120 + 121 + case 'ADDRESS': { 122 + // ADDRESS(row_num, col_num, [abs_num]) 123 + const rowNum = toNum(args[0]); 124 + const colNum = toNum(args[1]); 125 + const absNum = args[2] !== undefined ? toNum(args[2]) : 1; 126 + const colLetter = colToLetter(colNum); 127 + switch (absNum) { 128 + case 1: return '$' + colLetter + '$' + rowNum; 129 + case 2: return colLetter + '$' + rowNum; 130 + case 3: return '$' + colLetter + rowNum; 131 + case 4: return colLetter + '' + rowNum; 132 + default: return '$' + colLetter + '$' + rowNum; 133 + } 134 + } 135 + 136 + case 'XLOOKUP': { 137 + const needle = args[0]; 138 + const lookupArr = Array.isArray(args[1]) ? args[1] : [args[1]]; 139 + const returnArr = Array.isArray(args[2]) ? args[2] : [args[2]]; 140 + const ifNotFound = args[3] !== undefined ? args[3] : '#N/A'; 141 + const matchMode = args[4] !== undefined ? toNum(args[4]) : 0; 142 + const searchMode = args[5] !== undefined ? toNum(args[5]) : 1; 143 + 144 + // Flatten lookup and return arrays to 1D — use their linear length 145 + const lookupLen = lookupArr.length; 146 + const indices = []; 147 + for (let i = 0; i < lookupLen; i++) indices.push(i); 148 + if (searchMode === -1) indices.reverse(); 149 + 150 + let foundIdx = -1; 151 + 152 + if (matchMode === 0) { 153 + // Exact match 154 + for (const i of indices) { 155 + if (valuesEqual(lookupArr[i], needle)) { foundIdx = i; break; } 156 + } 157 + } else if (matchMode === 2) { 158 + // Wildcard match 159 + const pattern = wildcardToRegex(String(needle)); 160 + for (const i of indices) { 161 + if (pattern.test(String(lookupArr[i]))) { foundIdx = i; break; } 162 + } 163 + } else if (matchMode === -1) { 164 + // Exact or next smaller 165 + let bestIdx = -1; 166 + let bestVal = -Infinity; 167 + for (let i = 0; i < lookupLen; i++) { 168 + const v = lookupArr[i]; 169 + const cmp = compareValues(v, needle); 170 + if (cmp === 0) { foundIdx = i; break; } 171 + if (cmp < 0 && toNum(v) > bestVal) { bestVal = toNum(v); bestIdx = i; } 172 + } 173 + if (foundIdx === -1) foundIdx = bestIdx; 174 + } else if (matchMode === 1) { 175 + // Exact or next larger 176 + let bestIdx = -1; 177 + let bestVal = Infinity; 178 + for (let i = 0; i < lookupLen; i++) { 179 + const v = lookupArr[i]; 180 + const cmp = compareValues(v, needle); 181 + if (cmp === 0) { foundIdx = i; break; } 182 + if (cmp > 0 && toNum(v) < bestVal) { bestVal = toNum(v); bestIdx = i; } 183 + } 184 + if (foundIdx === -1) foundIdx = bestIdx; 185 + } 186 + 187 + if (foundIdx === -1) return ifNotFound; 188 + return returnArr[foundIdx] !== undefined ? returnArr[foundIdx] : ifNotFound; 189 + } 190 + 191 + case 'CHOOSE': { 192 + const chIdx = Math.floor(toNum(args[0])); 193 + if (chIdx < 1 || chIdx >= args.length) return '#VALUE!'; 194 + return args[chIdx]; 195 + } 196 + 197 + default: return undefined; 198 + } 199 + }
+295
src/sheets/formula-math.ts
··· 1 + /** 2 + * Math, statistical, and trigonometric formula functions. 3 + * 4 + * SUM, AVERAGE, COUNT, MIN, MAX, ROUND, SQRT, SIN, COS, etc. 5 + */ 6 + 7 + import { toNum, flat, nums, matchCriteria, matchCriteriaWild } from './formula-helpers.js'; 8 + 9 + export function callMathFunction(name: string, args: unknown[]): unknown | undefined { 10 + switch (name) { 11 + case 'SUM': return nums(args).reduce((a, b) => a + b, 0); 12 + case 'AVERAGE': { const n = nums(args); return n.length ? n.reduce((a, b) => a + b, 0) / n.length : '#DIV/0!'; } 13 + case 'COUNT': return nums(args).length; 14 + case 'COUNTA': return flat(args).length; 15 + case 'MIN': { const n = nums(args); return n.length ? Math.min(...n) : 0; } 16 + case 'MAX': { const n = nums(args); return n.length ? Math.max(...n) : 0; } 17 + case 'ADD': return toNum(args[0]) + toNum(args[1]); 18 + case 'MINUS': return toNum(args[0]) - toNum(args[1]); 19 + case 'MULTIPLY': return toNum(args[0]) * toNum(args[1]); 20 + case 'DIVIDE': { const d = toNum(args[1]); return d === 0 ? '#DIV/0!' : toNum(args[0]) / d; } 21 + case 'ABS': return Math.abs(toNum(args[0])); 22 + case 'ROUND': return Math.round(toNum(args[0]) * Math.pow(10, toNum(args[1] ?? 0))) / Math.pow(10, toNum(args[1] ?? 0)); 23 + case 'ROUNDUP': { const rv = toNum(args[0]); const f = Math.pow(10, toNum(args[1] ?? 0)); return (rv >= 0 ? Math.ceil(rv * f) : Math.floor(rv * f)) / f; } 24 + case 'ROUNDDOWN': { const rv = toNum(args[0]); const f = Math.pow(10, toNum(args[1] ?? 0)); return (rv >= 0 ? Math.floor(rv * f) : Math.ceil(rv * f)) / f; } 25 + case 'INT': return Math.floor(toNum(args[0])); 26 + case 'MOD': { const mDiv = toNum(args[1]); if (mDiv === 0) return '#DIV/0!'; const mR = toNum(args[0]) % mDiv; return (mR !== 0 && Math.sign(mR) !== Math.sign(mDiv)) ? mR + mDiv : mR; } 27 + case 'POWER': return Math.pow(toNum(args[0]), toNum(args[1])); 28 + case 'SQRT': return Math.sqrt(toNum(args[0])); 29 + case 'LOG': return args.length > 1 ? Math.log(toNum(args[0])) / Math.log(toNum(args[1])) : Math.log10(toNum(args[0])); 30 + case 'LN': return Math.log(toNum(args[0])); 31 + case 'EXP': return Math.exp(toNum(args[0])); 32 + case 'PI': return Math.PI; 33 + case 'RAND': return Math.random(); 34 + case 'RANDBETWEEN': { 35 + const bottom = Math.ceil(toNum(args[0])); 36 + const top = Math.floor(toNum(args[1])); 37 + return Math.floor(Math.random() * (top - bottom + 1)) + bottom; 38 + } 39 + 40 + // --- Conditional aggregation --- 41 + case 'SUMIF': { 42 + const range = Array.isArray(args[0]) ? args[0] : [args[0]]; 43 + const criteria = args[1]; 44 + const sumRange = args[2] != null ? (Array.isArray(args[2]) ? args[2] : [args[2]]) : range; 45 + let sum = 0; 46 + for (let i = 0; i < range.length; i++) { 47 + if (matchCriteria(range[i], criteria)) { 48 + sum += toNum(sumRange[i] ?? 0); 49 + } 50 + } 51 + return sum; 52 + } 53 + 54 + case 'COUNTIF': { 55 + const range = Array.isArray(args[0]) ? args[0] : [args[0]]; 56 + const criteria = args[1]; 57 + return range.filter(v => matchCriteria(v, criteria)).length; 58 + } 59 + 60 + case 'AVERAGEIF': { 61 + const range = Array.isArray(args[0]) ? args[0] : [args[0]]; 62 + const criteria = args[1]; 63 + const avgRange = args[2] != null ? (Array.isArray(args[2]) ? args[2] : [args[2]]) : range; 64 + const vals: number[] = []; 65 + for (let i = 0; i < range.length; i++) { 66 + if (matchCriteria(range[i], criteria)) vals.push(toNum(avgRange[i] ?? 0)); 67 + } 68 + return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : '#DIV/0!'; 69 + } 70 + 71 + case 'SUMIFS': { 72 + // SUMIFS(sum_range, criteria_range1, criteria1, [criteria_range2, criteria2], ...) 73 + const sumRange = Array.isArray(args[0]) ? args[0] : [args[0]]; 74 + const criteriaCount = Math.floor((args.length - 1) / 2); 75 + let sum = 0; 76 + for (let i = 0; i < sumRange.length; i++) { 77 + let allMatch = true; 78 + for (let c = 0; c < criteriaCount; c++) { 79 + const critRange = Array.isArray(args[1 + c * 2]) ? args[1 + c * 2] : [args[1 + c * 2]]; 80 + const criteria = args[2 + c * 2]; 81 + if (!matchCriteriaWild((critRange as unknown[])[i], criteria)) { allMatch = false; break; } 82 + } 83 + if (allMatch) sum += toNum(sumRange[i] ?? 0); 84 + } 85 + return sum; 86 + } 87 + 88 + case 'COUNTIFS': { 89 + // COUNTIFS(criteria_range1, criteria1, [criteria_range2, criteria2], ...) 90 + const criteriaCount = Math.floor(args.length / 2); 91 + if (criteriaCount === 0) return 0; 92 + const firstRange = Array.isArray(args[0]) ? args[0] : [args[0]]; 93 + let count = 0; 94 + for (let i = 0; i < firstRange.length; i++) { 95 + let allMatch = true; 96 + for (let c = 0; c < criteriaCount; c++) { 97 + const critRange = Array.isArray(args[c * 2]) ? args[c * 2] : [args[c * 2]]; 98 + const criteria = args[1 + c * 2]; 99 + if (!matchCriteriaWild((critRange as unknown[])[i], criteria)) { allMatch = false; break; } 100 + } 101 + if (allMatch) count++; 102 + } 103 + return count; 104 + } 105 + 106 + case 'AVERAGEIFS': { 107 + // AVERAGEIFS(average_range, criteria_range1, criteria1, ...) 108 + const avgRange = Array.isArray(args[0]) ? args[0] : [args[0]]; 109 + const criteriaCount = Math.floor((args.length - 1) / 2); 110 + const vals: number[] = []; 111 + for (let i = 0; i < avgRange.length; i++) { 112 + let allMatch = true; 113 + for (let c = 0; c < criteriaCount; c++) { 114 + const critRange = Array.isArray(args[1 + c * 2]) ? args[1 + c * 2] : [args[1 + c * 2]]; 115 + const criteria = args[2 + c * 2]; 116 + if (!matchCriteriaWild((critRange as unknown[])[i], criteria)) { allMatch = false; break; } 117 + } 118 + if (allMatch) vals.push(toNum(avgRange[i] ?? 0)); 119 + } 120 + return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : '#DIV/0!'; 121 + } 122 + 123 + // --- Statistics --- 124 + case 'MEDIAN': { 125 + const n = nums(args).sort((a, b) => a - b); 126 + if (!n.length) return 0; 127 + const mid = Math.floor(n.length / 2); 128 + return n.length % 2 ? n[mid] : (n[mid - 1] + n[mid]) / 2; 129 + } 130 + 131 + case 'STDEV': { 132 + const n = nums(args); 133 + if (n.length < 2) return '#DIV/0!'; 134 + const mean = n.reduce((a, b) => a + b, 0) / n.length; 135 + const variance = n.reduce((a, b) => a + (b - mean) ** 2, 0) / (n.length - 1); 136 + return Math.sqrt(variance); 137 + } 138 + 139 + case 'LARGE': { 140 + const lgN = nums([args[0]]).sort((a, b) => b - a); 141 + const lgK = toNum(args[1]); 142 + if (lgK < 1 || lgK > lgN.length) return '#NUM!'; 143 + return lgN[lgK - 1]; 144 + } 145 + case 'SMALL': { 146 + const smN = nums([args[0]]).sort((a, b) => a - b); 147 + const smK = toNum(args[1]); 148 + if (smK < 1 || smK > smN.length) return '#NUM!'; 149 + return smN[smK - 1]; 150 + } 151 + case 'RANK': { 152 + const rkVal = toNum(args[0]); 153 + const rkN = nums([args[1]]); 154 + const rkOrder = args[2] !== undefined ? toNum(args[2]) : 0; 155 + const rkSorted = [...rkN].sort((a, b) => rkOrder ? a - b : b - a); 156 + const rkIdx = rkSorted.indexOf(rkVal); 157 + return rkIdx === -1 ? '#N/A' : rkIdx + 1; 158 + } 159 + case 'PERCENTILE': { 160 + const pcN = nums([args[0]]).sort((a, b) => a - b); 161 + const pcK = toNum(args[1]); 162 + if (pcK < 0 || pcK > 1 || pcN.length === 0) return '#NUM!'; 163 + const pcIdx = pcK * (pcN.length - 1); 164 + const pcLo = Math.floor(pcIdx); 165 + const pcHi = Math.ceil(pcIdx); 166 + if (pcLo === pcHi) return pcN[pcLo]; 167 + return pcN[pcLo] + (pcN[pcHi] - pcN[pcLo]) * (pcIdx - pcLo); 168 + } 169 + case 'VAR': { 170 + const varN = nums(args); 171 + if (varN.length < 2) return '#DIV/0!'; 172 + const varMean = varN.reduce((a, b) => a + b, 0) / varN.length; 173 + return varN.reduce((a, b) => a + (b - varMean) ** 2, 0) / (varN.length - 1); 174 + } 175 + case 'VARP': { 176 + const vpN = nums(args); 177 + if (vpN.length === 0) return '#DIV/0!'; 178 + const vpMean = vpN.reduce((a, b) => a + b, 0) / vpN.length; 179 + return vpN.reduce((a, b) => a + (b - vpMean) ** 2, 0) / vpN.length; 180 + } 181 + case 'STDEVP': { 182 + const sdpN = nums(args); 183 + if (sdpN.length === 0) return '#DIV/0!'; 184 + const sdpMean = sdpN.reduce((a, b) => a + b, 0) / sdpN.length; 185 + return Math.sqrt(sdpN.reduce((a, b) => a + (b - sdpMean) ** 2, 0) / sdpN.length); 186 + } 187 + 188 + // --- Additional math --- 189 + case 'SUMPRODUCT': { 190 + const spArrays = args.map(a => Array.isArray(a) ? (a as unknown[]).map(toNum) : [toNum(a)]); 191 + const spLen = Math.min(...spArrays.map(a => a.length)); 192 + let spSum = 0; 193 + for (let i = 0; i < spLen; i++) { 194 + let spProd = 1; 195 + for (const arr of spArrays) spProd *= arr[i]; 196 + spSum += spProd; 197 + } 198 + return spSum; 199 + } 200 + case 'PRODUCT': { 201 + const pn = nums(args); 202 + return pn.length ? pn.reduce((a, b) => a * b, 1) : 0; 203 + } 204 + case 'SIGN': { 205 + const sv = toNum(args[0]); 206 + return sv > 0 ? 1 : sv < 0 ? -1 : 0; 207 + } 208 + case 'EVEN': { 209 + const ev2 = toNum(args[0]); 210 + const evRound = ev2 >= 0 ? Math.ceil(ev2) : Math.floor(ev2); 211 + if (evRound % 2 === 0) return evRound; 212 + return ev2 >= 0 ? evRound + 1 : evRound - 1; 213 + } 214 + case 'ODD': { 215 + const ov = toNum(args[0]); 216 + const ovRound = ov >= 0 ? Math.ceil(ov) : Math.floor(ov); 217 + if (ovRound === 0) return ov >= 0 ? 1 : -1; 218 + if (Math.abs(ovRound) % 2 === 1) return ovRound; 219 + return ov >= 0 ? ovRound + 1 : ovRound - 1; 220 + } 221 + case 'CEILING': { 222 + const cNum = toNum(args[0]); 223 + const cSig = toNum(args[1]); 224 + if (cSig === 0) return 0; 225 + if (cNum > 0 && cSig < 0) return '#NUM!'; 226 + return Math.ceil(cNum / cSig) * cSig; 227 + } 228 + case 'FLOOR': { 229 + const fNum = toNum(args[0]); 230 + const fSig = toNum(args[1]); 231 + if (fSig === 0) return 0; 232 + if (fNum > 0 && fSig < 0) return '#NUM!'; 233 + return Math.floor(fNum / fSig) * fSig; 234 + } 235 + case 'FACT': { 236 + const fn2 = Math.floor(toNum(args[0])); 237 + if (fn2 < 0) return '#NUM!'; 238 + if (fn2 > 170) return '#NUM!'; // 171! exceeds Number.MAX_VALUE 239 + if (fn2 <= 1) return 1; 240 + let fResult = 1; 241 + for (let i = 2; i <= fn2; i++) fResult *= i; 242 + return fResult; 243 + } 244 + case 'COMBIN': { 245 + const cn = Math.floor(toNum(args[0])); 246 + const ck = Math.floor(toNum(args[1])); 247 + if (ck < 0 || ck > cn || cn < 0) return '#NUM!'; 248 + if (ck === 0 || ck === cn) return 1; 249 + let cResult = 1; 250 + for (let i = 0; i < Math.min(ck, cn - ck); i++) { 251 + cResult = cResult * (cn - i) / (i + 1); 252 + } 253 + return Math.round(cResult); 254 + } 255 + case 'GCD': { 256 + const gcdVals = flat(args).map(v => Math.abs(Math.floor(toNum(v)))); 257 + let ga = gcdVals[0] ?? 0; 258 + for (let gi = 1; gi < gcdVals.length; gi++) { 259 + let gb = gcdVals[gi]; 260 + while (gb) { [ga, gb] = [gb, ga % gb]; } 261 + } 262 + return ga; 263 + } 264 + case 'LCM': { 265 + const lcmVals = flat(args).map(v => Math.abs(Math.floor(toNum(v)))); 266 + let result = lcmVals[0] ?? 0; 267 + for (let li = 1; li < lcmVals.length; li++) { 268 + const b = lcmVals[li]; 269 + if (result === 0 && b === 0) continue; 270 + let lg = result, lt = b; 271 + while (lt) { [lg, lt] = [lt, lg % lt]; } 272 + result = (result / lg) * b; 273 + } 274 + return result; 275 + } 276 + case 'QUOTIENT': { 277 + const qd = toNum(args[1]); 278 + if (qd === 0) return '#DIV/0!'; 279 + return Math.trunc(toNum(args[0]) / qd); 280 + } 281 + 282 + // --- Trigonometric --- 283 + case 'SIN': return Math.sin(toNum(args[0])); 284 + case 'COS': return Math.cos(toNum(args[0])); 285 + case 'TAN': return Math.tan(toNum(args[0])); 286 + case 'ASIN': return Math.asin(toNum(args[0])); 287 + case 'ACOS': return Math.acos(toNum(args[0])); 288 + case 'ATAN': return Math.atan(toNum(args[0])); 289 + case 'ATAN2': return Math.atan2(toNum(args[1]), toNum(args[0])); 290 + case 'DEGREES': return toNum(args[0]) * (180 / Math.PI); 291 + case 'RADIANS': return toNum(args[0]) * (Math.PI / 180); 292 + 293 + default: return undefined; 294 + } 295 + }
+96
src/sheets/formula-text.ts
··· 1 + /** 2 + * String/text formula functions. 3 + * 4 + * CONCATENATE, LEN, LEFT, RIGHT, MID, UPPER, LOWER, TRIM, etc. 5 + */ 6 + 7 + import { toNum, escapeRegex, flat, formatValue } from './formula-helpers.js'; 8 + 9 + export function callTextFunction(name: string, args: unknown[]): unknown | undefined { 10 + switch (name) { 11 + case 'CONCATENATE': return flat(args).map(String).join(''); 12 + case 'LEN': return String(args[0]).length; 13 + case 'LEFT': return String(args[0]).slice(0, toNum(args[1] ?? 1)); 14 + case 'RIGHT': return String(args[0]).slice(-toNum(args[1] ?? 1)); 15 + case 'MID': return String(args[0]).slice(toNum(args[1]) - 1, toNum(args[1]) - 1 + toNum(args[2])); 16 + case 'UPPER': return String(args[0]).toUpperCase(); 17 + case 'LOWER': return String(args[0]).toLowerCase(); 18 + case 'TRIM': return String(args[0]).trim(); 19 + case 'SUBSTITUTE': { 20 + const str = String(args[0]); 21 + const old = String(args[1]); 22 + const rep = String(args[2]); 23 + if (args[3] != null) { 24 + const instance = toNum(args[3]); 25 + let count = 0; 26 + return str.replace(new RegExp(escapeRegex(old), 'g'), (match) => { 27 + count++; 28 + return count === instance ? rep : match; 29 + }); 30 + } 31 + return str.replaceAll(old, rep); 32 + } 33 + case 'FIND': { 34 + // FIND is case-sensitive (per Excel spec) 35 + const idx = String(args[1]).indexOf(String(args[0]), toNum(args[2] ?? 1) - 1); 36 + return idx === -1 ? '#VALUE!' : idx + 1; 37 + } 38 + case 'SEARCH': { 39 + // SEARCH is case-insensitive 40 + const idx = String(args[1]).toUpperCase().indexOf(String(args[0]).toUpperCase(), toNum(args[2] ?? 1) - 1); 41 + return idx === -1 ? '#VALUE!' : idx + 1; 42 + } 43 + case 'TEXT': return formatValue(toNum(args[0]), String(args[1])); 44 + case 'VALUE': return toNum(args[0]); 45 + 46 + case 'TEXTJOIN': { 47 + const delimiter = String(args[0]); 48 + const ignoreEmpty = Boolean(args[1]); 49 + const values: unknown[] = []; 50 + for (let i = 2; i < args.length; i++) { 51 + if (Array.isArray(args[i])) { 52 + for (const v of args[i] as unknown[]) values.push(v); 53 + } else { 54 + values.push(args[i]); 55 + } 56 + } 57 + const filtered = ignoreEmpty ? values.filter(v => v !== '' && v !== null && v !== undefined) : values; 58 + return filtered.map(String).join(delimiter); 59 + } 60 + 61 + case 'CONCAT': { 62 + const values: unknown[] = []; 63 + for (const arg of args) { 64 + if (Array.isArray(arg)) { 65 + for (const v of arg as unknown[]) { 66 + if (v !== '' && v !== null && v !== undefined) values.push(v); 67 + } 68 + } else { 69 + values.push(arg); 70 + } 71 + } 72 + return values.map(String).join(''); 73 + } 74 + 75 + case 'PROPER': { 76 + return String(args[0]).toLowerCase().replace(/(?:^|\s|[^\w])\w/g, c => c.toUpperCase()); 77 + } 78 + case 'REPT': { const rCount = Math.max(0, Math.floor(toNum(args[1]))); if (rCount > 32767) return '#VALUE!'; return String(args[0]).repeat(rCount); } 79 + case 'EXACT': return String(args[0]) === String(args[1]); 80 + case 'REPLACE': { 81 + const rpText = String(args[0]); 82 + const rpStart = toNum(args[1]) - 1; 83 + const rpNum = toNum(args[2]); 84 + const rpNew = String(args[3]); 85 + return rpText.slice(0, rpStart) + rpNew + rpText.slice(rpStart + rpNum); 86 + } 87 + case 'CLEAN': return String(args[0]).replace(/[\x00-\x1F]/g, ''); 88 + case 'CHAR': return String.fromCharCode(toNum(args[0])); 89 + case 'CODE': { 90 + const codeStr = String(args[0]); 91 + return codeStr.length > 0 ? codeStr.charCodeAt(0) : '#VALUE!'; 92 + } 93 + 94 + default: return undefined; 95 + } 96 + }
+91 -1303
src/sheets/formulas.ts
··· 3 3 * 4 4 * Supports: arithmetic, comparison, string concat (&), cell refs (A1, $A$1), 5 5 * ranges (A1:B5), and a library of common functions. 6 + * 7 + * Function implementations are split into focused modules: 8 + * formula-math.ts — Math, statistical, trigonometric 9 + * formula-text.ts — String/text manipulation 10 + * formula-date.ts — Date and time 11 + * formula-lookup.ts — Lookup and reference 12 + * formula-logical.ts — Logical and information 13 + * formula-financial.ts — Financial calculations 14 + * formula-array.ts — Dynamic array functions 15 + * formula-helpers.ts — Shared utilities (toNum, toBool, cell ref utils, etc.) 6 16 */ 7 17 8 - import type { CellRef, CellValue, CrossSheetResolver, NamedRangesMap, RangeArray, FormatType } from './types.js'; 18 + import type { CellValue, CrossSheetResolver, NamedRangesMap, RangeArray, FormatType } from './types.js'; 9 19 import { parseSparklineArgs } from './sparkline.js'; 10 20 import { executeQuery } from './query.js'; 21 + import { 22 + toNum, toBool, coerceForComparison, flat, 23 + parseRef as _parseRef, colToLetter as _colToLetter, letterToCol as _letterToCol, cellId as _cellId, 24 + } from './formula-helpers.js'; 25 + import { callMathFunction } from './formula-math.js'; 26 + import { callTextFunction } from './formula-text.js'; 27 + import { callDateFunction } from './formula-date.js'; 28 + import { callLookupFunction } from './formula-lookup.js'; 29 + import { callLogicalFunction } from './formula-logical.js'; 30 + import { callFinancialFunction } from './formula-financial.js'; 31 + import { callArrayFunction } from './formula-array.js'; 32 + 33 + // Re-export cell reference utilities (many files import these from formulas.js) 34 + export const parseRef = _parseRef; 35 + export const colToLetter = _colToLetter; 36 + export const letterToCol = _letterToCol; 37 + export const cellId = _cellId; 11 38 12 39 // --- Tokenizer --- 13 40 type TokenTypeValue = 'NUMBER' | 'STRING' | 'BOOLEAN' | 'CELL_REF' | 'CROSS_SHEET_REF' | 'RANGE' | 'FUNCTION' | 'IDENTIFIER' | 'OPERATOR' | 'LPAREN' | 'RPAREN' | 'COMMA' | 'COLON' | 'BANG' | 'EOF'; ··· 27 54 STRING: 'STRING', 28 55 BOOLEAN: 'BOOLEAN', 29 56 CELL_REF: 'CELL_REF', 30 - CROSS_SHEET_REF: 'CROSS_SHEET_REF', // e.g. Sheet2!A1 57 + CROSS_SHEET_REF: 'CROSS_SHEET_REF', 31 58 RANGE: 'RANGE', 32 59 FUNCTION: 'FUNCTION', 33 - IDENTIFIER: 'IDENTIFIER', // named range or unknown identifier 60 + IDENTIFIER: 'IDENTIFIER', 34 61 OPERATOR: 'OPERATOR', 35 62 LPAREN: 'LPAREN', 36 63 RPAREN: 'RPAREN', ··· 57 84 sheetName += s[i++]; 58 85 } 59 86 i++; // skip closing quote 60 - // Expect ! after quoted sheet name 61 87 if (i < s.length && s[i] === '!') { 62 88 i++; // skip ! 63 - // Read the cell ref after ! 64 89 let cellRef = ''; 65 90 while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 66 91 cellRef += s[i++]; ··· 76 101 i++; // skip opening quote 77 102 while (i < s.length) { 78 103 if (s[i] === '"') { 79 - // Excel-style escaped quote: "" → " 80 104 if (i + 1 < s.length && s[i + 1] === '"') { str += '"'; i += 2; continue; } 81 - break; // closing quote 105 + break; 82 106 } 83 107 if (s[i] === '\\' && i + 1 < s.length) { str += s[++i]; i++; continue; } 84 108 str += s[i]; ··· 95 119 let hasDot = false; 96 120 let hasE = false; 97 121 while (i < s.length && /[0-9.eE+-]/.test(s[i])) { 98 - // Handle scientific notation carefully 99 122 if ((s[i] === '+' || s[i] === '-') && num.length > 0 && !/[eE]/.test(num[num.length - 1])) break; 100 - // Only allow one decimal point 101 123 if (s[i] === '.' && hasDot) break; 102 124 if (s[i] === '.') hasDot = true; 103 - // Only allow one E/e 104 125 if ((s[i] === 'e' || s[i] === 'E') && hasE) break; 105 126 if (s[i] === 'e' || s[i] === 'E') hasE = true; 106 127 num += s[i++]; ··· 112 133 // Cell ref, function, boolean, identifier, or cross-sheet ref (Name!A1) 113 134 if (/[A-Za-z$_]/.test(s[i])) { 114 135 let word = ''; 115 - // Allow $ for absolute refs, . for named range names like sales.q1 116 136 while (i < s.length && /[A-Za-z0-9$_.]/.test(s[i])) { 117 137 word += s[i++]; 118 138 } 119 139 120 140 const upper = word.toUpperCase(); 121 141 122 - // Check for TRUE/FALSE 123 - if (upper === 'TRUE') { 124 - tokens.push({ type: TokenType.BOOLEAN, value: true }); 125 - continue; 126 - } 127 - if (upper === 'FALSE') { 128 - tokens.push({ type: TokenType.BOOLEAN, value: false }); 129 - continue; 130 - } 142 + if (upper === 'TRUE') { tokens.push({ type: TokenType.BOOLEAN, value: true }); continue; } 143 + if (upper === 'FALSE') { tokens.push({ type: TokenType.BOOLEAN, value: false }); continue; } 131 144 132 - // Check for cross-sheet ref: word!cellRef (e.g. Sheet2!A1) 145 + // Cross-sheet ref: word!cellRef 133 146 if (i < s.length && s[i] === '!') { 134 - i++; // skip ! 147 + i++; 135 148 let cellRef = ''; 136 149 while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 137 150 cellRef += s[i++]; ··· 140 153 continue; 141 154 } 142 155 143 - // Check if next non-space char is '(' → function 156 + // Check if next non-space char is '(' -> function 144 157 let peek = i; 145 158 while (peek < s.length && s[peek] === ' ') peek++; 146 159 if (peek < s.length && s[peek] === '(') { ··· 148 161 continue; 149 162 } 150 163 151 - // Check if it's a cell reference (e.g., A1, $B$2, AA100) 164 + // Cell reference (e.g., A1, $B$2, AA100) 152 165 const cellRefPattern = /^\$?[A-Z]{1,3}\$?[0-9]+$/i; 153 166 if (cellRefPattern.test(word.replace(/\$/g, ''))) { 154 167 tokens.push({ type: TokenType.CELL_REF, value: upper.replace(/\$/g, '') }); ··· 178 191 else { tokens.push({ type: TokenType.OPERATOR, value: '>' }); i++; } 179 192 continue; 180 193 } 181 - if (s[i] === '=') { 182 - tokens.push({ type: TokenType.OPERATOR, value: '=' }); 183 - i++; 184 - continue; 185 - } 194 + if (s[i] === '=') { tokens.push({ type: TokenType.OPERATOR, value: '=' }); i++; continue; } 186 195 187 196 if (s[i] === '(') { tokens.push({ type: TokenType.LPAREN }); i++; continue; } 188 197 if (s[i] === ')') { tokens.push({ type: TokenType.RPAREN }); i++; continue; } ··· 194 203 if (s[i] === '#') { 195 204 let end = i + 1; 196 205 while (end < s.length && /[A-Za-z0-9/]/.test(s[end])) end++; 197 - // Consume trailing ! or ? 198 206 if (end < s.length && (s[end] === '!' || s[end] === '?')) end++; 199 207 const errStr = s.slice(i, end); 200 208 tokens.push({ type: TokenType.STRING, value: errStr }); ··· 202 210 continue; 203 211 } 204 212 205 - // Unknown character — abort tokenization 206 213 throw new Error(`Unknown character: ${s[i]}`); 207 214 } 208 215 ··· 237 244 return t; 238 245 } 239 246 240 - parse(): unknown { 241 - const result = this.expression(); 242 - return result; 243 - } 247 + parse(): unknown { return this.expression(); } 244 248 245 - // expression → comparison 246 - expression(): unknown { 247 - return this.comparison(); 248 - } 249 + expression(): unknown { return this.comparison(); } 249 250 250 - // comparison → concat (('=' | '<>' | '<' | '>' | '<=' | '>=') concat)? 251 251 comparison(): unknown { 252 252 let left = this.concat(); 253 253 const t = this.peek(); 254 254 if (t.type === TokenType.OPERATOR && ['=', '<>', '<', '>', '<=', '>='].includes(t.value)) { 255 255 this.advance(); 256 256 const right = this.concat(); 257 - // Excel-style comparison: coerce types before comparing 258 257 const [cl, cr] = coerceForComparison(left, right); 259 258 switch (t.value) { 260 259 case '=': return cl === cr; ··· 268 267 return left; 269 268 } 270 269 271 - // concat → addition ('&' addition)* 272 270 concat(): unknown { 273 271 let left = this.addition(); 274 272 while (this.peek().type === TokenType.OPERATOR && this.peek().value === '&') { ··· 279 277 return left; 280 278 } 281 279 282 - // addition → multiplication (('+' | '-') multiplication)* 283 280 addition(): unknown { 284 281 let left = this.multiplication(); 285 282 while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '+' || this.peek().value === '-')) { ··· 290 287 return left; 291 288 } 292 289 293 - // multiplication → power (('*' | '/') power)* 294 290 multiplication(): unknown { 295 291 let left = this.power(); 296 292 while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '*' || this.peek().value === '/')) { ··· 306 302 return left; 307 303 } 308 304 309 - // power → unary ('^' power)? — right-associative (matches Excel) 310 305 power(): unknown { 311 306 const left = this.unary(); 312 307 if (this.peek().type === TokenType.OPERATOR && this.peek().value === '^') { 313 308 this.advance(); 314 - const right = this.power(); // recurse for right-associativity 309 + const right = this.power(); 315 310 return Math.pow(toNum(left), toNum(right)); 316 311 } 317 312 return left; 318 313 } 319 314 320 - // unary → ('-' | '+') unary | primary 321 315 unary(): unknown { 322 316 if (this.peek().type === TokenType.OPERATOR && (this.peek().value === '-' || this.peek().value === '+')) { 323 317 const op = this.advance().value; ··· 327 321 return this.primary(); 328 322 } 329 323 330 - // primary → NUMBER | STRING | BOOLEAN | CELL_REF (':' CELL_REF)? | CROSS_SHEET_REF | IDENTIFIER | FUNCTION '(' args ')' | '(' expression ')' 331 324 primary(): unknown { 332 325 const t = this.peek(); 333 326 334 - if (t.type === TokenType.NUMBER) { 335 - this.advance(); 336 - return t.value; 337 - } 338 - 339 - if (t.type === TokenType.STRING) { 340 - this.advance(); 341 - return t.value; 342 - } 343 - 344 - if (t.type === TokenType.BOOLEAN) { 345 - this.advance(); 346 - return t.value; 347 - } 327 + if (t.type === TokenType.NUMBER) { this.advance(); return t.value; } 328 + if (t.type === TokenType.STRING) { this.advance(); return t.value; } 329 + if (t.type === TokenType.BOOLEAN) { this.advance(); return t.value; } 348 330 349 - // Cross-sheet reference: Sheet2!A1 or 'Sheet Name'!A1:B5 350 331 if (t.type === TokenType.CROSS_SHEET_REF) { 351 332 this.advance(); 352 333 const { sheetName, ref } = t.value; 353 - // Check for range in the ref (e.g. A1:B5 was captured as one string) 354 - if (ref.includes(':')) { 355 - return this.resolveCrossSheetRange(sheetName, ref); 356 - } 334 + if (ref.includes(':')) return this.resolveCrossSheetRange(sheetName, ref); 357 335 return this.resolveCrossSheetCell(sheetName, ref); 358 336 } 359 337 360 338 if (t.type === TokenType.CELL_REF) { 361 339 this.advance(); 362 - // Check if it's a range 363 340 if (this.peek().type === TokenType.COLON) { 364 - this.advance(); // consume ':' 341 + this.advance(); 365 342 const end = this.expect(TokenType.CELL_REF); 366 343 return this.resolveRange(t.value, end.value); 367 344 } 368 345 return this.getCellValue(t.value); 369 346 } 370 347 371 - // Named range identifier (not a function, not a cell ref) 372 348 if (t.type === TokenType.IDENTIFIER) { 373 349 this.advance(); 374 - // Check LET-scoped variables first 375 350 const identLower = t.value.toLowerCase(); 376 - if (this._letScope && identLower in this._letScope) { 377 - return this._letScope[identLower]; 378 - } 351 + if (this._letScope && identLower in this._letScope) return this._letScope[identLower]; 379 352 return this.resolveNamedRange(t.value); 380 353 } 381 354 382 355 if (t.type === TokenType.FUNCTION) { 383 356 this.advance(); 384 - // Special handling for LET — needs compile-time name resolution 385 - if (t.value === 'LET') { 386 - return this.parseLet(); 387 - } 388 - // Special handling for LAMBDA — user-defined functions (#88) 389 - if (t.value === 'LAMBDA') { 390 - return this.parseLambda(); 391 - } 392 - // Special handling for INDIRECT — needs access to getCellValue and crossSheetResolver 393 - if (t.value === 'INDIRECT') { 394 - return this.parseIndirect(); 395 - } 396 - // Special handling for ROW/COLUMN — needs raw cell ref, not its value 397 - if (t.value === 'ROW' || t.value === 'COLUMN') { 398 - return this.parseRowColumn(t.value as 'ROW' | 'COLUMN'); 399 - } 357 + if (t.value === 'LET') return this.parseLet(); 358 + if (t.value === 'LAMBDA') return this.parseLambda(); 359 + if (t.value === 'INDIRECT') return this.parseIndirect(); 360 + if (t.value === 'ROW' || t.value === 'COLUMN') return this.parseRowColumn(t.value as 'ROW' | 'COLUMN'); 400 361 this.expect(TokenType.LPAREN); 401 362 const args = []; 402 363 if (this.peek().type !== TokenType.RPAREN) { 403 - // Handle first arg — could be omitted if first token is COMMA 404 - if (this.peek().type === TokenType.COMMA) { 405 - args.push(undefined); 406 - } else { 407 - args.push(this.parseFunctionArg()); 408 - } 364 + if (this.peek().type === TokenType.COMMA) { args.push(undefined); } 365 + else { args.push(this.parseFunctionArg()); } 409 366 while (this.peek().type === TokenType.COMMA) { 410 367 this.advance(); 411 - // Handle omitted arguments (consecutive commas or trailing comma before RPAREN) 412 - if (this.peek().type === TokenType.COMMA || this.peek().type === TokenType.RPAREN) { 413 - args.push(undefined); 414 - } else { 415 - args.push(this.parseFunctionArg()); 416 - } 368 + if (this.peek().type === TokenType.COMMA || this.peek().type === TokenType.RPAREN) { args.push(undefined); } 369 + else { args.push(this.parseFunctionArg()); } 417 370 } 418 371 } 419 372 this.expect(TokenType.RPAREN); ··· 430 383 throw new Error(`Unexpected token: ${t.type}`); 431 384 } 432 385 433 - // Function args can be ranges (CELL_REF:CELL_REF), cross-sheet ranges, named ranges, or expressions 434 386 parseFunctionArg(): unknown { 435 - // Cross-sheet ref in function arg (range already parsed in tokenizer) 436 387 if (this.peek().type === TokenType.CROSS_SHEET_REF) { 437 388 const saved = this.pos; 438 389 const t = this.advance(); 439 390 const { sheetName, ref } = t.value; 440 - if (ref.includes(':')) { 441 - return this.resolveCrossSheetRange(sheetName, ref); 442 - } 443 - // Single cross-sheet cell ref; could be part of an expression, backtrack 391 + if (ref.includes(':')) return this.resolveCrossSheetRange(sheetName, ref); 444 392 this.pos = saved; 445 393 return this.expression(); 446 394 } 447 - // Peek ahead for range pattern: CELL_REF COLON CELL_REF 448 395 if (this.peek().type === TokenType.CELL_REF) { 449 396 const saved = this.pos; 450 397 const start = this.advance(); ··· 455 402 return this.resolveRange(start.value, end.value); 456 403 } 457 404 } 458 - // Not a range — backtrack and parse as expression 459 405 this.pos = saved; 460 406 } 461 - // Named range identifier in function arg 462 407 if (this.peek().type === TokenType.IDENTIFIER) { 463 408 const saved = this.pos; 464 409 const t = this.advance(); 465 410 const resolved = this.resolveNamedRange(t.value); 466 - if (Array.isArray(resolved)) { 467 - return resolved; 468 - } 469 - // Not a named range, backtrack 411 + if (Array.isArray(resolved)) return resolved; 470 412 this.pos = saved; 471 413 } 472 414 return this.expression(); 473 415 } 474 416 475 - // Parse LET(name1, value1, [name2, value2, ...], calculation) 476 417 parseLet(): unknown { 477 418 this.expect(TokenType.LPAREN); 478 419 const prevScope = this._letScope ? { ...this._letScope } : null; 479 420 if (!this._letScope) this._letScope = {}; 480 421 481 - // Collect name/value pairs 482 - // LET requires at least 3 args: name, value, calculation 483 - // We read pairs of (identifier, expression) then a final expression 484 - const names = []; 485 - const values = []; 486 - 487 - // Read first name 488 422 while (true) { 489 - // Current token should be an identifier (the variable name) 490 - // The tokenizer may have produced FUNCTION, IDENTIFIER, or CELL_REF for the name 491 423 const nameToken = this.peek(); 492 424 let varName; 493 425 if (nameToken.type === TokenType.IDENTIFIER || nameToken.type === TokenType.FUNCTION) { 494 426 this.advance(); 495 427 varName = nameToken.value.toLowerCase(); 496 428 } else if (nameToken.type === TokenType.CELL_REF) { 497 - // Allow cell-ref-like identifiers as LET names (e.g., "x" wouldn't hit this, but handle gracefully) 498 429 this.advance(); 499 430 varName = nameToken.value.toLowerCase(); 500 431 } else { ··· 502 433 } 503 434 504 435 this.expect(TokenType.COMMA); 505 - // Parse the value expression 506 436 const val = this.parseFunctionArg(); 507 437 this._letScope[varName] = val; 508 - names.push(varName); 509 - values.push(val); 510 438 511 - // Now check: is the next thing a COMMA followed by what looks like another name/value pair, 512 - // or is it RPAREN (meaning we just got the calculation as the value)? 513 - // Actually, the grammar is: after value, if COMMA follows, there's either another name/value pair or the final calc. 514 - // We need to peek ahead: COMMA + IDENTIFIER/FUNCTION + COMMA means more pairs. 515 - // COMMA + expression + RPAREN means final calculation. 516 439 if (this.peek().type === TokenType.COMMA) { 517 - this.advance(); // consume comma 518 - 519 - // Check if what follows is "identifier COMMA" pattern (another name/value pair) 520 - // or if it's the final calculation expression 521 - const savedPos = this.pos; 440 + this.advance(); 522 441 const nextToken = this.peek(); 523 442 if ((nextToken.type === TokenType.IDENTIFIER || nextToken.type === TokenType.FUNCTION) && 524 443 this.tokens[this.pos + 1]?.type === TokenType.COMMA) { 525 - // Another name/value pair — continue the loop 526 444 continue; 527 445 } 528 - // It's the final calculation 529 446 const result = this.parseFunctionArg(); 530 447 this.expect(TokenType.RPAREN); 531 - // Restore previous scope 532 448 this._letScope = prevScope; 533 449 return result; 534 450 } else if (this.peek().type === TokenType.RPAREN) { 535 - // The "value" we just parsed IS the calculation (single name/value pair case) 536 - // Wait, this means we had LET(name, calc) with only 2 args, which is invalid. 537 - // Actually, in our loop: we read name, comma, value. If next is RPAREN, 538 - // then value IS the final calculation expression. 539 - // But LET needs at least name, value, calculation (3 args). 540 - // Re-reading the logic: we consumed name + comma + value. 541 - // If RPAREN follows, that means: LET(name, value) — only 2 args, invalid. 542 - // But actually for LET(name, value, calc): after reading name+comma, we parse value. 543 - // Then we see COMMA, advance, see it's the final calc, parse it, expect RPAREN. 544 - // So hitting RPAREN here means LET(name, expression) — invalid, but let's be lenient 545 - // and just return the value. 546 - this.advance(); // consume RPAREN 451 + this.advance(); 547 452 this._letScope = prevScope; 548 453 return val; 549 454 } 550 455 } 551 456 } 552 457 553 - // Parse LAMBDA(param1, param2, ..., body) — creates a callable closure (#88) 554 - // Usage: LAMBDA(x, y, x+y)(2, 3) → 5 555 458 parseLambda(): unknown { 556 459 this.expect(TokenType.LPAREN); 557 - 558 - // Collect parameter names and body tokens 559 - // All args except the last are parameter names; the last is the body expression. 560 - // We need to save token positions and re-parse the body with param bindings. 561 460 const paramNames: string[] = []; 562 - const bodyStartPositions: number[] = []; 563 - 564 - // Read tokens to find params and body 565 - // Strategy: collect identifiers separated by commas, the final arg before RPAREN is the body 566 - // We save the start position of each arg, then retroactively determine which are params vs body 567 - const argStarts: number[] = []; 568 - let depth = 1; // already consumed LPAREN 569 - 570 - // First pass: find where each argument starts 571 - argStarts.push(this.pos); 572 - const savedPos = this.pos; 461 + let depth = 1; 573 462 574 - // Count args by scanning tokens (tracking paren depth) 575 463 let scanPos = this.pos; 576 464 const argBoundaries: number[] = [scanPos]; 577 465 while (scanPos < this.tokens.length) { ··· 585 473 } 586 474 scanPos++; 587 475 } 588 - const rparenPos = scanPos; // position of closing RPAREN 476 + const rparenPos = scanPos; 589 477 590 - // All args except the last are parameter names 591 478 for (let i = 0; i < argBoundaries.length - 1; i++) { 592 479 const tok = this.tokens[argBoundaries[i]]; 593 480 if (tok.type === TokenType.IDENTIFIER || tok.type === TokenType.FUNCTION || tok.type === TokenType.CELL_REF) { ··· 595 482 } 596 483 } 597 484 598 - // Position parser at the start of the body (last arg) 599 485 this.pos = argBoundaries[argBoundaries.length - 1]; 486 + const bodyTokens = this.tokens.slice(this.pos, rparenPos); 600 487 601 - // Save the body token range for deferred evaluation 602 - const bodyStart = this.pos; 603 - const bodyEnd = rparenPos; 604 - const bodyTokens = this.tokens.slice(bodyStart, bodyEnd); 605 - 606 - // Skip past the body and closing RPAREN 607 488 this.pos = rparenPos; 608 489 this.expect(TokenType.RPAREN); 609 490 610 - // Check for immediate invocation: LAMBDA(...)(<args>) 611 491 if (this.peek().type === TokenType.LPAREN) { 612 - this.advance(); // consume LPAREN 492 + this.advance(); 613 493 const callArgs: unknown[] = []; 614 494 if (this.peek().type !== TokenType.RPAREN) { 615 495 callArgs.push(this.parseFunctionArg()); ··· 620 500 } 621 501 this.expect(TokenType.RPAREN); 622 502 623 - // Evaluate body with params bound to call args 624 503 const prevScope = this._letScope ? { ...this._letScope } : {}; 625 504 this._letScope = this._letScope || {}; 626 505 for (let i = 0; i < paramNames.length; i++) { 627 506 this._letScope[paramNames[i]] = callArgs[i] ?? 0; 628 507 } 629 508 630 - // Parse the body tokens 631 509 const bodyParser = new Parser( 632 510 [...bodyTokens, { type: TokenType.EOF, value: undefined }], 633 511 this.getCellValue, ··· 641 519 return result; 642 520 } 643 521 644 - // Not immediately invoked — return a sentinel (lambdas must be invoked inline) 645 522 return '#VALUE!'; 646 523 } 647 524 648 - // Parse INDIRECT(ref_text) — evaluates its argument as a string, then resolves as a cell reference 649 525 parseIndirect(): unknown { 650 526 this.expect(TokenType.LPAREN); 651 527 const refText = String(this.expression()); ··· 653 529 654 530 if (!refText) return '#REF!'; 655 531 656 - // Uppercase the cell ref portion (sheet names are case-sensitive) 657 - // Check for cross-sheet ref: contains '!' 658 532 const bangIdx = refText.indexOf('!'); 659 533 if (bangIdx !== -1) { 660 534 let sheetName: string; 661 535 let cellRefStr: string; 662 536 663 - // Handle quoted sheet names: 'Sheet Name'!A1 664 537 if (refText.startsWith("'")) { 665 538 const closeQuote = refText.indexOf("'", 1); 666 539 if (closeQuote === -1 || refText[closeQuote + 1] !== '!') return '#REF!'; ··· 671 544 cellRefStr = refText.slice(bangIdx + 1).toUpperCase(); 672 545 } 673 546 674 - // Validate the cell ref portion 675 547 const parsed = parseRef(cellRefStr); 676 548 if (!parsed) return '#REF!'; 677 - 678 549 if (!this.crossSheetResolver) return '#REF!'; 679 550 if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 680 551 return this.crossSheetResolver.getSheetCellValue(sheetName, cellRefStr); 681 552 } 682 553 683 - // Simple same-sheet ref — strip $ signs and uppercase 684 554 const cleaned = refText.toUpperCase().replace(/\$/g, ''); 685 555 const parsed = parseRef(cleaned); 686 556 if (!parsed) return '#REF!'; 687 557 return this.getCellValue(cleaned); 688 558 } 689 559 690 - // Parse ROW(ref) / COLUMN(ref) — needs the raw cell reference, not its value 691 560 parseRowColumn(fn: 'ROW' | 'COLUMN'): unknown { 692 561 this.expect(TokenType.LPAREN); 693 562 const t = this.peek(); ··· 698 567 if (!ref) return '#REF!'; 699 568 return fn === 'ROW' ? ref.row : ref.col; 700 569 } 701 - // If not a direct cell ref, evaluate the expression (fallback) 702 570 const val = this.expression(); 703 571 this.expect(TokenType.RPAREN); 704 - // Try to parse the evaluated value as a ref string 705 572 const refStr = String(val).toUpperCase().replace(/\$/g, ''); 706 573 const ref = parseRef(refStr); 707 574 if (!ref) return '#REF!'; ··· 724 591 values.push(this.getCellValue(ref)); 725 592 } 726 593 } 727 - // Tag the array with dimensions so VLOOKUP/HLOOKUP can interpret it as 2D 728 594 values._rangeRows = rowMax - rowMin + 1; 729 595 values._rangeCols = colMax - colMin + 1; 730 596 return values; 731 597 } 732 598 733 - // Resolve a cross-sheet single cell reference 734 599 resolveCrossSheetCell(sheetName: string, cellRef: string): unknown { 735 600 if (!this.crossSheetResolver) return '#REF!'; 736 601 if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 737 602 return this.crossSheetResolver.getSheetCellValue(sheetName, cellRef); 738 603 } 739 604 740 - // Resolve a cross-sheet range reference (e.g. Sheet2!A1:B5) 741 605 resolveCrossSheetRange(sheetName: string, rangeStr: string): RangeArray | string { 742 606 if (!this.crossSheetResolver) return '#REF!'; 743 607 if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; ··· 762 626 return values; 763 627 } 764 628 765 - // Resolve a named range identifier to its values 766 629 resolveNamedRange(name: string): unknown { 767 630 const key = name.toLowerCase(); 768 631 const entry = this.namedRanges[key]; 769 - if (!entry) { 770 - return `#NAME? (${name})`; 771 - } 632 + if (!entry) return `#NAME? (${name})`; 772 633 const rangeStr = entry.range; 773 - // Determine the getCellValue to use — could be cross-sheet or same-sheet 774 634 const parts = rangeStr.split(':'); 775 - if (parts.length === 2) { 776 - return this.resolveRange(parts[0], parts[1]); 777 - } 778 - // Single cell named range 635 + if (parts.length === 2) return this.resolveRange(parts[0], parts[1]); 779 636 return this.getCellValue(rangeStr); 780 637 } 781 638 } 782 639 783 - // --- Function library --- 784 - function callFunction(name: string, args: unknown[]): unknown { 785 - // Flatten any range arrays in args 786 - const flat = (arr: unknown[]): unknown[] => (arr as unknown[]).flat(Infinity).filter(v => v !== '' && v !== null && v !== undefined); 787 - const nums = (arr: unknown[]): number[] => flat(arr).map(toNum).filter(v => !isNaN(v)); 788 - 789 - switch (name) { 790 - case 'SUM': return nums(args).reduce((a, b) => a + b, 0); 791 - case 'AVERAGE': { const n = nums(args); return n.length ? n.reduce((a, b) => a + b, 0) / n.length : '#DIV/0!'; } 792 - case 'COUNT': return nums(args).length; 793 - case 'COUNTA': return flat(args).length; 794 - case 'MIN': { const n = nums(args); return n.length ? Math.min(...n) : 0; } 795 - case 'MAX': { const n = nums(args); return n.length ? Math.max(...n) : 0; } 796 - case 'ADD': return toNum(args[0]) + toNum(args[1]); 797 - case 'MINUS': return toNum(args[0]) - toNum(args[1]); 798 - case 'MULTIPLY': return toNum(args[0]) * toNum(args[1]); 799 - case 'DIVIDE': { const d = toNum(args[1]); return d === 0 ? '#DIV/0!' : toNum(args[0]) / d; } 800 - case 'ABS': return Math.abs(toNum(args[0])); 801 - case 'ROUND': return Math.round(toNum(args[0]) * Math.pow(10, toNum(args[1] ?? 0))) / Math.pow(10, toNum(args[1] ?? 0)); 802 - case 'ROUNDUP': { const rv = toNum(args[0]); const f = Math.pow(10, toNum(args[1] ?? 0)); return (rv >= 0 ? Math.ceil(rv * f) : Math.floor(rv * f)) / f; } 803 - case 'ROUNDDOWN': { const rv = toNum(args[0]); const f = Math.pow(10, toNum(args[1] ?? 0)); return (rv >= 0 ? Math.floor(rv * f) : Math.ceil(rv * f)) / f; } 804 - case 'INT': return Math.floor(toNum(args[0])); 805 - case 'MOD': { const mDiv = toNum(args[1]); if (mDiv === 0) return '#DIV/0!'; const mR = toNum(args[0]) % mDiv; return (mR !== 0 && Math.sign(mR) !== Math.sign(mDiv)) ? mR + mDiv : mR; } 806 - case 'POWER': return Math.pow(toNum(args[0]), toNum(args[1])); 807 - case 'SQRT': return Math.sqrt(toNum(args[0])); 808 - case 'LOG': return args.length > 1 ? Math.log(toNum(args[0])) / Math.log(toNum(args[1])) : Math.log10(toNum(args[0])); 809 - case 'LN': return Math.log(toNum(args[0])); 810 - case 'EXP': return Math.exp(toNum(args[0])); 811 - case 'PI': return Math.PI; 812 - case 'RAND': return Math.random(); 813 - case 'RANDBETWEEN': { 814 - const bottom = Math.ceil(toNum(args[0])); 815 - const top = Math.floor(toNum(args[1])); 816 - return Math.floor(Math.random() * (top - bottom + 1)) + bottom; 817 - } 818 - 819 - case 'IF': return toBool(args[0]) ? args[1] : (args[2] ?? false); 820 - case 'AND': return flat(args).every(toBool); 821 - case 'OR': return flat(args).some(toBool); 822 - case 'NOT': return !toBool(args[0]); 823 - case 'IFERROR': { 824 - const val = args[0]; 825 - if (typeof val === 'string' && val.startsWith('#')) return args[1] ?? ''; 826 - return val; 827 - } 828 - 829 - case 'CONCATENATE': return flat(args).map(String).join(''); 830 - case 'LEN': return String(args[0]).length; 831 - case 'LEFT': return String(args[0]).slice(0, toNum(args[1] ?? 1)); 832 - case 'RIGHT': return String(args[0]).slice(-toNum(args[1] ?? 1)); 833 - case 'MID': return String(args[0]).slice(toNum(args[1]) - 1, toNum(args[1]) - 1 + toNum(args[2])); 834 - case 'UPPER': return String(args[0]).toUpperCase(); 835 - case 'LOWER': return String(args[0]).toLowerCase(); 836 - case 'TRIM': return String(args[0]).trim(); 837 - case 'SUBSTITUTE': { 838 - const str = String(args[0]); 839 - const old = String(args[1]); 840 - const rep = String(args[2]); 841 - if (args[3] != null) { 842 - const instance = toNum(args[3]); 843 - let count = 0; 844 - return str.replace(new RegExp(escapeRegex(old), 'g'), (match) => { 845 - count++; 846 - return count === instance ? rep : match; 847 - }); 848 - } 849 - return str.replaceAll(old, rep); 850 - } 851 - case 'FIND': { 852 - // FIND is case-sensitive (per Excel spec) 853 - const idx = String(args[1]).indexOf(String(args[0]), toNum(args[2] ?? 1) - 1); 854 - return idx === -1 ? '#VALUE!' : idx + 1; 855 - } 856 - case 'SEARCH': { 857 - // SEARCH is case-insensitive 858 - const idx = String(args[1]).toUpperCase().indexOf(String(args[0]).toUpperCase(), toNum(args[2] ?? 1) - 1); 859 - return idx === -1 ? '#VALUE!' : idx + 1; 860 - } 861 - case 'TEXT': return formatValue(toNum(args[0]), String(args[1])); 862 - case 'VALUE': return toNum(args[0]); 863 - 864 - case 'NOW': return new Date(); 865 - case 'TODAY': { const d = new Date(); d.setHours(0, 0, 0, 0); return d; } 866 - case 'DATE': return new Date(toNum(args[0]), toNum(args[1]) - 1, toNum(args[2])); 867 - case 'YEAR': { const yd = new Date(args[0] as string | number | Date); return isNaN(yd.getTime()) ? '#VALUE!' : yd.getFullYear(); } 868 - case 'MONTH': { const md = new Date(args[0] as string | number | Date); return isNaN(md.getTime()) ? '#VALUE!' : md.getMonth() + 1; } 869 - case 'DAY': { const dd = new Date(args[0] as string | number | Date); return isNaN(dd.getTime()) ? '#VALUE!' : dd.getDate(); } 870 - 871 - case 'VLOOKUP': { 872 - const needle = args[0]; 873 - const range = args[1]; 874 - const colIdx = toNum(args[2]); 875 - const rangeLookup = args[3] !== undefined ? Boolean(args[3]) : true; 876 - if (!Array.isArray(range)) return '#VALUE!'; 877 - const rows = range._rangeRows || range.length; 878 - const cols = range._rangeCols || 1; 879 - if (colIdx < 1 || colIdx > cols) return '#REF!'; 880 - return vlookup(needle, range, rows, cols, colIdx, rangeLookup); 881 - } 882 - 883 - case 'HLOOKUP': { 884 - const needle = args[0]; 885 - const range = args[1]; 886 - const rowIdx = toNum(args[2]); 887 - const rangeLookup = args[3] !== undefined ? Boolean(args[3]) : true; 888 - if (!Array.isArray(range)) return '#VALUE!'; 889 - const rows = range._rangeRows || 1; 890 - const cols = range._rangeCols || range.length; 891 - if (rowIdx < 1 || rowIdx > rows) return '#REF!'; 892 - return hlookup(needle, range, rows, cols, rowIdx, rangeLookup); 893 - } 894 - 895 - case 'INDEX': { 896 - const range = Array.isArray(args[0]) ? args[0] : [args[0]]; 897 - const row = toNum(args[1]) - 1; 898 - const col = args[2] != null ? toNum(args[2]) - 1 : 0; 899 - const idxCols = (range as RangeArray)._rangeCols || range.length; 900 - const idxRows = (range as RangeArray)._rangeRows || 1; 901 - if (row < 0 || row >= idxRows || col < 0 || col >= idxCols) return '#REF!'; 902 - return range[row * idxCols + col] ?? '#REF!'; 903 - } 904 - 905 - case 'MATCH': { 906 - const needle = args[0]; 907 - const range = Array.isArray(args[1]) ? args[1] : [args[1]]; 908 - const matchType = args[2] != null ? toNum(args[2]) : 1; 909 - if (matchType === 0) { 910 - // Exact match 911 - const idx = range.findIndex(v => v === needle || String(v) === String(needle)); 912 - return idx === -1 ? '#N/A' : idx + 1; 913 - } else if (matchType === 1) { 914 - // Sorted ascending: find largest value <= needle 915 - const nv = toNum(needle); 916 - let bestIdx = -1; 917 - for (let i = 0; i < range.length; i++) { 918 - const rv = toNum(range[i]); 919 - if (rv <= nv) bestIdx = i; else break; 920 - } 921 - return bestIdx === -1 ? '#N/A' : bestIdx + 1; 922 - } else { 923 - // match_type = -1: sorted descending: find smallest value >= needle 924 - const nv = toNum(needle); 925 - let bestIdx = -1; 926 - for (let i = 0; i < range.length; i++) { 927 - const rv = toNum(range[i]); 928 - if (rv >= nv) bestIdx = i; else break; 929 - } 930 - return bestIdx === -1 ? '#N/A' : bestIdx + 1; 931 - } 932 - } 933 - 934 - case 'ADDRESS': { 935 - // ADDRESS(row_num, col_num, [abs_num]) 936 - // abs_num: 1=absolute (default), 2=abs row/rel col, 3=rel row/abs col, 4=relative 937 - const rowNum = toNum(args[0]); 938 - const colNum = toNum(args[1]); 939 - const absNum = args[2] !== undefined ? toNum(args[2]) : 1; 940 - const colLetter = colToLetter(colNum); 941 - switch (absNum) { 942 - case 1: return '$' + colLetter + '$' + rowNum; 943 - case 2: return colLetter + '$' + rowNum; 944 - case 3: return '$' + colLetter + rowNum; 945 - case 4: return colLetter + '' + rowNum; 946 - default: return '$' + colLetter + '$' + rowNum; 947 - } 948 - } 949 - 950 - case 'SUMIF': { 951 - const range = Array.isArray(args[0]) ? args[0] : [args[0]]; 952 - const criteria = args[1]; 953 - const sumRange = args[2] != null ? (Array.isArray(args[2]) ? args[2] : [args[2]]) : range; 954 - let sum = 0; 955 - for (let i = 0; i < range.length; i++) { 956 - if (matchCriteria(range[i], criteria)) { 957 - sum += toNum(sumRange[i] ?? 0); 958 - } 959 - } 960 - return sum; 961 - } 962 - 963 - case 'COUNTIF': { 964 - const range = Array.isArray(args[0]) ? args[0] : [args[0]]; 965 - const criteria = args[1]; 966 - return range.filter(v => matchCriteria(v, criteria)).length; 967 - } 968 - 969 - case 'AVERAGEIF': { 970 - const range = Array.isArray(args[0]) ? args[0] : [args[0]]; 971 - const criteria = args[1]; 972 - const avgRange = args[2] != null ? (Array.isArray(args[2]) ? args[2] : [args[2]]) : range; 973 - const vals = []; 974 - for (let i = 0; i < range.length; i++) { 975 - if (matchCriteria(range[i], criteria)) vals.push(toNum(avgRange[i] ?? 0)); 976 - } 977 - return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : '#DIV/0!'; 978 - } 979 - 980 - case 'MEDIAN': { 981 - const n = nums(args).sort((a, b) => a - b); 982 - if (!n.length) return 0; 983 - const mid = Math.floor(n.length / 2); 984 - return n.length % 2 ? n[mid] : (n[mid - 1] + n[mid]) / 2; 985 - } 986 - 987 - case 'STDEV': { 988 - const n = nums(args); 989 - if (n.length < 2) return '#DIV/0!'; 990 - const mean = n.reduce((a, b) => a + b, 0) / n.length; 991 - const variance = n.reduce((a, b) => a + (b - mean) ** 2, 0) / (n.length - 1); 992 - return Math.sqrt(variance); 993 - } 994 - 995 - case 'XLOOKUP': { 996 - const needle = args[0]; 997 - const lookupArr = Array.isArray(args[1]) ? args[1] : [args[1]]; 998 - const returnArr = Array.isArray(args[2]) ? args[2] : [args[2]]; 999 - const ifNotFound = args[3] !== undefined ? args[3] : '#N/A'; 1000 - const matchMode = args[4] !== undefined ? toNum(args[4]) : 0; 1001 - const searchMode = args[5] !== undefined ? toNum(args[5]) : 1; 1002 - 1003 - // Flatten lookup and return arrays to 1D — use their linear length 1004 - const lookupLen = lookupArr.length; 1005 - const indices = []; 1006 - for (let i = 0; i < lookupLen; i++) indices.push(i); 1007 - if (searchMode === -1) indices.reverse(); 1008 - 1009 - let foundIdx = -1; 1010 - 1011 - if (matchMode === 0) { 1012 - // Exact match 1013 - for (const i of indices) { 1014 - if (valuesEqual(lookupArr[i], needle)) { foundIdx = i; break; } 1015 - } 1016 - } else if (matchMode === 2) { 1017 - // Wildcard match 1018 - const pattern = wildcardToRegex(String(needle)); 1019 - for (const i of indices) { 1020 - if (pattern.test(String(lookupArr[i]))) { foundIdx = i; break; } 1021 - } 1022 - } else if (matchMode === -1) { 1023 - // Exact or next smaller 1024 - let bestIdx = -1; 1025 - let bestVal = -Infinity; 1026 - for (let i = 0; i < lookupLen; i++) { 1027 - const v = lookupArr[i]; 1028 - const cmp = compareValues(v, needle); 1029 - if (cmp === 0) { foundIdx = i; break; } 1030 - if (cmp < 0 && toNum(v) > bestVal) { bestVal = toNum(v); bestIdx = i; } 1031 - } 1032 - if (foundIdx === -1) foundIdx = bestIdx; 1033 - } else if (matchMode === 1) { 1034 - // Exact or next larger 1035 - let bestIdx = -1; 1036 - let bestVal = Infinity; 1037 - for (let i = 0; i < lookupLen; i++) { 1038 - const v = lookupArr[i]; 1039 - const cmp = compareValues(v, needle); 1040 - if (cmp === 0) { foundIdx = i; break; } 1041 - if (cmp > 0 && toNum(v) < bestVal) { bestVal = toNum(v); bestIdx = i; } 1042 - } 1043 - if (foundIdx === -1) foundIdx = bestIdx; 1044 - } 1045 - 1046 - if (foundIdx === -1) return ifNotFound; 1047 - return returnArr[foundIdx] !== undefined ? returnArr[foundIdx] : ifNotFound; 1048 - } 1049 - 1050 - case 'SUMIFS': { 1051 - // SUMIFS(sum_range, criteria_range1, criteria1, [criteria_range2, criteria2], ...) 1052 - const sumRange = Array.isArray(args[0]) ? args[0] : [args[0]]; 1053 - const criteriaCount = Math.floor((args.length - 1) / 2); 1054 - let sum = 0; 1055 - for (let i = 0; i < sumRange.length; i++) { 1056 - let allMatch = true; 1057 - for (let c = 0; c < criteriaCount; c++) { 1058 - const critRange = Array.isArray(args[1 + c * 2]) ? args[1 + c * 2] : [args[1 + c * 2]]; 1059 - const criteria = args[2 + c * 2]; 1060 - if (!matchCriteriaWild(critRange[i], criteria)) { allMatch = false; break; } 1061 - } 1062 - if (allMatch) sum += toNum(sumRange[i] ?? 0); 1063 - } 1064 - return sum; 1065 - } 1066 - 1067 - case 'COUNTIFS': { 1068 - // COUNTIFS(criteria_range1, criteria1, [criteria_range2, criteria2], ...) 1069 - const criteriaCount = Math.floor(args.length / 2); 1070 - if (criteriaCount === 0) return 0; 1071 - const firstRange = Array.isArray(args[0]) ? args[0] : [args[0]]; 1072 - let count = 0; 1073 - for (let i = 0; i < firstRange.length; i++) { 1074 - let allMatch = true; 1075 - for (let c = 0; c < criteriaCount; c++) { 1076 - const critRange = Array.isArray(args[c * 2]) ? args[c * 2] : [args[c * 2]]; 1077 - const criteria = args[1 + c * 2]; 1078 - if (!matchCriteriaWild(critRange[i], criteria)) { allMatch = false; break; } 1079 - } 1080 - if (allMatch) count++; 1081 - } 1082 - return count; 1083 - } 1084 - 1085 - case 'AVERAGEIFS': { 1086 - // AVERAGEIFS(average_range, criteria_range1, criteria1, ...) 1087 - const avgRange = Array.isArray(args[0]) ? args[0] : [args[0]]; 1088 - const criteriaCount = Math.floor((args.length - 1) / 2); 1089 - const vals = []; 1090 - for (let i = 0; i < avgRange.length; i++) { 1091 - let allMatch = true; 1092 - for (let c = 0; c < criteriaCount; c++) { 1093 - const critRange = Array.isArray(args[1 + c * 2]) ? args[1 + c * 2] : [args[1 + c * 2]]; 1094 - const criteria = args[2 + c * 2]; 1095 - if (!matchCriteriaWild(critRange[i], criteria)) { allMatch = false; break; } 1096 - } 1097 - if (allMatch) vals.push(toNum(avgRange[i] ?? 0)); 1098 - } 1099 - return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : '#DIV/0!'; 1100 - } 1101 - 1102 - case 'TEXTJOIN': { 1103 - const delimiter = String(args[0]); 1104 - const ignoreEmpty = Boolean(args[1]); 1105 - const values = []; 1106 - for (let i = 2; i < args.length; i++) { 1107 - if (Array.isArray(args[i])) { 1108 - for (const v of args[i]) values.push(v); 1109 - } else { 1110 - values.push(args[i]); 1111 - } 1112 - } 1113 - const filtered = ignoreEmpty ? values.filter(v => v !== '' && v !== null && v !== undefined) : values; 1114 - return filtered.map(String).join(delimiter); 1115 - } 1116 - 1117 - case 'CONCAT': { 1118 - const values = []; 1119 - for (const arg of args) { 1120 - if (Array.isArray(arg)) { 1121 - for (const v of arg) { 1122 - if (v !== '' && v !== null && v !== undefined) values.push(v); 1123 - } 1124 - } else { 1125 - values.push(arg); 1126 - } 1127 - } 1128 - return values.map(String).join(''); 1129 - } 1130 - 1131 - case 'SWITCH': { 1132 - // SWITCH(expression, case1, value1, [case2, value2, ...], [default]) 1133 - const expr = args[0]; 1134 - const pairs = args.slice(1); 1135 - const hasDefault = pairs.length % 2 === 1; 1136 - const pairCount = Math.floor(pairs.length / 2); 1137 - for (let i = 0; i < pairCount; i++) { 1138 - if (valuesEqual(expr, pairs[i * 2])) return pairs[i * 2 + 1]; 1139 - } 1140 - return hasDefault ? pairs[pairs.length - 1] : '#N/A'; 1141 - } 1142 - 1143 - // --- Information Functions --- 1144 - case 'ISNUMBER': return typeof args[0] === 'number'; 1145 - case 'ISTEXT': return typeof args[0] === 'string' && !String(args[0]).startsWith('#'); 1146 - case 'ISBLANK': return args[0] === null || args[0] === undefined || args[0] === ''; 1147 - case 'ISERROR': { 1148 - const ev = args[0]; 1149 - return typeof ev === 'string' && (ev === '#REF!' || ev === '#VALUE!' || ev === '#DIV/0!' || ev === '#N/A' || ev === '#ERROR!' || ev === '#NUM!' || ev.startsWith('#NAME?')); 1150 - } 1151 - case 'ISNA': return args[0] === '#N/A'; 1152 - case 'ISLOGICAL': return typeof args[0] === 'boolean'; 1153 - case 'TYPE': { 1154 - const tv = args[0]; 1155 - if (typeof tv === 'number') return 1; 1156 - if (typeof tv === 'string' && tv.startsWith('#')) return 16; 1157 - if (typeof tv === 'string') return 2; 1158 - if (typeof tv === 'boolean') return 4; 1159 - if (Array.isArray(tv)) return 64; 1160 - return 1; 1161 - } 1162 - case 'N': { 1163 - const nv = args[0]; 1164 - if (typeof nv === 'number') return nv; 1165 - if (typeof nv === 'boolean') return nv ? 1 : 0; 1166 - if (nv instanceof Date) return nv.getTime(); 1167 - return 0; 1168 - } 1169 - case 'T': { 1170 - const tv2 = args[0]; 1171 - return typeof tv2 === 'string' && !String(tv2).startsWith('#') ? tv2 : ''; 1172 - } 1173 - 1174 - // --- Math Functions (additional) --- 1175 - case 'SUMPRODUCT': { 1176 - const spArrays = args.map(a => Array.isArray(a) ? (a as unknown[]).map(toNum) : [toNum(a)]); 1177 - const spLen = Math.min(...spArrays.map(a => a.length)); 1178 - let spSum = 0; 1179 - for (let i = 0; i < spLen; i++) { 1180 - let spProd = 1; 1181 - for (const arr of spArrays) spProd *= arr[i]; 1182 - spSum += spProd; 1183 - } 1184 - return spSum; 1185 - } 1186 - case 'PRODUCT': { 1187 - const pn = nums(args); 1188 - return pn.length ? pn.reduce((a, b) => a * b, 1) : 0; 1189 - } 1190 - case 'SIGN': { 1191 - const sv = toNum(args[0]); 1192 - return sv > 0 ? 1 : sv < 0 ? -1 : 0; 1193 - } 1194 - case 'EVEN': { 1195 - const ev2 = toNum(args[0]); 1196 - const evRound = ev2 >= 0 ? Math.ceil(ev2) : Math.floor(ev2); 1197 - if (evRound % 2 === 0) return evRound; 1198 - return ev2 >= 0 ? evRound + 1 : evRound - 1; 1199 - } 1200 - case 'ODD': { 1201 - const ov = toNum(args[0]); 1202 - const ovRound = ov >= 0 ? Math.ceil(ov) : Math.floor(ov); 1203 - if (ovRound === 0) return ov >= 0 ? 1 : -1; 1204 - if (Math.abs(ovRound) % 2 === 1) return ovRound; 1205 - return ov >= 0 ? ovRound + 1 : ovRound - 1; 1206 - } 1207 - case 'CEILING': { 1208 - const cNum = toNum(args[0]); 1209 - const cSig = toNum(args[1]); 1210 - if (cSig === 0) return 0; 1211 - if (cNum > 0 && cSig < 0) return '#NUM!'; 1212 - return Math.ceil(cNum / cSig) * cSig; 1213 - } 1214 - case 'FLOOR': { 1215 - const fNum = toNum(args[0]); 1216 - const fSig = toNum(args[1]); 1217 - if (fSig === 0) return 0; 1218 - if (fNum > 0 && fSig < 0) return '#NUM!'; 1219 - return Math.floor(fNum / fSig) * fSig; 1220 - } 1221 - case 'FACT': { 1222 - const fn2 = Math.floor(toNum(args[0])); 1223 - if (fn2 < 0) return '#NUM!'; 1224 - if (fn2 > 170) return '#NUM!'; // 171! exceeds Number.MAX_VALUE 1225 - if (fn2 <= 1) return 1; 1226 - let fResult = 1; 1227 - for (let i = 2; i <= fn2; i++) fResult *= i; 1228 - return fResult; 1229 - } 1230 - case 'COMBIN': { 1231 - const cn = Math.floor(toNum(args[0])); 1232 - const ck = Math.floor(toNum(args[1])); 1233 - if (ck < 0 || ck > cn || cn < 0) return '#NUM!'; 1234 - if (ck === 0 || ck === cn) return 1; 1235 - let cResult = 1; 1236 - for (let i = 0; i < Math.min(ck, cn - ck); i++) { 1237 - cResult = cResult * (cn - i) / (i + 1); 1238 - } 1239 - return Math.round(cResult); 1240 - } 1241 - case 'GCD': { 1242 - const gcdVals = flat(args).map(v => Math.abs(Math.floor(toNum(v)))); 1243 - let ga = gcdVals[0] ?? 0; 1244 - for (let gi = 1; gi < gcdVals.length; gi++) { 1245 - let gb = gcdVals[gi]; 1246 - while (gb) { [ga, gb] = [gb, ga % gb]; } 1247 - } 1248 - return ga; 1249 - } 1250 - case 'LCM': { 1251 - const lcmVals = flat(args).map(v => Math.abs(Math.floor(toNum(v)))); 1252 - let result = lcmVals[0] ?? 0; 1253 - for (let li = 1; li < lcmVals.length; li++) { 1254 - const b = lcmVals[li]; 1255 - if (result === 0 && b === 0) continue; 1256 - let lg = result, lt = b; 1257 - while (lt) { [lg, lt] = [lt, lg % lt]; } 1258 - result = (result / lg) * b; 1259 - } 1260 - return result; 1261 - } 1262 - case 'QUOTIENT': { 1263 - const qd = toNum(args[1]); 1264 - if (qd === 0) return '#DIV/0!'; 1265 - return Math.trunc(toNum(args[0]) / qd); 1266 - } 640 + // --- Function dispatch --- 1267 641 1268 - // --- Trigonometric Functions --- 1269 - case 'SIN': return Math.sin(toNum(args[0])); 1270 - case 'COS': return Math.cos(toNum(args[0])); 1271 - case 'TAN': return Math.tan(toNum(args[0])); 1272 - case 'ASIN': return Math.asin(toNum(args[0])); 1273 - case 'ACOS': return Math.acos(toNum(args[0])); 1274 - case 'ATAN': return Math.atan(toNum(args[0])); 1275 - case 'ATAN2': return Math.atan2(toNum(args[1]), toNum(args[0])); 1276 - case 'DEGREES': return toNum(args[0]) * (180 / Math.PI); 1277 - case 'RADIANS': return toNum(args[0]) * (Math.PI / 180); 642 + function callFunction(name: string, args: unknown[]): unknown { 643 + let result: unknown; 1278 644 1279 - // --- Text Functions (additional) --- 1280 - case 'PROPER': { 1281 - return String(args[0]).toLowerCase().replace(/(?:^|\s|[^\w])\w/g, c => c.toUpperCase()); 1282 - } 1283 - case 'REPT': { const rCount = Math.max(0, Math.floor(toNum(args[1]))); if (rCount > 32767) return '#VALUE!'; return String(args[0]).repeat(rCount); } 1284 - case 'EXACT': return String(args[0]) === String(args[1]); 1285 - case 'REPLACE': { 1286 - const rpText = String(args[0]); 1287 - const rpStart = toNum(args[1]) - 1; 1288 - const rpNum = toNum(args[2]); 1289 - const rpNew = String(args[3]); 1290 - return rpText.slice(0, rpStart) + rpNew + rpText.slice(rpStart + rpNum); 1291 - } 1292 - case 'CLEAN': return String(args[0]).replace(/[\x00-\x1F]/g, ''); 1293 - case 'CHAR': return String.fromCharCode(toNum(args[0])); 1294 - case 'CODE': { 1295 - const codeStr = String(args[0]); 1296 - return codeStr.length > 0 ? codeStr.charCodeAt(0) : '#VALUE!'; 1297 - } 645 + result = callMathFunction(name, args); 646 + if (result !== undefined) return result; 1298 647 1299 - // --- Date/Time Functions (additional) --- 1300 - case 'HOUR': return new Date(args[0] as string | number | Date).getHours(); 1301 - case 'MINUTE': return new Date(args[0] as string | number | Date).getMinutes(); 1302 - case 'SECOND': return new Date(args[0] as string | number | Date).getSeconds(); 1303 - case 'WEEKDAY': { 1304 - const wdDate = new Date(args[0] as string | number | Date); 1305 - const wdType = args[1] !== undefined ? toNum(args[1]) : 1; 1306 - const wdDay = wdDate.getDay(); 1307 - if (wdType === 1) return wdDay + 1; 1308 - if (wdType === 2) return wdDay === 0 ? 7 : wdDay; 1309 - if (wdType === 3) return wdDay === 0 ? 6 : wdDay - 1; 1310 - return wdDay + 1; 1311 - } 1312 - case 'EDATE': { 1313 - const edDate = new Date(args[0] as string | number | Date); 1314 - const edMonths = toNum(args[1]); 1315 - const edOrigDay = edDate.getDate(); 1316 - edDate.setMonth(edDate.getMonth() + edMonths); 1317 - // Clamp day-of-month if it rolled over (e.g. Jan 31 + 1 month → Mar 3 should be Feb 28) 1318 - if (edDate.getDate() !== edOrigDay) { 1319 - edDate.setDate(0); // go to last day of previous month 1320 - } 1321 - return edDate; 1322 - } 1323 - case 'EOMONTH': { 1324 - const emDate = new Date(args[0] as string | number | Date); 1325 - const emMonths = toNum(args[1]); 1326 - emDate.setMonth(emDate.getMonth() + emMonths + 1, 0); // day 0 = last day of target month 1327 - return emDate; 1328 - } 1329 - case 'DAYS': { 1330 - const dEnd = new Date(args[0] as string | number | Date); 1331 - const dStart = new Date(args[1] as string | number | Date); 1332 - return Math.round((dEnd.getTime() - dStart.getTime()) / 86400000); 1333 - } 1334 - case 'NETWORKDAYS': { 1335 - const nwStart = new Date(args[0] as string | number | Date); 1336 - const nwEnd = new Date(args[1] as string | number | Date); 1337 - if (isNaN(nwStart.getTime()) || isNaN(nwEnd.getTime())) return '#VALUE!'; 1338 - // Safety: cap at ~200 years to prevent infinite/very-long loops 1339 - const nwDaySpan = Math.abs(nwEnd.getTime() - nwStart.getTime()) / 86400000; 1340 - if (nwDaySpan > 73000) return '#VALUE!'; 1341 - let nwCount = 0; 1342 - const nwStep = nwStart <= nwEnd ? 1 : -1; 1343 - const nwCur = new Date(nwStart); 1344 - while ((nwStep > 0 && nwCur <= nwEnd) || (nwStep < 0 && nwCur >= nwEnd)) { 1345 - const nwDay = nwCur.getDay(); 1346 - if (nwDay !== 0 && nwDay !== 6) nwCount++; 1347 - nwCur.setDate(nwCur.getDate() + nwStep); 1348 - } 1349 - return nwStep > 0 ? nwCount : -nwCount; 1350 - } 648 + result = callLogicalFunction(name, args); 649 + if (result !== undefined) return result; 1351 650 1352 - // --- Statistical Functions (additional) --- 1353 - case 'LARGE': { 1354 - const lgN = nums([args[0]]).sort((a, b) => b - a); 1355 - const lgK = toNum(args[1]); 1356 - if (lgK < 1 || lgK > lgN.length) return '#NUM!'; 1357 - return lgN[lgK - 1]; 1358 - } 1359 - case 'SMALL': { 1360 - const smN = nums([args[0]]).sort((a, b) => a - b); 1361 - const smK = toNum(args[1]); 1362 - if (smK < 1 || smK > smN.length) return '#NUM!'; 1363 - return smN[smK - 1]; 1364 - } 1365 - case 'RANK': { 1366 - const rkVal = toNum(args[0]); 1367 - const rkN = nums([args[1]]); 1368 - const rkOrder = args[2] !== undefined ? toNum(args[2]) : 0; 1369 - const rkSorted = [...rkN].sort((a, b) => rkOrder ? a - b : b - a); 1370 - const rkIdx = rkSorted.indexOf(rkVal); 1371 - return rkIdx === -1 ? '#N/A' : rkIdx + 1; 1372 - } 1373 - case 'PERCENTILE': { 1374 - const pcN = nums([args[0]]).sort((a, b) => a - b); 1375 - const pcK = toNum(args[1]); 1376 - if (pcK < 0 || pcK > 1 || pcN.length === 0) return '#NUM!'; 1377 - const pcIdx = pcK * (pcN.length - 1); 1378 - const pcLo = Math.floor(pcIdx); 1379 - const pcHi = Math.ceil(pcIdx); 1380 - if (pcLo === pcHi) return pcN[pcLo]; 1381 - return pcN[pcLo] + (pcN[pcHi] - pcN[pcLo]) * (pcIdx - pcLo); 1382 - } 1383 - case 'VAR': { 1384 - const varN = nums(args); 1385 - if (varN.length < 2) return '#DIV/0!'; 1386 - const varMean = varN.reduce((a, b) => a + b, 0) / varN.length; 1387 - return varN.reduce((a, b) => a + (b - varMean) ** 2, 0) / (varN.length - 1); 1388 - } 1389 - case 'VARP': { 1390 - const vpN = nums(args); 1391 - if (vpN.length === 0) return '#DIV/0!'; 1392 - const vpMean = vpN.reduce((a, b) => a + b, 0) / vpN.length; 1393 - return vpN.reduce((a, b) => a + (b - vpMean) ** 2, 0) / vpN.length; 1394 - } 1395 - case 'STDEVP': { 1396 - const sdpN = nums(args); 1397 - if (sdpN.length === 0) return '#DIV/0!'; 1398 - const sdpMean = sdpN.reduce((a, b) => a + b, 0) / sdpN.length; 1399 - return Math.sqrt(sdpN.reduce((a, b) => a + (b - sdpMean) ** 2, 0) / sdpN.length); 1400 - } 651 + result = callTextFunction(name, args); 652 + if (result !== undefined) return result; 1401 653 1402 - // --- Financial Functions --- 1403 - case 'PMT': { 1404 - const pmtRate = toNum(args[0]); 1405 - const pmtNper = toNum(args[1]); 1406 - const pmtPv = toNum(args[2]); 1407 - const pmtFv = args[3] !== undefined ? toNum(args[3]) : 0; 1408 - const pmtType = args[4] !== undefined ? toNum(args[4]) : 0; 1409 - if (pmtRate === 0) return -(pmtPv + pmtFv) / pmtNper; 1410 - const pmtPvif = Math.pow(1 + pmtRate, pmtNper); 1411 - return -(pmtRate * (pmtPv * pmtPvif + pmtFv)) / (pmtPvif - 1) / (1 + pmtRate * pmtType); 1412 - } 1413 - case 'FV': { 1414 - const fvRate = toNum(args[0]); 1415 - const fvNper = toNum(args[1]); 1416 - const fvPmt = toNum(args[2]); 1417 - const fvPv = args[3] !== undefined ? toNum(args[3]) : 0; 1418 - const fvType = args[4] !== undefined ? toNum(args[4]) : 0; 1419 - if (fvRate === 0) return -(fvPv + fvPmt * fvNper); 1420 - const fvPvif = Math.pow(1 + fvRate, fvNper); 1421 - return -(fvPv * fvPvif + fvPmt * (1 + fvRate * fvType) * ((fvPvif - 1) / fvRate)); 1422 - } 1423 - case 'PV': { 1424 - const pvRate = toNum(args[0]); 1425 - const pvNper = toNum(args[1]); 1426 - const pvPmt = toNum(args[2]); 1427 - const pvFv = args[3] !== undefined ? toNum(args[3]) : 0; 1428 - const pvType = args[4] !== undefined ? toNum(args[4]) : 0; 1429 - if (pvRate === 0) return -(pvFv + pvPmt * pvNper); 1430 - const pvPvif = Math.pow(1 + pvRate, pvNper); 1431 - return -(pvFv + pvPmt * (1 + pvRate * pvType) * ((pvPvif - 1) / pvRate)) / pvPvif; 1432 - } 1433 - case 'NPV': { 1434 - const npvRate = toNum(args[0]); 1435 - let npvSum = 0; 1436 - let npvPeriod = 1; 1437 - for (let i = 1; i < args.length; i++) { 1438 - const npvVals = Array.isArray(args[i]) ? (args[i] as unknown[]).map(toNum) : [toNum(args[i])]; 1439 - for (const nvItem of npvVals) { 1440 - npvSum += nvItem / Math.pow(1 + npvRate, npvPeriod); 1441 - npvPeriod++; 1442 - } 1443 - } 1444 - return npvSum; 1445 - } 1446 - case 'IRR': { 1447 - const irrVals = Array.isArray(args[0]) ? (args[0] as unknown[]).map(toNum) : [toNum(args[0])]; 1448 - let irrGuess = args[1] !== undefined ? toNum(args[1]) : 0.1; 1449 - for (let iter = 0; iter < 100; iter++) { 1450 - let irrNpv = 0, irrDnpv = 0; 1451 - for (let i = 0; i < irrVals.length; i++) { 1452 - irrNpv += irrVals[i] / Math.pow(1 + irrGuess, i); 1453 - irrDnpv -= i * irrVals[i] / Math.pow(1 + irrGuess, i + 1); 1454 - } 1455 - if (Math.abs(irrNpv) < 1e-7) return irrGuess; 1456 - if (irrDnpv === 0) return '#NUM!'; 1457 - irrGuess = irrGuess - irrNpv / irrDnpv; 1458 - } 1459 - return '#NUM!'; 1460 - } 1461 - 1462 - // --- Lookup/Logic Functions (additional) --- 1463 - case 'CHOOSE': { 1464 - const chIdx = Math.floor(toNum(args[0])); 1465 - if (chIdx < 1 || chIdx >= args.length) return '#VALUE!'; 1466 - return args[chIdx]; 1467 - } 654 + result = callDateFunction(name, args); 655 + if (result !== undefined) return result; 1468 656 1469 - // --- Dynamic Array Functions (#86) --- 1470 - case 'FILTER': { 1471 - // FILTER(array, include, [if_empty]) 1472 - const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 1473 - const include = Array.isArray(args[1]) ? args[1] : [args[1]]; 1474 - const ifEmpty = args[2] !== undefined ? args[2] : '#N/A'; 1475 - const rows = (source as RangeArray)._rangeRows || source.length; 1476 - const cols = (source as RangeArray)._rangeCols || 1; 657 + result = callLookupFunction(name, args); 658 + if (result !== undefined) return result; 1477 659 1478 - const filtered: unknown[] = []; 1479 - for (let r = 0; r < rows; r++) { 1480 - const keep = include[r]; 1481 - // Truthy = include (booleans, non-zero numbers) 1482 - if (keep === true || (typeof keep === 'number' && keep !== 0)) { 1483 - if (cols > 1) { 1484 - for (let c = 0; c < cols; c++) { 1485 - filtered.push(source[r * cols + c]); 1486 - } 1487 - } else { 1488 - filtered.push(source[r]); 1489 - } 1490 - } 1491 - } 1492 - if (filtered.length === 0) return ifEmpty; 1493 - const result: RangeArray = filtered as RangeArray; 1494 - const filteredRows = cols > 1 ? filtered.length / cols : filtered.length; 1495 - result._rangeRows = filteredRows; 1496 - result._rangeCols = cols; 1497 - return result; 1498 - } 660 + result = callFinancialFunction(name, args); 661 + if (result !== undefined) return result; 1499 662 1500 - case 'SORT': { 1501 - // SORT(array, [sort_index], [sort_order], [by_col]) 1502 - const source = Array.isArray(args[0]) ? [...args[0]] : [args[0]]; 1503 - const sortIndex = args[1] !== undefined ? toNum(args[1]) : 1; 1504 - const sortOrder = args[2] !== undefined ? toNum(args[2]) : 1; // 1=asc, -1=desc 1505 - const rows = (args[0] as RangeArray)?._rangeRows || source.length; 1506 - const cols = (args[0] as RangeArray)?._rangeCols || 1; 663 + result = callArrayFunction(name, args); 664 + if (result !== undefined) return result; 1507 665 1508 - if (cols <= 1) { 1509 - // Single column — sort values directly 1510 - const sorted = source.filter(v => v !== '' && v !== null && v !== undefined); 1511 - sorted.sort((a, b) => { 1512 - const na = typeof a === 'number' ? a : NaN; 1513 - const nb = typeof b === 'number' ? b : NaN; 1514 - if (!isNaN(na) && !isNaN(nb)) return (na - nb) * sortOrder; 1515 - return String(a ?? '').localeCompare(String(b ?? '')) * sortOrder; 1516 - }); 1517 - const result: RangeArray = sorted as RangeArray; 1518 - result._rangeRows = sorted.length; 1519 - result._rangeCols = 1; 1520 - return result; 1521 - } 1522 - 1523 - // Multi-column: sort rows by sort_index column 1524 - const rowArrays: unknown[][] = []; 1525 - for (let r = 0; r < rows; r++) { 1526 - const row: unknown[] = []; 1527 - for (let c = 0; c < cols; c++) { 1528 - row.push(source[r * cols + c]); 1529 - } 1530 - rowArrays.push(row); 1531 - } 1532 - const si = Math.max(0, sortIndex - 1); // 1-based to 0-based 1533 - rowArrays.sort((a, b) => { 1534 - const va = a[si]; 1535 - const vb = b[si]; 1536 - const na = typeof va === 'number' ? va : NaN; 1537 - const nb = typeof vb === 'number' ? vb : NaN; 1538 - if (!isNaN(na) && !isNaN(nb)) return (na - nb) * sortOrder; 1539 - return String(va ?? '').localeCompare(String(vb ?? '')) * sortOrder; 1540 - }); 1541 - const flatResult: RangeArray = rowArrays.flat() as RangeArray; 1542 - flatResult._rangeRows = rows; 1543 - flatResult._rangeCols = cols; 1544 - return flatResult; 1545 - } 1546 - 1547 - case 'UNIQUE': { 1548 - // UNIQUE(array, [by_col], [exactly_once]) 1549 - const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 1550 - const exactlyOnce = args[2] === true; 1551 - const rows = (args[0] as RangeArray)?._rangeRows || source.length; 1552 - const cols = (args[0] as RangeArray)?._rangeCols || 1; 1553 - 1554 - if (cols <= 1) { 1555 - // Single column 1556 - const seen = new Map<string, { value: unknown; count: number }>(); 1557 - for (const v of source) { 1558 - if (v === '' || v === null || v === undefined) continue; 1559 - const key = String(v); 1560 - const existing = seen.get(key); 1561 - if (existing) { 1562 - existing.count++; 1563 - } else { 1564 - seen.set(key, { value: v, count: 1 }); 1565 - } 1566 - } 1567 - const values = exactlyOnce 1568 - ? [...seen.values()].filter(e => e.count === 1).map(e => e.value) 1569 - : [...seen.values()].map(e => e.value); 1570 - const result: RangeArray = values as RangeArray; 1571 - result._rangeRows = values.length; 1572 - result._rangeCols = 1; 1573 - return result.length === 0 ? '#N/A' : result; 1574 - } 1575 - 1576 - // Multi-column: unique rows 1577 - const rowKeys = new Map<string, { row: unknown[]; count: number }>(); 1578 - const orderedKeys: string[] = []; 1579 - for (let r = 0; r < rows; r++) { 1580 - const row: unknown[] = []; 1581 - for (let c = 0; c < cols; c++) { 1582 - row.push(source[r * cols + c]); 1583 - } 1584 - const key = row.map(v => String(v ?? '')).join('\0'); 1585 - const existing = rowKeys.get(key); 1586 - if (existing) { 1587 - existing.count++; 1588 - } else { 1589 - rowKeys.set(key, { row, count: 1 }); 1590 - orderedKeys.push(key); 1591 - } 1592 - } 1593 - const uniqueRows = exactlyOnce 1594 - ? orderedKeys.filter(k => rowKeys.get(k)!.count === 1).map(k => rowKeys.get(k)!.row) 1595 - : orderedKeys.map(k => rowKeys.get(k)!.row); 1596 - if (uniqueRows.length === 0) return '#N/A'; 1597 - const flatResult: RangeArray = uniqueRows.flat() as RangeArray; 1598 - flatResult._rangeRows = uniqueRows.length; 1599 - flatResult._rangeCols = cols; 1600 - return flatResult; 1601 - } 1602 - 1603 - // --- SEQUENCE (#86) --- 1604 - case 'SEQUENCE': { 1605 - // SEQUENCE(rows, [cols], [start], [step]) 1606 - const seqRows = Math.max(1, Math.floor(toNum(args[0]))); 1607 - const seqCols = args.length > 1 ? Math.max(1, Math.floor(toNum(args[1]))) : 1; 1608 - if (seqRows * seqCols > 10000) return '#VALUE!'; 1609 - const seqStart = args.length > 2 ? toNum(args[2]) : 1; 1610 - const seqStep = args.length > 3 ? toNum(args[3]) : 1; 1611 - const seqValues: unknown[] = []; 1612 - let seqCurrent = seqStart; 1613 - for (let r = 0; r < seqRows; r++) { 1614 - for (let c = 0; c < seqCols; c++) { 1615 - seqValues.push(seqCurrent); 1616 - seqCurrent += seqStep; 1617 - } 1618 - } 1619 - const seqResult: RangeArray = seqValues as RangeArray; 1620 - seqResult._rangeRows = seqRows; 1621 - seqResult._rangeCols = seqCols; 1622 - return seqResult; 1623 - } 1624 - 1625 - // --- QUERY (#85) --- 666 + // Functions that remain here due to local imports (sparkline, query) 667 + switch (name) { 1626 668 case 'QUERY': { 1627 - // QUERY(data, query, [headers]) 1628 669 const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 1629 670 const queryStr = String(args[1] ?? ''); 1630 671 const hasHeaders = args[2] !== undefined ? Boolean(args[2]) : true; ··· 1633 674 return executeQuery(source, rows, cols, queryStr, hasHeaders); 1634 675 } 1635 676 1636 - // --- Sparkline --- 1637 677 case 'SPARKLINE': { 1638 - // First arg is data (range array), second is optional type/options 1639 678 const rawData = Array.isArray(args[0]) ? args[0] : flat(args.slice(0, 1)); 1640 679 const numericData = rawData.flat(Infinity).filter((v: unknown) => 1641 680 typeof v === 'number' && !isNaN(v as number) ··· 1643 682 if (numericData.length === 0) return ''; 1644 683 const sparkArgs: unknown[] = [numericData]; 1645 684 if (args.length > 1) sparkArgs.push(args[1]); 1646 - const result = parseSparklineArgs(sparkArgs); 1647 - if (!result) return '#VALUE!'; 1648 - return result; 685 + const sparkResult = parseSparklineArgs(sparkArgs); 686 + if (!sparkResult) return '#VALUE!'; 687 + return sparkResult; 1649 688 } 1650 689 1651 690 default: return `#NAME? (${name})`; 1652 691 } 1653 692 } 1654 693 1655 - // --- Helpers --- 1656 - function toNum(v: unknown): number { 1657 - if (v === '' || v === null || v === undefined) return 0; 1658 - if (typeof v === 'boolean') return v ? 1 : 0; 1659 - if (typeof v === 'number') return v; 1660 - const n = Number(v); 1661 - return isNaN(n) ? 0 : n; 1662 - } 1663 - 1664 - /** Excel-style boolean coercion: TRUE/FALSE, nonzero=true, zero/empty=false, non-numeric strings=false */ 1665 - function toBool(v: unknown): boolean { 1666 - if (typeof v === 'boolean') return v; 1667 - if (typeof v === 'number') return v !== 0; 1668 - if (v === '' || v === null || v === undefined) return false; 1669 - if (typeof v === 'string') { 1670 - const upper = v.toUpperCase(); 1671 - if (upper === 'TRUE') return true; 1672 - if (upper === 'FALSE') return false; 1673 - const n = Number(v); 1674 - if (!isNaN(n)) return n !== 0; 1675 - return false; // non-numeric strings are not valid booleans 1676 - } 1677 - return Boolean(v); 1678 - } 1679 - 1680 - function escapeRegex(s: string): string { 1681 - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 1682 - } 1683 - 1684 - /** Excel-style comparison coercion: numbers vs numeric strings, case-insensitive strings, bool↔number */ 1685 - function coerceForComparison(a: unknown, b: unknown): [unknown, unknown] { 1686 - // Booleans coerce to numbers for comparison with numbers/numeric strings 1687 - if (typeof a === 'boolean') a = a ? 1 : 0; 1688 - if (typeof b === 'boolean') b = b ? 1 : 0; 1689 - 1690 - // If both are strings, try numeric comparison first; fall back to case-insensitive string compare 1691 - if (typeof a === 'string' && typeof b === 'string') { 1692 - const na = Number(a), nb = Number(b); 1693 - if (!isNaN(na) && a !== '' && !isNaN(nb) && b !== '') return [na, nb]; 1694 - return [a.toUpperCase(), b.toUpperCase()]; 1695 - } 1696 - 1697 - // Mixed number/string: coerce the string to a number if possible 1698 - if (typeof a === 'number' && typeof b === 'string') { 1699 - const nb = Number(b); 1700 - if (!isNaN(nb) && b !== '') return [a, nb]; 1701 - } 1702 - if (typeof b === 'number' && typeof a === 'string') { 1703 - const na = Number(a); 1704 - if (!isNaN(na) && a !== '') return [na, b]; 1705 - } 1706 - 1707 - return [a, b]; 1708 - } 1709 - 1710 - function matchCriteria(value: unknown, criteria: unknown): boolean { 1711 - if (typeof criteria === 'string') { 1712 - if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2)); 1713 - if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2)); 1714 - if (criteria.startsWith('<>')) { 1715 - const cv = criteria.slice(2); 1716 - const nv = Number(cv); 1717 - if (!isNaN(nv) && cv !== '') return toNum(value) !== nv; 1718 - return String(value).toLowerCase() !== cv.toLowerCase(); 1719 - } 1720 - if (criteria.startsWith('>')) return toNum(value) > toNum(criteria.slice(1)); 1721 - if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1)); 1722 - if (criteria.startsWith('=')) { 1723 - const cv = criteria.slice(1); 1724 - const nv = Number(cv); 1725 - if (!isNaN(nv) && cv !== '') return toNum(value) === nv; 1726 - return String(value).toLowerCase() === cv.toLowerCase(); 1727 - } 1728 - return String(value).toLowerCase() === String(criteria).toLowerCase(); 1729 - } 1730 - return value === criteria; 1731 - } 1732 - 1733 - /** Convert wildcard pattern (* and ?) to a RegExp */ 1734 - function wildcardToRegex(pattern: string): RegExp { 1735 - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 1736 - const regexStr = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); 1737 - return new RegExp('^' + regexStr + '$', 'i'); 1738 - } 1739 - 1740 - /** matchCriteria with wildcard support for SUMIFS/COUNTIFS/AVERAGEIFS */ 1741 - function matchCriteriaWild(value: unknown, criteria: unknown): boolean { 1742 - if (typeof criteria === 'string') { 1743 - if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2)); 1744 - if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2)); 1745 - if (criteria.startsWith('<>')) { 1746 - const cv = criteria.slice(2); 1747 - const nv = Number(cv); 1748 - if (!isNaN(nv) && cv !== '') return toNum(value) !== nv; 1749 - return String(value).toLowerCase() !== cv.toLowerCase(); 1750 - } 1751 - if (criteria.startsWith('>')) return toNum(value) > toNum(criteria.slice(1)); 1752 - if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1)); 1753 - if (criteria.startsWith('=')) { 1754 - const cv = criteria.slice(1); 1755 - const nv = Number(cv); 1756 - if (!isNaN(nv) && cv !== '') return toNum(value) === nv; 1757 - return String(value).toLowerCase() === cv.toLowerCase(); 1758 - } 1759 - // Check for wildcards 1760 - if (criteria.includes('*') || criteria.includes('?')) { 1761 - return wildcardToRegex(criteria).test(String(value)); 1762 - } 1763 - // Empty criteria matches empty cells 1764 - if (criteria === '') return value === '' || value === null || value === undefined; 1765 - return String(value).toLowerCase() === String(criteria).toLowerCase(); 1766 - } 1767 - if (typeof criteria === 'number') { 1768 - return toNum(value) === criteria; 1769 - } 1770 - return value === criteria; 1771 - } 1772 - 1773 - function formatValue(num: number, fmt: string): string { 1774 - // Percentage formats 1775 - if (fmt.endsWith('%')) { 1776 - const inner = fmt.slice(0, -1); 1777 - const dotPos = inner.indexOf('.'); 1778 - const decimals = dotPos === -1 ? 0 : inner.length - dotPos - 1; 1779 - return (num * 100).toFixed(decimals) + '%'; 1780 - } 1781 - 1782 - // Scientific notation: 0.00E+00 (check before dot/comma to avoid false match) 1783 - if (/e\+/i.test(fmt)) { 1784 - const dotE = fmt.toLowerCase().indexOf('e'); 1785 - const decPart = fmt.slice(0, dotE); 1786 - const decDot = decPart.indexOf('.'); 1787 - const decimals = decDot === -1 ? 0 : decPart.length - decDot - 1; 1788 - return num.toExponential(decimals).toUpperCase(); 1789 - } 1790 - 1791 - // Comma-separated with optional decimals: #,##0 or #,##0.00 etc. 1792 - if (fmt.includes(',')) { 1793 - const dotPos = fmt.indexOf('.'); 1794 - const decimals = dotPos === -1 ? 0 : fmt.length - dotPos - 1; 1795 - return num.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); 1796 - } 1797 - 1798 - // Fixed-point: 0.0, 0.00, 0.000, etc. 1799 - const dotPos = fmt.indexOf('.'); 1800 - if (dotPos !== -1) { 1801 - const decimals = fmt.length - dotPos - 1; 1802 - return num.toFixed(decimals); 1803 - } 1804 - 1805 - // Plain integer format 1806 - if (fmt === '0' || fmt === '#') return Math.round(num).toString(); 1807 - 1808 - return num.toString(); 1809 - } 1810 - 1811 - // --- VLOOKUP / HLOOKUP helpers --- 1812 - function vlookup(needle: unknown, flatRange: RangeArray, rows: number, cols: number, colIdx: number, rangeLookup: boolean): unknown { 1813 - if (rangeLookup) { 1814 - let bestRow = -1; 1815 - for (let r = 0; r < rows; r++) { 1816 - const val = flatRange[r * cols]; 1817 - const cmp = compareValues(val, needle); 1818 - if (cmp <= 0) bestRow = r; 1819 - else break; 1820 - } 1821 - if (bestRow === -1) return '#N/A'; 1822 - return flatRange[bestRow * cols + (colIdx - 1)]; 1823 - } else { 1824 - for (let r = 0; r < rows; r++) { 1825 - const val = flatRange[r * cols]; 1826 - if (valuesEqual(val, needle)) { 1827 - return flatRange[r * cols + (colIdx - 1)]; 1828 - } 1829 - } 1830 - return '#N/A'; 1831 - } 1832 - } 1833 - 1834 - function hlookup(needle: unknown, flatRange: RangeArray, rows: number, cols: number, rowIdx: number, rangeLookup: boolean): unknown { 1835 - if (rangeLookup) { 1836 - let bestCol = -1; 1837 - for (let c = 0; c < cols; c++) { 1838 - const val = flatRange[c]; 1839 - const cmp = compareValues(val, needle); 1840 - if (cmp <= 0) bestCol = c; 1841 - else break; 1842 - } 1843 - if (bestCol === -1) return '#N/A'; 1844 - return flatRange[(rowIdx - 1) * cols + bestCol]; 1845 - } else { 1846 - for (let c = 0; c < cols; c++) { 1847 - const val = flatRange[c]; 1848 - if (valuesEqual(val, needle)) { 1849 - return flatRange[(rowIdx - 1) * cols + c]; 1850 - } 1851 - } 1852 - return '#N/A'; 1853 - } 1854 - } 1855 - 1856 - function compareValues(a: unknown, b: unknown): number { 1857 - if (typeof a === 'number' && typeof b === 'number') return a - b; 1858 - return String(a).toLowerCase().localeCompare(String(b).toLowerCase()); 1859 - } 694 + // --- Main evaluate function --- 1860 695 1861 - function valuesEqual(a: unknown, b: unknown): boolean { 1862 - if (typeof a === 'number' && typeof b === 'number') return a === b; 1863 - if (typeof a === 'number' || typeof b === 'number') { 1864 - const na = toNum(a), nb = toNum(b); 1865 - if (a !== '' && b !== '' && !isNaN(Number(a)) && !isNaN(Number(b))) return na === nb; 1866 - } 1867 - return String(a).toLowerCase() === String(b).toLowerCase(); 1868 - } 1869 - 1870 - // --- Cell reference utilities --- 1871 - export function parseRef(ref: string): CellRef | null { 1872 - const match = ref.match(/^([A-Z]+)(\d+)$/); 1873 - if (!match) return null; 1874 - return { col: letterToCol(match[1]), row: parseInt(match[2]) }; 1875 - } 1876 - 1877 - export function colToLetter(col: number): string { 1878 - let result = ''; 1879 - while (col > 0) { 1880 - col--; 1881 - result = String.fromCharCode(65 + (col % 26)) + result; 1882 - col = Math.floor(col / 26); 1883 - } 1884 - return result; 1885 - } 1886 - 1887 - export function letterToCol(letter: string): number { 1888 - let col = 0; 1889 - for (let i = 0; i < letter.length; i++) { 1890 - col = col * 26 + (letter.charCodeAt(i) - 64); 1891 - } 1892 - return col; 1893 - } 1894 - 1895 - export function cellId(col: number, row: number): string { 1896 - return colToLetter(col) + row; 1897 - } 1898 - 1899 - // --- Main evaluate function --- 1900 696 /** 1901 697 * Evaluate a formula string. 1902 698 */ ··· 1922 718 if (t.type === TokenType.CROSS_SHEET_REF) { 1923 719 const { sheetName, ref } = t.value; 1924 720 if (ref.includes(':')) { 1925 - // Cross-sheet range 1926 721 const parts = ref.split(':'); 1927 722 const start = parseRef(parts[0]); 1928 723 const end = parseRef(parts[1]); ··· 1939 734 continue; 1940 735 } 1941 736 if (t.type === TokenType.CELL_REF) { 1942 - // Check for range 1943 737 if (tokens[i + 1]?.type === TokenType.COLON && tokens[i + 2]?.type === TokenType.CELL_REF) { 1944 738 const start = parseRef(t.value); 1945 739 const end = parseRef(tokens[i + 2].value); ··· 1962 756 */ 1963 757 export function formatCell(value: unknown, format: string | undefined): string { 1964 758 if (value === '' || value === null || value === undefined) return ''; 1965 - if (typeof value === 'string' && value.startsWith('#')) return value; // Error 759 + if (typeof value === 'string' && value.startsWith('#')) return value; 1966 760 1967 - // Date objects may come from formula evaluation — convert to timestamp for display 1968 761 if (value instanceof Date) { 1969 - if (!format || format === 'auto' || format === 'date') { 1970 - return value.toLocaleDateString(); 1971 - } 762 + if (!format || format === 'auto' || format === 'date') return value.toLocaleDateString(); 1972 763 value = value.getTime(); 1973 764 } 1974 765 1975 - // Safety net: extract text from objects to prevent [object Object] display 1976 766 if (typeof value === 'object' && value !== null && !(value instanceof Date)) { 1977 767 if ('text' in value) value = (value as { text: string }).text; 1978 768 else if ('hyperlink' in value) value = (value as { hyperlink: string }).hyperlink; ··· 1985 775 } 1986 776 1987 777 if (!format || format === 'auto') { 1988 - if (typeof value === 'number') { 1989 - return Number.isInteger(value) ? value.toString() : value.toFixed(2); 1990 - } 778 + if (typeof value === 'number') return Number.isInteger(value) ? value.toString() : value.toFixed(2); 1991 779 return String(value); 1992 780 } 1993 781