Skip to content

Commit 7ef713b

Browse files
SamTV12345claude
andauthored
feat(sheet): add 37 missing Excel functions and array spill rendering (#373)
HyperFormula ships 418 functions but misses most of the modern Excel set. Register them as one plugin so they behave like built-ins (coercion, errors, autocomplete): CONCAT, TEXTBEFORE/TEXTAFTER, NUMBERVALUE, FIXED, DOLLAR, XMATCH, LOOKUP, UNIQUE, SORT, SORTBY, TAKE, DROP, VSTACK, HSTACK, TOCOL, TOROW, CHOOSECOLS, CHOOSEROWS, EXPAND, AVERAGEIFS, RANK(.EQ/.AVG), MODE(.SNGL/.MULT), TRIMMEAN, PERMUT, PERMUTATIONA, INTERCEPT, FORECAST(.LINEAR), FREQUENCY, ERROR.TYPE, TYPE, XIRR. Array results now render: blank cells fall back to the engine value, so spilled ranges (UNIQUE, SORT, SEQUENCE, FILTER) are visible like in Excel. XLSX round trip: Excel namespaces post-2007 functions (_xlfn.XLOOKUP, _xlfn._xlws.SORT). Strip on import, add back on export - without it every modern formula we wrote showed #NAME? in Excel. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1b042ff commit 7ef713b

8 files changed

Lines changed: 1424 additions & 4 deletions

File tree

lib/xlsx/export.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func Export(wb *sheet.Workbook) ([]byte, error) {
6060
return nil, err
6161
}
6262
if strings.HasPrefix(cell.Raw, "=") {
63-
if err := f.SetCellFormula(name, axis, cell.Raw[1:]); err != nil {
63+
if err := f.SetCellFormula(name, axis, addFunctionPrefixes(cell.Raw[1:])); err != nil {
6464
return nil, err
6565
}
6666
} else if n, err := strconv.ParseFloat(cell.Raw, 64); err == nil && cell.Raw != "" {

lib/xlsx/formulanames.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package xlsx
2+
3+
import "strings"
4+
5+
// Excel stores every function added after 2007 under a namespace prefix in the
6+
// file format: "_xlfn.XLOOKUP(...)", and "_xlfn._xlws.SORT(...)" for the
7+
// worksheet-scoped dynamic-array ones. The prefix is invisible in Excel's UI
8+
// and unknown to our formula engine, so it is stripped on import and added back
9+
// on export — without it Excel shows #NAME? for formulas we wrote.
10+
11+
// xlwsFunctions need the worksheet-scoped prefix instead of the plain one.
12+
var xlwsFunctions = map[string]bool{
13+
"SORT": true,
14+
"FILTER": true,
15+
}
16+
17+
// prefixedFunctions lists the non-dotted functions Excel namespaces. Dotted
18+
// names (NORM.DIST, RANK.AVG, ...) are all post-2007 too and handled by rule.
19+
var prefixedFunctions = map[string]bool{
20+
"ARABIC": true, "ARRAYTOTEXT": true, "BASE": true, "BITAND": true, "BITLSHIFT": true,
21+
"BITOR": true, "BITRSHIFT": true, "BITXOR": true, "CHOOSECOLS": true,
22+
"CHOOSEROWS": true, "COMBINA": true, "CONCAT": true, "COT": true, "COTH": true,
23+
"CSC": true, "CSCH": true, "DAYS": true, "DECIMAL": true, "DROP": true, "ENCODEURL": true,
24+
"EXPAND": true, "FILTER": true, "FORMULATEXT": true, "GAMMA": true, "GAUSS": true,
25+
"HSTACK": true, "IFNA": true, "IFS": true, "IMCOSH": true, "IMCOT": true, "IMCSC": true,
26+
"IMCSCH": true, "IMSEC": true, "IMSECH": true, "IMSINH": true, "IMTAN": true,
27+
"ISFORMULA": true, "ISOMITTED": true, "ISOWEEKNUM": true, "MAXIFS": true, "MINIFS": true,
28+
"MUNIT": true, "NUMBERVALUE": true, "PDURATION": true, "PERMUTATIONA": true, "PHI": true,
29+
"RANDARRAY": true, "RRI": true, "SEC": true, "SECH": true, "SEQUENCE": true, "SHEET": true,
30+
"SHEETS": true, "SORT": true, "SORTBY": true, "SWITCH": true, "TAKE": true,
31+
"TEXTAFTER": true, "TEXTBEFORE": true, "TEXTJOIN": true, "TEXTSPLIT": true, "TOCOL": true,
32+
"TOROW": true, "UNICHAR": true, "UNICODE": true, "UNIQUE": true, "VSTACK": true,
33+
"WEBSERVICE": true, "XLOOKUP": true, "XMATCH": true, "XOR": true,
34+
}
35+
36+
func needsPrefix(name string) bool {
37+
if prefixedFunctions[name] {
38+
return true
39+
}
40+
// Dotted names are the 2010+ statistical/compatibility set (NORM.DIST,
41+
// MODE.SNGL, CEILING.MATH, ...), all of which Excel namespaces.
42+
return strings.Contains(name, ".")
43+
}
44+
45+
// stripFunctionPrefixes removes Excel's namespace prefixes from a formula so
46+
// our engine sees plain function names.
47+
func stripFunctionPrefixes(formula string) string {
48+
for _, p := range []string{"_xlfn._xlws.", "_xlfn.", "_xlws."} {
49+
formula = strings.ReplaceAll(formula, p, "")
50+
}
51+
return formula
52+
}
53+
54+
// addFunctionPrefixes namespaces the post-2007 function names in a formula.
55+
// Function names are identifiers directly followed by '('; string literals are
56+
// skipped so text like "SORT(" inside quotes is left alone.
57+
func addFunctionPrefixes(formula string) string {
58+
var out strings.Builder
59+
for i := 0; i < len(formula); {
60+
c := formula[i]
61+
if c == '"' {
62+
j := i + 1
63+
for j < len(formula) {
64+
if formula[j] == '"' {
65+
// "" is an escaped quote inside the literal.
66+
if j+1 < len(formula) && formula[j+1] == '"' {
67+
j += 2
68+
continue
69+
}
70+
break
71+
}
72+
j++
73+
}
74+
if j < len(formula) {
75+
j++
76+
}
77+
out.WriteString(formula[i:j])
78+
i = j
79+
continue
80+
}
81+
if !isNameStart(c) {
82+
out.WriteByte(c)
83+
i++
84+
continue
85+
}
86+
j := i
87+
for j < len(formula) && isNameByte(formula[j]) {
88+
j++
89+
}
90+
name := formula[i:j]
91+
if j < len(formula) && formula[j] == '(' && needsPrefix(strings.ToUpper(name)) {
92+
if xlwsFunctions[strings.ToUpper(name)] {
93+
out.WriteString("_xlfn._xlws.")
94+
} else {
95+
out.WriteString("_xlfn.")
96+
}
97+
}
98+
out.WriteString(name)
99+
i = j
100+
}
101+
return out.String()
102+
}
103+
104+
func isNameStart(c byte) bool {
105+
return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'
106+
}
107+
108+
func isNameByte(c byte) bool {
109+
return isNameStart(c) || c >= '0' && c <= '9' || c == '.' || c == '_'
110+
}

lib/xlsx/formulanames_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package xlsx
2+
3+
import "testing"
4+
5+
func TestAddFunctionPrefixes(t *testing.T) {
6+
cases := []struct{ in, want string }{
7+
{"SUM(A1:A2)", "SUM(A1:A2)"},
8+
{"IF(A1>0,1,2)", "IF(A1>0,1,2)"},
9+
{"XLOOKUP(A1,B:B,C:C)", "_xlfn.XLOOKUP(A1,B:B,C:C)"},
10+
{"SORT(A1:A4)", "_xlfn._xlws.SORT(A1:A4)"},
11+
{"UNIQUE(SORT(A1:A4))", "_xlfn.UNIQUE(_xlfn._xlws.SORT(A1:A4))"},
12+
{"RANK.AVG(A1,B1:B4)", "_xlfn.RANK.AVG(A1,B1:B4)"},
13+
{"NORM.DIST(1,0,1,TRUE)", "_xlfn.NORM.DIST(1,0,1,TRUE)"},
14+
// Text literals must not be rewritten, even when they look like calls.
15+
{`CONCAT("SORT(","x")`, `_xlfn.CONCAT("SORT(","x")`},
16+
{`IF(A1="UNIQUE(",1,2)`, `IF(A1="UNIQUE(",1,2)`},
17+
// Sheet-qualified references stay untouched.
18+
{"SUM(Sheet2!A1:A2)", "SUM(Sheet2!A1:A2)"},
19+
}
20+
for _, c := range cases {
21+
if got := addFunctionPrefixes(c.in); got != c.want {
22+
t.Errorf("addFunctionPrefixes(%q) = %q, want %q", c.in, got, c.want)
23+
}
24+
}
25+
}
26+
27+
func TestStripFunctionPrefixesRoundTrip(t *testing.T) {
28+
for _, f := range []string{"SUM(A1:A2)", "_xlfn.UNIQUE(_xlfn._xlws.SORT(A1:A4))", "_xlfn.TEXTBEFORE(A1,\"-\")"} {
29+
stripped := stripFunctionPrefixes(f)
30+
if got := addFunctionPrefixes(stripped); got != f {
31+
t.Errorf("round trip of %q gave %q", f, got)
32+
}
33+
}
34+
if got := stripFunctionPrefixes("_xlws.FILTER(A1:A4,B1:B4)"); got != "FILTER(A1:A4,B1:B4)" {
35+
t.Errorf("stripFunctionPrefixes dropped the wrong part: %q", got)
36+
}
37+
}

lib/xlsx/import.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func Import(r io.Reader) (sheet.WorkbookSnapshot, error) {
5757
}
5858
raw := val
5959
if formula, ferr := f.GetCellFormula(name, axis); ferr == nil && formula != "" {
60-
raw = "=" + formula
60+
raw = "=" + stripFunctionPrefixes(formula)
6161
}
6262
styleId := 0
6363
if xid, serr := f.GetCellStyle(name, axis); serr == nil && xid != 0 {
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, it, expect, beforeAll } from 'vitest';
2+
import { FormulaEngine } from './formulaEngine';
3+
import { excelExtraFunctionNames } from './excelFunctions';
4+
5+
// One engine for all cases: formulas go into column Z and read fixtures from
6+
// A:D, so each case is a single setCell + read.
7+
let e: FormulaEngine;
8+
9+
const FIXTURE = [
10+
// A B C D
11+
['3', 'apple', '10', '2020-01-01'],
12+
['1', 'banana', '20', '2021-01-01'],
13+
['3', 'apple', '30', '2022-01-01'],
14+
['7', 'cherry', '40', '2023-01-01'],
15+
];
16+
17+
const evalAt = (formula: string, row = 0, col = 25): string => e.setCell(row, col, formula).value;
18+
19+
beforeAll(() => {
20+
e = new FormulaEngine();
21+
FIXTURE.forEach((cells, r) => cells.forEach((raw, c) => e.setCell(r, c, raw)));
22+
});
23+
24+
describe('registration', () => {
25+
it('exposes the new functions to autocomplete', () => {
26+
const names = new FormulaEngine().functionNames();
27+
for (const n of excelExtraFunctionNames) expect(names).toContain(n);
28+
});
29+
});
30+
31+
describe('text', () => {
32+
it('CONCAT joins scalars and ranges', () => {
33+
expect(evalAt('=CONCAT("a",1,TRUE(),B1:B2)')).toBe('a1TRUEapplebanana');
34+
});
35+
36+
it('TEXTBEFORE / TEXTAFTER pick by instance', () => {
37+
expect(evalAt('=TEXTBEFORE("a-b-c","-")')).toBe('a');
38+
expect(evalAt('=TEXTBEFORE("a-b-c","-",2)')).toBe('a-b');
39+
expect(evalAt('=TEXTBEFORE("a-b-c","-",-1)')).toBe('a-b');
40+
expect(evalAt('=TEXTAFTER("a-b-c","-")')).toBe('b-c');
41+
expect(evalAt('=TEXTAFTER("a-b-c","-",-1)')).toBe('c');
42+
expect(evalAt('=TEXTAFTER("a-b-c","X",1,0,0,"none")')).toBe('none');
43+
expect(evalAt('=TEXTBEFORE("aXb","x",1,1)')).toBe('a'); // case-insensitive
44+
expect(evalAt('=TEXTBEFORE("abc","-")')).toBe('#N/A');
45+
expect(evalAt('=TEXTBEFORE("abc","-",1,0,1)')).toBe('abc'); // match_end
46+
});
47+
48+
it('NUMBERVALUE honours separators and percent signs', () => {
49+
expect(evalAt('=NUMBERVALUE("1.234,56",",",".")')).toBe('1234.56');
50+
expect(evalAt('=NUMBERVALUE("50%")')).toBe('0.5');
51+
expect(evalAt('=NUMBERVALUE("abc")')).toBe('#VALUE!');
52+
});
53+
54+
it('FIXED and DOLLAR format numbers', () => {
55+
expect(evalAt('=FIXED(1234.567)')).toBe('1,234.57');
56+
expect(evalAt('=FIXED(1234.567,1,TRUE())')).toBe('1234.6');
57+
expect(evalAt('=FIXED(1234.567,-2)')).toBe('1,200');
58+
expect(evalAt('=DOLLAR(1234.567)')).toBe('$1,234.57');
59+
expect(evalAt('=DOLLAR(-1234.567)')).toBe('($1,234.57)');
60+
});
61+
});
62+
63+
describe('lookup', () => {
64+
it('XMATCH finds exact, closest and wildcard matches', () => {
65+
expect(evalAt('=XMATCH(7,A1:A4)')).toBe('4');
66+
expect(evalAt('=XMATCH(3,A1:A4)')).toBe('1');
67+
expect(evalAt('=XMATCH(3,A1:A4,0,-1)')).toBe('3'); // search from the end
68+
expect(evalAt('=XMATCH(5,A1:A4,-1)')).toBe('1'); // next smaller (3)
69+
expect(evalAt('=XMATCH(5,A1:A4,1)')).toBe('4'); // next larger (7)
70+
expect(evalAt('=XMATCH("ban*",B1:B4,2)')).toBe('2');
71+
expect(evalAt('=XMATCH(99,A1:A4)')).toBe('#N/A');
72+
});
73+
74+
it('LOOKUP handles the vector and the array form', () => {
75+
expect(evalAt('=LOOKUP(3,A1:A4,C1:C4)')).toBe('30');
76+
expect(evalAt('=LOOKUP(4,A1:A4,C1:C4)')).toBe('30'); // largest value <= 4
77+
expect(evalAt('=LOOKUP(3,A1:C4)')).toBe('30'); // taller than wide: last column
78+
expect(evalAt('=LOOKUP(0,A1:A4,C1:C4)')).toBe('#N/A');
79+
});
80+
});
81+
82+
describe('dynamic arrays', () => {
83+
// Array results spill; read the spilled cells directly.
84+
const spill = (formula: string, row: number, col: number): string => {
85+
e.setCell(10, 10, formula); // K11
86+
return e.getValue(row, col).value;
87+
};
88+
89+
it('UNIQUE drops duplicate rows', () => {
90+
expect(spill('=UNIQUE(B1:B4)', 10, 10)).toBe('apple');
91+
expect(spill('=UNIQUE(B1:B4)', 11, 10)).toBe('banana');
92+
expect(spill('=UNIQUE(B1:B4)', 12, 10)).toBe('cherry');
93+
expect(spill('=UNIQUE(B1:B4,FALSE(),TRUE())', 10, 10)).toBe('banana'); // exactly once
94+
});
95+
96+
it('SORT orders rows by a column', () => {
97+
expect(spill('=SORT(A1:A4)', 10, 10)).toBe('1');
98+
expect(spill('=SORT(A1:A4,1,-1)', 10, 10)).toBe('7');
99+
});
100+
101+
it('SORTBY orders one range by another', () => {
102+
expect(spill('=SORTBY(B1:B4,C1:C4,-1)', 10, 10)).toBe('cherry');
103+
});
104+
105+
it('TAKE and DROP slice from either end', () => {
106+
expect(spill('=TAKE(A1:A4,2)', 11, 10)).toBe('1');
107+
expect(spill('=TAKE(A1:A4,-1)', 10, 10)).toBe('7');
108+
expect(spill('=DROP(A1:A4,3)', 10, 10)).toBe('7');
109+
expect(spill('=DROP(A1:A4,-3)', 10, 10)).toBe('3');
110+
});
111+
112+
it('VSTACK and HSTACK combine ranges', () => {
113+
expect(spill('=VSTACK(A1:A2,C1:C2)', 12, 10)).toBe('10');
114+
expect(spill('=HSTACK(A1:A2,C1:C2)', 10, 11)).toBe('10');
115+
});
116+
117+
it('TOCOL and TOROW flatten', () => {
118+
expect(spill('=TOROW(A1:A4)', 10, 13)).toBe('7');
119+
expect(spill('=TOCOL(A1:C1)', 12, 10)).toBe('10');
120+
});
121+
122+
it('CHOOSECOLS and CHOOSEROWS pick by index', () => {
123+
expect(spill('=CHOOSECOLS(A1:C1,3)', 10, 10)).toBe('10');
124+
expect(spill('=CHOOSEROWS(A1:A4,-1)', 10, 10)).toBe('7');
125+
expect(spill('=CHOOSECOLS(A1:C1,9)', 10, 10)).toBe('#VALUE!');
126+
});
127+
128+
it('EXPAND pads to a larger size', () => {
129+
expect(spill('=EXPAND(A1:A2,3,1,0)', 12, 10)).toBe('0');
130+
expect(spill('=EXPAND(A1:A2,1)', 10, 10)).toBe('#VALUE!'); // cannot shrink
131+
});
132+
133+
it('FREQUENCY buckets values', () => {
134+
e.setCell(20, 0, '5'); // A21 bin
135+
e.setCell(21, 0, '25'); // A22 bin
136+
expect(spill('=FREQUENCY(C1:C4,A21:A22)', 10, 10)).toBe('0'); // <=5
137+
expect(spill('=FREQUENCY(C1:C4,A21:A22)', 11, 10)).toBe('2'); // <=25
138+
expect(spill('=FREQUENCY(C1:C4,A21:A22)', 12, 10)).toBe('2'); // rest
139+
});
140+
});
141+
142+
describe('statistics', () => {
143+
it('AVERAGEIFS averages with multiple criteria', () => {
144+
expect(evalAt('=AVERAGEIFS(C1:C4,A1:A4,3)')).toBe('20'); // (10+30)/2
145+
expect(evalAt('=AVERAGEIFS(C1:C4,A1:A4,3,C1:C4,">15")')).toBe('30');
146+
expect(evalAt('=AVERAGEIFS(C1:C4,B1:B4,"a*")')).toBe('20');
147+
expect(evalAt('=AVERAGEIFS(C1:C4,B1:B4,"<>apple")')).toBe('30'); // (20+40)/2
148+
expect(evalAt('=AVERAGEIFS(C1:C4,A1:A4,99)')).toBe('#DIV/0!');
149+
});
150+
151+
it('RANK and RANK.AVG rank ties', () => {
152+
expect(evalAt('=RANK(7,A1:A4)')).toBe('1');
153+
expect(evalAt('=RANK(3,A1:A4)')).toBe('2');
154+
expect(evalAt('=RANK(3,A1:A4,1)')).toBe('2'); // ascending: 1 is smaller
155+
expect(evalAt('=RANK.AVG(3,A1:A4)')).toBe('2.5');
156+
expect(evalAt('=RANK.EQ(3,A1:A4)')).toBe('2');
157+
expect(evalAt('=RANK(99,A1:A4)')).toBe('#N/A');
158+
});
159+
160+
it('MODE returns the most frequent value', () => {
161+
expect(evalAt('=MODE(A1:A4)')).toBe('3');
162+
expect(evalAt('=MODE.SNGL(A1:A4)')).toBe('3');
163+
expect(evalAt('=MODE(C1:C4)')).toBe('#N/A'); // all distinct
164+
});
165+
166+
it('TRIMMEAN drops the extremes', () => {
167+
expect(evalAt('=TRIMMEAN(C1:C4,0.5)')).toBe('25'); // trims 10 and 40
168+
expect(evalAt('=TRIMMEAN(C1:C4,0)')).toBe('25');
169+
});
170+
171+
it('PERMUT and PERMUTATIONA count arrangements', () => {
172+
expect(evalAt('=PERMUT(5,2)')).toBe('20');
173+
expect(evalAt('=PERMUTATIONA(5,2)')).toBe('25');
174+
expect(evalAt('=PERMUT(2,5)')).toBe('#NUM!');
175+
});
176+
177+
it('INTERCEPT and FORECAST fit a line', () => {
178+
// C = 10, 20, 30, 40 against x = 1..4 in B21:B24
179+
e.setCell(20, 1, '1');
180+
e.setCell(21, 1, '2');
181+
e.setCell(22, 1, '3');
182+
e.setCell(23, 1, '4');
183+
expect(evalAt('=INTERCEPT(C1:C4,B21:B24)')).toBe('0');
184+
expect(evalAt('=FORECAST(5,C1:C4,B21:B24)')).toBe('50');
185+
expect(evalAt('=FORECAST.LINEAR(5,C1:C4,B21:B24)')).toBe('50');
186+
});
187+
});
188+
189+
describe('information', () => {
190+
it('ERROR.TYPE maps error values to codes', () => {
191+
expect(evalAt('=ERROR.TYPE(1/0)')).toBe('2');
192+
expect(evalAt('=ERROR.TYPE(NA())')).toBe('7');
193+
expect(evalAt('=ERROR.TYPE(1)')).toBe('#N/A');
194+
});
195+
196+
it('TYPE classifies values', () => {
197+
expect(evalAt('=TYPE(1)')).toBe('1');
198+
expect(evalAt('=TYPE("x")')).toBe('2');
199+
expect(evalAt('=TYPE(TRUE())')).toBe('4');
200+
expect(evalAt('=TYPE(NA())')).toBe('16');
201+
// ponytail: a range argument collapses to its first value (no array type 64) —
202+
// accepting errors (16) matters more in practice than TYPE of an array.
203+
expect(evalAt('=TYPE(A1:A2)')).toBe('1');
204+
});
205+
});
206+
207+
describe('financial', () => {
208+
it('XIRR solves for the irregular-interval rate', () => {
209+
e.setCell(30, 0, '-1000');
210+
e.setCell(31, 0, '1100');
211+
e.setCell(30, 1, '=DATE(2020,1,1)');
212+
e.setCell(31, 1, '=DATE(2021,1,1)'); // 366 days later
213+
const r = Number(evalAt('=XIRR(A31:A32,B31:B32)'));
214+
expect(r).toBeCloseTo(0.0997, 3); // ~10% over 366/365 years
215+
});
216+
217+
it('reports #NUM! without a sign change', () => {
218+
e.setCell(30, 0, '-1000');
219+
e.setCell(31, 0, '-1100');
220+
expect(evalAt('=XIRR(A31:A32,B31:B32)')).toBe('#NUM!');
221+
});
222+
});

0 commit comments

Comments
 (0)