@valentinkolb/cloud 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,1187 @@
1
+ /**
2
+ * Markdown table formula language — pure-TS lexer + parser + evaluator.
3
+ *
4
+ * Used by both the `marked` extension (server-side render) and the
5
+ * notebooks CodeMirror table widget (client-side preview). Keep the
6
+ * file dependency-free so both runtimes can import it as-is.
7
+ *
8
+ * Public API:
9
+ *
10
+ * evaluateFormula(source: string, ctx: EvalContext): EvalResult
11
+ *
12
+ * `source` is a cell value starting with `=` (the leading `=` is
13
+ * stripped before parsing). `ctx` describes the surrounding table
14
+ * and the cell's position. Result is `{ kind: "ok", value }` or
15
+ * `{ kind: "error", code, message, suggestion? }` — the renderer
16
+ * decides how to display each.
17
+ *
18
+ * See `formula.test.ts` for the full behaviour surface.
19
+ */
20
+
21
+ // =============================================================================
22
+ // Public types
23
+ // =============================================================================
24
+
25
+ export type EvalContext = {
26
+ /** Header row (column names). */
27
+ headers: string[];
28
+ /** Body rows. Each row's length should match `headers.length`. */
29
+ rows: string[][];
30
+ /** Index of the row the formula is currently being evaluated for. */
31
+ currentRow: number;
32
+ /** Index of the column the formula is currently being evaluated for. */
33
+ currentCol: number;
34
+ /** @internal Cells `(row,col)` currently being evaluated — propagated
35
+ * through recursive formula-in-formula resolution to detect cycles. */
36
+ _visited?: ReadonlySet<string>;
37
+ };
38
+
39
+ export type EvalValue = number | string | boolean;
40
+
41
+ export type ErrorCode =
42
+ | "PARSE_ERROR"
43
+ | "UNKNOWN_FUNCTION"
44
+ | "UNKNOWN_COLUMN"
45
+ | "WRONG_ARG_COUNT"
46
+ | "NON_NUMERIC"
47
+ | "DIV_BY_ZERO"
48
+ | "TYPE_ERROR"
49
+ | "CIRCULAR_REF";
50
+
51
+ export type EvalError = {
52
+ kind: "error";
53
+ code: ErrorCode;
54
+ message: string;
55
+ /** A close-by valid name for typo errors — surface in the UI hover. */
56
+ suggestion?: string;
57
+ };
58
+
59
+ export type EvalResult = { kind: "ok"; value: EvalValue } | EvalError;
60
+
61
+ export type ProgressValue = {
62
+ ratio: number;
63
+ label: string;
64
+ };
65
+
66
+ const ok = (value: EvalValue): EvalResult => ({ kind: "ok", value });
67
+ const err = (code: ErrorCode, message: string, suggestion?: string): EvalError => ({ kind: "error", code, message, suggestion });
68
+
69
+ const PROGRESS_VALUE_RE = /^__progress:([^:]+):(.+)__$/;
70
+
71
+ const clampProgress = (ratio: number): number => Math.max(0, Math.min(1, ratio));
72
+
73
+ export const createProgressValue = (ratio: number, label: string): string =>
74
+ `__progress:${clampProgress(ratio)}:${encodeURIComponent(label)}__`;
75
+
76
+ export const parseProgressValue = (value: EvalValue): ProgressValue | null => {
77
+ if (typeof value !== "string") return null;
78
+ const match = value.match(PROGRESS_VALUE_RE);
79
+ if (!match) return null;
80
+ const ratio = Number.parseFloat(match[1]!);
81
+ if (Number.isNaN(ratio)) return null;
82
+ return {
83
+ ratio: clampProgress(ratio),
84
+ label: decodeURIComponent(match[2]!),
85
+ };
86
+ };
87
+
88
+ // =============================================================================
89
+ // Lexer
90
+ // =============================================================================
91
+
92
+ type TokenKind =
93
+ | "NUMBER"
94
+ | "STRING"
95
+ | "IDENT"
96
+ | "LPAREN"
97
+ | "RPAREN"
98
+ | "COMMA"
99
+ | "PLUS"
100
+ | "MINUS"
101
+ | "STAR"
102
+ | "SLASH"
103
+ | "EQ"
104
+ | "NEQ"
105
+ | "LT"
106
+ | "LTE"
107
+ | "GT"
108
+ | "GTE"
109
+ | "EOF";
110
+
111
+ type Token = { kind: TokenKind; value: string; pos: number };
112
+
113
+ const isDigit = (c: string) => c >= "0" && c <= "9";
114
+ const isIdentStart = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_";
115
+ const isIdentChar = (c: string) => isIdentStart(c) || isDigit(c);
116
+
117
+ const tokenize = (source: string): Token[] | EvalError => {
118
+ const tokens: Token[] = [];
119
+ let i = 0;
120
+ while (i < source.length) {
121
+ const c = source[i]!;
122
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") {
123
+ i++;
124
+ continue;
125
+ }
126
+ if (c === "(") {
127
+ tokens.push({ kind: "LPAREN", value: "(", pos: i });
128
+ i++;
129
+ continue;
130
+ }
131
+ if (c === ")") {
132
+ tokens.push({ kind: "RPAREN", value: ")", pos: i });
133
+ i++;
134
+ continue;
135
+ }
136
+ if (c === ",") {
137
+ tokens.push({ kind: "COMMA", value: ",", pos: i });
138
+ i++;
139
+ continue;
140
+ }
141
+ if (c === "+") {
142
+ tokens.push({ kind: "PLUS", value: "+", pos: i });
143
+ i++;
144
+ continue;
145
+ }
146
+ if (c === "-") {
147
+ tokens.push({ kind: "MINUS", value: "-", pos: i });
148
+ i++;
149
+ continue;
150
+ }
151
+ if (c === "*") {
152
+ tokens.push({ kind: "STAR", value: "*", pos: i });
153
+ i++;
154
+ continue;
155
+ }
156
+ if (c === "/") {
157
+ tokens.push({ kind: "SLASH", value: "/", pos: i });
158
+ i++;
159
+ continue;
160
+ }
161
+ if (c === "=" && source[i + 1] === "=") {
162
+ tokens.push({ kind: "EQ", value: "==", pos: i });
163
+ i += 2;
164
+ continue;
165
+ }
166
+ if (c === "!" && source[i + 1] === "=") {
167
+ tokens.push({ kind: "NEQ", value: "!=", pos: i });
168
+ i += 2;
169
+ continue;
170
+ }
171
+ if (c === "<" && source[i + 1] === "=") {
172
+ tokens.push({ kind: "LTE", value: "<=", pos: i });
173
+ i += 2;
174
+ continue;
175
+ }
176
+ if (c === "<") {
177
+ tokens.push({ kind: "LT", value: "<", pos: i });
178
+ i++;
179
+ continue;
180
+ }
181
+ if (c === ">" && source[i + 1] === "=") {
182
+ tokens.push({ kind: "GTE", value: ">=", pos: i });
183
+ i += 2;
184
+ continue;
185
+ }
186
+ if (c === ">") {
187
+ tokens.push({ kind: "GT", value: ">", pos: i });
188
+ i++;
189
+ continue;
190
+ }
191
+ if (c === '"') {
192
+ // String literal. Supports \" and \\ escapes — KISS, no \n or other.
193
+ let j = i + 1;
194
+ let value = "";
195
+ while (j < source.length && source[j] !== '"') {
196
+ if (source[j] === "\\" && j + 1 < source.length) {
197
+ const next = source[j + 1]!;
198
+ if (next === '"' || next === "\\") {
199
+ value += next;
200
+ j += 2;
201
+ continue;
202
+ }
203
+ }
204
+ value += source[j];
205
+ j++;
206
+ }
207
+ if (j >= source.length) {
208
+ return err("PARSE_ERROR", `Unterminated string starting at position ${i}`);
209
+ }
210
+ tokens.push({ kind: "STRING", value, pos: i });
211
+ i = j + 1;
212
+ continue;
213
+ }
214
+ if (c === "`") {
215
+ // Backtick-quoted identifier. Used to reference column names that
216
+ // contain spaces or special characters: `=SUM(\`Tax (19%)\`)`.
217
+ // Same escape rules as strings (\` and \\).
218
+ let j = i + 1;
219
+ let value = "";
220
+ while (j < source.length && source[j] !== "`") {
221
+ if (source[j] === "\\" && j + 1 < source.length) {
222
+ const next = source[j + 1]!;
223
+ if (next === "`" || next === "\\") {
224
+ value += next;
225
+ j += 2;
226
+ continue;
227
+ }
228
+ }
229
+ value += source[j];
230
+ j++;
231
+ }
232
+ if (j >= source.length) {
233
+ return err("PARSE_ERROR", `Unterminated backtick-quoted identifier starting at position ${i}`);
234
+ }
235
+ tokens.push({ kind: "IDENT", value, pos: i });
236
+ i = j + 1;
237
+ continue;
238
+ }
239
+ if (isDigit(c) || (c === "." && isDigit(source[i + 1] ?? ""))) {
240
+ let j = i;
241
+ while (j < source.length && isDigit(source[j]!)) j++;
242
+ if (source[j] === ".") {
243
+ j++;
244
+ while (j < source.length && isDigit(source[j]!)) j++;
245
+ }
246
+ tokens.push({ kind: "NUMBER", value: source.slice(i, j), pos: i });
247
+ i = j;
248
+ continue;
249
+ }
250
+ if (isIdentStart(c)) {
251
+ let j = i;
252
+ while (j < source.length && isIdentChar(source[j]!)) j++;
253
+ tokens.push({ kind: "IDENT", value: source.slice(i, j), pos: i });
254
+ i = j;
255
+ continue;
256
+ }
257
+ return err("PARSE_ERROR", `Unexpected character "${c}" at position ${i}`);
258
+ }
259
+ tokens.push({ kind: "EOF", value: "", pos: source.length });
260
+ return tokens;
261
+ };
262
+
263
+ // =============================================================================
264
+ // Parser — recursive descent
265
+ // =============================================================================
266
+
267
+ type BinOp = "+" | "-" | "*" | "/" | "==" | "!=" | "<" | "<=" | ">" | ">=";
268
+
269
+ type AST =
270
+ | { kind: "num"; value: number }
271
+ | { kind: "str"; value: string }
272
+ | { kind: "col"; name: string }
273
+ | { kind: "call"; name: string; args: AST[] }
274
+ | { kind: "binop"; op: BinOp; left: AST; right: AST }
275
+ | { kind: "neg"; operand: AST };
276
+
277
+ class Parser {
278
+ private pos = 0;
279
+ constructor(private tokens: Token[]) {}
280
+
281
+ private peek(): Token {
282
+ return this.tokens[this.pos]!;
283
+ }
284
+
285
+ private advance(): Token {
286
+ return this.tokens[this.pos++]!;
287
+ }
288
+
289
+ parse(): AST | EvalError {
290
+ try {
291
+ const expr = this.parseExpr();
292
+ if (this.peek().kind !== "EOF") {
293
+ return err("PARSE_ERROR", `Unexpected "${this.peek().value}" after end of formula`);
294
+ }
295
+ return expr;
296
+ } catch (e) {
297
+ if (e instanceof Error) return err("PARSE_ERROR", e.message);
298
+ return err("PARSE_ERROR", String(e));
299
+ }
300
+ }
301
+
302
+ // expr := compareExpr
303
+ private parseExpr(): AST {
304
+ return this.parseCompare();
305
+ }
306
+
307
+ // compareExpr := addExpr (('==' | '!=' | '<' | '<=' | '>' | '>=') addExpr)*
308
+ private parseCompare(): AST {
309
+ let left = this.parseAdd();
310
+ while (true) {
311
+ const k = this.peek().kind;
312
+ let op: BinOp | null = null;
313
+ if (k === "EQ") op = "==";
314
+ else if (k === "NEQ") op = "!=";
315
+ else if (k === "LT") op = "<";
316
+ else if (k === "LTE") op = "<=";
317
+ else if (k === "GT") op = ">";
318
+ else if (k === "GTE") op = ">=";
319
+ if (!op) break;
320
+ this.advance();
321
+ const right = this.parseAdd();
322
+ left = { kind: "binop", op, left, right };
323
+ }
324
+ return left;
325
+ }
326
+
327
+ // addExpr := mulExpr (('+' | '-') mulExpr)*
328
+ private parseAdd(): AST {
329
+ let left = this.parseMul();
330
+ while (true) {
331
+ const k = this.peek().kind;
332
+ if (k !== "PLUS" && k !== "MINUS") break;
333
+ const op: BinOp = k === "PLUS" ? "+" : "-";
334
+ this.advance();
335
+ const right = this.parseMul();
336
+ left = { kind: "binop", op, left, right };
337
+ }
338
+ return left;
339
+ }
340
+
341
+ // mulExpr := unaryExpr (('*' | '/') unaryExpr)*
342
+ private parseMul(): AST {
343
+ let left = this.parseUnary();
344
+ while (true) {
345
+ const k = this.peek().kind;
346
+ if (k !== "STAR" && k !== "SLASH") break;
347
+ const op: BinOp = k === "STAR" ? "*" : "/";
348
+ this.advance();
349
+ const right = this.parseUnary();
350
+ left = { kind: "binop", op, left, right };
351
+ }
352
+ return left;
353
+ }
354
+
355
+ // unaryExpr := '-' unaryExpr | primary
356
+ private parseUnary(): AST {
357
+ if (this.peek().kind === "MINUS") {
358
+ this.advance();
359
+ return { kind: "neg", operand: this.parseUnary() };
360
+ }
361
+ return this.parsePrimary();
362
+ }
363
+
364
+ // primary := NUMBER | STRING | IDENT | IDENT '(' arglist? ')' | '(' expr ')'
365
+ private parsePrimary(): AST {
366
+ const tok = this.peek();
367
+ if (tok.kind === "NUMBER") {
368
+ this.advance();
369
+ return { kind: "num", value: Number.parseFloat(tok.value) };
370
+ }
371
+ if (tok.kind === "STRING") {
372
+ this.advance();
373
+ return { kind: "str", value: tok.value };
374
+ }
375
+ if (tok.kind === "LPAREN") {
376
+ this.advance();
377
+ const inner = this.parseExpr();
378
+ if (this.peek().kind !== "RPAREN") {
379
+ throw new Error(`Expected ")" after expression`);
380
+ }
381
+ this.advance();
382
+ return inner;
383
+ }
384
+ if (tok.kind === "IDENT") {
385
+ this.advance();
386
+ if (this.peek().kind === "LPAREN") {
387
+ this.advance();
388
+ const args: AST[] = [];
389
+ if (this.peek().kind !== "RPAREN") {
390
+ args.push(this.parseExpr());
391
+ while (this.peek().kind === "COMMA") {
392
+ this.advance();
393
+ args.push(this.parseExpr());
394
+ }
395
+ }
396
+ if (this.peek().kind !== "RPAREN") {
397
+ throw new Error(`Expected ")" or "," in function call to "${tok.value}"`);
398
+ }
399
+ this.advance();
400
+ return { kind: "call", name: tok.value, args };
401
+ }
402
+ return { kind: "col", name: tok.value };
403
+ }
404
+ if (tok.kind === "EOF") {
405
+ throw new Error(`Unexpected end of formula`);
406
+ }
407
+ throw new Error(`Unexpected "${tok.value}" at position ${tok.pos}`);
408
+ }
409
+ }
410
+
411
+ // =============================================================================
412
+ // Did-you-mean — Levenshtein-distance suggestion
413
+ // =============================================================================
414
+
415
+ const levenshtein = (a: string, b: string): number => {
416
+ const al = a.length;
417
+ const bl = b.length;
418
+ if (al === 0) return bl;
419
+ if (bl === 0) return al;
420
+ const prev = new Array<number>(bl + 1);
421
+ const curr = new Array<number>(bl + 1);
422
+ for (let j = 0; j <= bl; j++) prev[j] = j;
423
+ for (let i = 1; i <= al; i++) {
424
+ curr[0] = i;
425
+ for (let j = 1; j <= bl; j++) {
426
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
427
+ curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
428
+ }
429
+ for (let j = 0; j <= bl; j++) prev[j] = curr[j]!;
430
+ }
431
+ return prev[bl]!;
432
+ };
433
+
434
+ const findClosest = (target: string, options: string[], maxDistance = 2): string | undefined => {
435
+ let best: string | undefined;
436
+ let bestD = maxDistance + 1;
437
+ const t = target.toLowerCase();
438
+ for (const opt of options) {
439
+ const d = levenshtein(t, opt.toLowerCase());
440
+ if (d < bestD) {
441
+ bestD = d;
442
+ best = opt;
443
+ }
444
+ }
445
+ return best;
446
+ };
447
+
448
+ // =============================================================================
449
+ // Evaluator
450
+ // =============================================================================
451
+
452
+ const isNumeric = (v: EvalValue): v is number => typeof v === "number" && !Number.isNaN(v);
453
+
454
+ const toNumber = (v: EvalValue): number | null => {
455
+ if (typeof v === "number") return Number.isNaN(v) ? null : v;
456
+ if (typeof v === "boolean") return v ? 1 : 0;
457
+ if (typeof v === "string") {
458
+ const trimmed = v.trim();
459
+ if (trimmed === "") return null;
460
+ const n = Number.parseFloat(trimmed);
461
+ return Number.isNaN(n) ? null : n;
462
+ }
463
+ return null;
464
+ };
465
+
466
+ const toString = (v: EvalValue): string => {
467
+ if (typeof v === "string") return v;
468
+ if (typeof v === "boolean") return v ? "true" : "false";
469
+ return String(v);
470
+ };
471
+
472
+ const isTruthy = (v: EvalValue): boolean => {
473
+ if (typeof v === "boolean") return v;
474
+ if (typeof v === "number") return v !== 0 && !Number.isNaN(v);
475
+ return v.length > 0;
476
+ };
477
+
478
+ const lookupColumn = (name: string, ctx: EvalContext): { index: number } | { suggestion?: string } => {
479
+ const lower = name.toLowerCase();
480
+ const idx = ctx.headers.findIndex((h) => h.toLowerCase() === lower);
481
+ if (idx !== -1) return { index: idx };
482
+ return { suggestion: findClosest(name, ctx.headers) };
483
+ };
484
+
485
+ const cellAt = (row: number, col: number, ctx: EvalContext): string => {
486
+ return ctx.rows[row]?.[col] ?? "";
487
+ };
488
+
489
+ const isCurrentFormulaCell = (row: number, col: number, ctx: EvalContext): boolean =>
490
+ row === ctx.currentRow && col === ctx.currentCol && cellAt(row, col, ctx).startsWith("=");
491
+
492
+ /**
493
+ * Resolve a single cell to its evaluated value. If the cell is itself
494
+ * a formula, recursively evaluate it (with cycle detection via
495
+ * `ctx._visited`). Non-formula cells are coerced to number when
496
+ * possible, otherwise returned as-is.
497
+ *
498
+ * Used by both the column-reference resolver in `evaluateAst` and by
499
+ * the column / row aggregate functions, so a `=SUM(total)` aggregating
500
+ * a column that's itself filled with `=hours * rate` formulas computes
501
+ * the chain correctly instead of seeing the literal formula strings.
502
+ */
503
+ const evaluateCell = (row: number, col: number, ctx: EvalContext): EvalResult => {
504
+ const cell = cellAt(row, col, ctx);
505
+ if (!cell.startsWith("=")) {
506
+ const n = toNumber(cell);
507
+ return n !== null ? ok(n) : ok(cell);
508
+ }
509
+ const key = `${row},${col}`;
510
+ if (ctx._visited?.has(key)) {
511
+ const colName = ctx.headers[col] ?? `col ${col}`;
512
+ return err("CIRCULAR_REF", `Circular reference involving "${colName}" at row ${row + 1}`);
513
+ }
514
+ const visited = new Set<string>(ctx._visited ?? []);
515
+ visited.add(key);
516
+ return evaluateFormula(cell, {
517
+ ...ctx,
518
+ currentRow: row,
519
+ currentCol: col,
520
+ _visited: visited,
521
+ });
522
+ };
523
+
524
+ type EvalState = {
525
+ ctx: EvalContext;
526
+ evaluate: (ast: AST) => EvalResult;
527
+ };
528
+
529
+ // -- Function implementations -------------------------------------------------
530
+
531
+ type FuncImpl = (args: AST[], state: EvalState) => EvalResult;
532
+
533
+ const argCountError = (name: string, expected: string, got: number): EvalError =>
534
+ err("WRONG_ARG_COUNT", `${name} expects ${expected}, got ${got}`);
535
+
536
+ /** Resolve a column-name AST argument and gather every row's value
537
+ * for that column — formulas are recursively evaluated, errors are
538
+ * silently skipped (won't propagate up) so an aggregate over a
539
+ * partially-broken column still produces a meaningful number. */
540
+ const collectColumnValues = (arg: AST, state: EvalState): { values: string[] } | EvalError => {
541
+ if (arg.kind !== "col") {
542
+ return err("TYPE_ERROR", `Expected a column name, got ${arg.kind === "num" ? "number" : "value"}`);
543
+ }
544
+ const lookup = lookupColumn(arg.name, state.ctx);
545
+ if (!("index" in lookup)) {
546
+ return err(
547
+ "UNKNOWN_COLUMN",
548
+ `Unknown column "${arg.name}"${lookup.suggestion ? ` — did you mean "${lookup.suggestion}"?` : ""}`,
549
+ lookup.suggestion,
550
+ );
551
+ }
552
+ const values: string[] = [];
553
+ for (let r = 0; r < state.ctx.rows.length; r++) {
554
+ if (isCurrentFormulaCell(r, lookup.index, state.ctx)) continue;
555
+ const result = evaluateCell(r, lookup.index, state.ctx);
556
+ if (result.kind === "ok") values.push(toString(result.value));
557
+ // errored cells silently skipped — same handling as non-numeric cells
558
+ }
559
+ return { values };
560
+ };
561
+
562
+ /** Map raw cell strings to numbers, dropping empties / non-numeric. */
563
+ const numericColumnValues = (values: string[]): number[] => {
564
+ const out: number[] = [];
565
+ for (const v of values) {
566
+ const n = toNumber(v);
567
+ if (n !== null) out.push(n);
568
+ }
569
+ return out;
570
+ };
571
+
572
+ const aggregateColumn = (name: string, args: AST[], state: EvalState, fn: (nums: number[]) => number | EvalError): EvalResult => {
573
+ if (args.length !== 1) return argCountError(name, "1 argument (column name)", args.length);
574
+ const col = collectColumnValues(args[0]!, state);
575
+ if ("kind" in col) return col;
576
+ const nums = numericColumnValues(col.values);
577
+ const result = fn(nums);
578
+ if (typeof result === "number") return ok(result);
579
+ return result;
580
+ };
581
+
582
+ const FUNCTIONS: Record<string, FuncImpl> = {
583
+ // --- Math ---
584
+ ROUND: (args, state) => {
585
+ if (args.length !== 2) return argCountError("ROUND", "2 arguments (number, digits)", args.length);
586
+ const v = state.evaluate(args[0]!);
587
+ if (v.kind === "error") return v;
588
+ const d = state.evaluate(args[1]!);
589
+ if (d.kind === "error") return d;
590
+ const n = toNumber(v.value);
591
+ if (n === null) return err("NON_NUMERIC", `ROUND: first argument is not a number`);
592
+ const digits = toNumber(d.value);
593
+ if (digits === null) return err("NON_NUMERIC", `ROUND: digits argument is not a number`);
594
+ const factor = Math.pow(10, Math.floor(digits));
595
+ return ok(Math.round(n * factor) / factor);
596
+ },
597
+ ABS: (args, state) => {
598
+ if (args.length !== 1) return argCountError("ABS", "1 argument (number)", args.length);
599
+ const v = state.evaluate(args[0]!);
600
+ if (v.kind === "error") return v;
601
+ const n = toNumber(v.value);
602
+ if (n === null) return err("NON_NUMERIC", `ABS: argument is not a number`);
603
+ return ok(Math.abs(n));
604
+ },
605
+ SQRT: (args, state) => {
606
+ if (args.length !== 1) return argCountError("SQRT", "1 argument (number)", args.length);
607
+ const v = state.evaluate(args[0]!);
608
+ if (v.kind === "error") return v;
609
+ const n = toNumber(v.value);
610
+ if (n === null) return err("NON_NUMERIC", `SQRT: argument is not a number`);
611
+ if (n < 0) return err("NON_NUMERIC", `SQRT: argument must be non-negative (got ${n})`);
612
+ return ok(Math.sqrt(n));
613
+ },
614
+ POW: (args, state) => {
615
+ if (args.length !== 2) return argCountError("POW", "2 arguments (base, exponent)", args.length);
616
+ const baseV = state.evaluate(args[0]!);
617
+ if (baseV.kind === "error") return baseV;
618
+ const expV = state.evaluate(args[1]!);
619
+ if (expV.kind === "error") return expV;
620
+ const base = toNumber(baseV.value);
621
+ const exp = toNumber(expV.value);
622
+ if (base === null) return err("NON_NUMERIC", `POW: base is not a number`);
623
+ if (exp === null) return err("NON_NUMERIC", `POW: exponent is not a number`);
624
+ return ok(Math.pow(base, exp));
625
+ },
626
+ MOD: (args, state) => {
627
+ if (args.length !== 2) return argCountError("MOD", "2 arguments (a, b)", args.length);
628
+ const aV = state.evaluate(args[0]!);
629
+ if (aV.kind === "error") return aV;
630
+ const bV = state.evaluate(args[1]!);
631
+ if (bV.kind === "error") return bV;
632
+ const a = toNumber(aV.value);
633
+ const b = toNumber(bV.value);
634
+ if (a === null) return err("NON_NUMERIC", `MOD: first argument is not a number`);
635
+ if (b === null) return err("NON_NUMERIC", `MOD: second argument is not a number`);
636
+ if (b === 0) return err("DIV_BY_ZERO", `MOD: divisor is zero`);
637
+ return ok(a % b);
638
+ },
639
+
640
+ // --- Column aggregates ---
641
+ SUM: (args, state) => aggregateColumn("SUM", args, state, (nums) => nums.reduce((a, b) => a + b, 0)),
642
+ AVG: (args, state) =>
643
+ aggregateColumn("AVG", args, state, (nums) => (nums.length === 0 ? 0 : nums.reduce((a, b) => a + b, 0) / nums.length)),
644
+ MIN: (args, state) => aggregateColumn("MIN", args, state, (nums) => (nums.length === 0 ? 0 : Math.min(...nums))),
645
+ MAX: (args, state) => aggregateColumn("MAX", args, state, (nums) => (nums.length === 0 ? 0 : Math.max(...nums))),
646
+ COUNT: (args, state) => {
647
+ if (args.length !== 1) return argCountError("COUNT", "1 argument (column name)", args.length);
648
+ const col = collectColumnValues(args[0]!, state);
649
+ if ("kind" in col) return col;
650
+ return ok(col.values.filter((v) => v.trim().length > 0).length);
651
+ },
652
+ MEDIAN: (args, state) =>
653
+ aggregateColumn("MEDIAN", args, state, (nums) => {
654
+ if (nums.length === 0) return 0;
655
+ const sorted = [...nums].sort((a, b) => a - b);
656
+ const mid = Math.floor(sorted.length / 2);
657
+ return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!;
658
+ }),
659
+ UNIQUE: (args, state) => {
660
+ if (args.length !== 1) return argCountError("UNIQUE", "1 argument (column name)", args.length);
661
+ const col = collectColumnValues(args[0]!, state);
662
+ if ("kind" in col) return col;
663
+ // Count distinct NON-EMPTY values, case-sensitive. Empty strings
664
+ // (blank cells) are not "a value" for unique-count purposes —
665
+ // same convention as COUNT().
666
+ const seen = new Set<string>();
667
+ for (const v of col.values) {
668
+ if (v.trim().length === 0) continue;
669
+ seen.add(v);
670
+ }
671
+ return ok(seen.size);
672
+ },
673
+ STDEV: (args, state) =>
674
+ aggregateColumn("STDEV", args, state, (nums) => {
675
+ // Sample standard deviation (Bessel's correction, n-1 denominator).
676
+ // Matches what most spreadsheet apps default to. Returns 0 for
677
+ // n < 2 (no variance computable).
678
+ if (nums.length < 2) return 0;
679
+ const mean = nums.reduce((a, b) => a + b, 0) / nums.length;
680
+ const variance = nums.reduce((sum, n) => sum + (n - mean) ** 2, 0) / (nums.length - 1);
681
+ return Math.sqrt(variance);
682
+ }),
683
+ COUNTIF: (args, state) => {
684
+ if (args.length !== 2) return argCountError("COUNTIF", "2 arguments (column, value)", args.length);
685
+ const col = collectColumnValues(args[0]!, state);
686
+ if ("kind" in col) return col;
687
+ const valueArg = state.evaluate(args[1]!);
688
+ if (valueArg.kind === "error") return valueArg;
689
+ const target = toString(valueArg.value);
690
+ let count = 0;
691
+ for (const v of col.values) if (v === target) count++;
692
+ return ok(count);
693
+ },
694
+ SUMIF: (args, state) => {
695
+ if (args.length !== 3) return argCountError("SUMIF", "3 arguments (sumCol, condCol, condValue)", args.length);
696
+ if (args[0]!.kind !== "col") {
697
+ return err("TYPE_ERROR", `SUMIF: first argument must be a column name`);
698
+ }
699
+ if (args[1]!.kind !== "col") {
700
+ return err("TYPE_ERROR", `SUMIF: second argument must be a column name`);
701
+ }
702
+ const sumLookup = lookupColumn(args[0]!.name, state.ctx);
703
+ if (!("index" in sumLookup)) {
704
+ return err(
705
+ "UNKNOWN_COLUMN",
706
+ `Unknown column "${args[0]!.name}"${sumLookup.suggestion ? ` — did you mean "${sumLookup.suggestion}"?` : ""}`,
707
+ sumLookup.suggestion,
708
+ );
709
+ }
710
+ const condLookup = lookupColumn(args[1]!.name, state.ctx);
711
+ if (!("index" in condLookup)) {
712
+ return err(
713
+ "UNKNOWN_COLUMN",
714
+ `Unknown column "${args[1]!.name}"${condLookup.suggestion ? ` — did you mean "${condLookup.suggestion}"?` : ""}`,
715
+ condLookup.suggestion,
716
+ );
717
+ }
718
+ const condValueArg = state.evaluate(args[2]!);
719
+ if (condValueArg.kind === "error") return condValueArg;
720
+ const target = toString(condValueArg.value);
721
+ let total = 0;
722
+ for (let r = 0; r < state.ctx.rows.length; r++) {
723
+ if (isCurrentFormulaCell(r, condLookup.index, state.ctx) || isCurrentFormulaCell(r, sumLookup.index, state.ctx)) continue;
724
+ const condResult = evaluateCell(r, condLookup.index, state.ctx);
725
+ if (condResult.kind !== "ok") continue;
726
+ if (toString(condResult.value) !== target) continue;
727
+ const sumResult = evaluateCell(r, sumLookup.index, state.ctx);
728
+ if (sumResult.kind !== "ok") continue;
729
+ const n = toNumber(sumResult.value);
730
+ if (n !== null) total += n;
731
+ }
732
+ return ok(total);
733
+ },
734
+
735
+ // --- PERCENT helper — sugar for ROUND(part / total * 100, 2) ---
736
+ PERCENT: (args, state) => {
737
+ if (args.length !== 2) return argCountError("PERCENT", "2 arguments (part, total)", args.length);
738
+ const part = state.evaluate(args[0]!);
739
+ if (part.kind === "error") return part;
740
+ const total = state.evaluate(args[1]!);
741
+ if (total.kind === "error") return total;
742
+ const p = toNumber(part.value);
743
+ const t = toNumber(total.value);
744
+ if (p === null) return err("NON_NUMERIC", `PERCENT: "part" is not a number`);
745
+ if (t === null) return err("NON_NUMERIC", `PERCENT: "total" is not a number`);
746
+ if (t === 0) return err("DIV_BY_ZERO", `PERCENT: total is zero`);
747
+ return ok(Math.round((p / t) * 10000) / 100);
748
+ },
749
+ PROGRESS: (args, state) => {
750
+ if (args.length !== 1 && args.length !== 2) return argCountError("PROGRESS", "1 ratio or 2 arguments (done, total)", args.length);
751
+ const doneResult = state.evaluate(args[0]!);
752
+ if (doneResult.kind === "error") return doneResult;
753
+ const done = toNumber(doneResult.value);
754
+ if (done === null) return err("NON_NUMERIC", `PROGRESS: first argument is not a number`);
755
+
756
+ if (args.length === 1) {
757
+ return ok(createProgressValue(done, `${Math.round(clampProgress(done) * 100)}%`));
758
+ }
759
+
760
+ const totalResult = state.evaluate(args[1]!);
761
+ if (totalResult.kind === "error") return totalResult;
762
+ const total = toNumber(totalResult.value);
763
+ if (total === null) return err("NON_NUMERIC", `PROGRESS: total is not a number`);
764
+ if (total === 0) return err("DIV_BY_ZERO", `PROGRESS: total is zero`);
765
+ return ok(createProgressValue(done / total, `${formatValue(done)}/${formatValue(total)}`));
766
+ },
767
+
768
+ // --- Row aggregates ---
769
+ ROWSUM: (args, state) => {
770
+ if (args.length !== 0) return argCountError("ROWSUM", "no arguments", args.length);
771
+ const row = state.ctx.rows[state.ctx.currentRow] ?? [];
772
+ let sum = 0;
773
+ for (let i = 0; i < row.length; i++) {
774
+ if (i === state.ctx.currentCol) continue;
775
+ const result = evaluateCell(state.ctx.currentRow, i, state.ctx);
776
+ if (result.kind !== "ok") continue;
777
+ const n = toNumber(result.value);
778
+ if (n !== null) sum += n;
779
+ }
780
+ return ok(sum);
781
+ },
782
+ ROWAVG: (args, state) => {
783
+ if (args.length !== 0) return argCountError("ROWAVG", "no arguments", args.length);
784
+ const row = state.ctx.rows[state.ctx.currentRow] ?? [];
785
+ let sum = 0;
786
+ let count = 0;
787
+ for (let i = 0; i < row.length; i++) {
788
+ if (i === state.ctx.currentCol) continue;
789
+ const result = evaluateCell(state.ctx.currentRow, i, state.ctx);
790
+ if (result.kind !== "ok") continue;
791
+ const n = toNumber(result.value);
792
+ if (n !== null) {
793
+ sum += n;
794
+ count++;
795
+ }
796
+ }
797
+ return ok(count === 0 ? 0 : sum / count);
798
+ },
799
+
800
+ // --- Conditional ---
801
+ IF: (args, state) => {
802
+ if (args.length !== 3) return argCountError("IF", "3 arguments (condition, then, else)", args.length);
803
+ const cond = state.evaluate(args[0]!);
804
+ if (cond.kind === "error") return cond;
805
+ return isTruthy(cond.value) ? state.evaluate(args[1]!) : state.evaluate(args[2]!);
806
+ },
807
+ IFEMPTY: (args, state) => {
808
+ if (args.length !== 2) return argCountError("IFEMPTY", "2 arguments (value, fallback)", args.length);
809
+ // For column refs we check the RAW cell text for emptiness so a
810
+ // formula that legitimately returns an empty string isn't replaced
811
+ // with the fallback. For other expressions, "empty" means empty
812
+ // string after evaluation.
813
+ if (args[0]!.kind === "col") {
814
+ const lookup = lookupColumn(args[0]!.name, state.ctx);
815
+ if (!("index" in lookup)) {
816
+ return err(
817
+ "UNKNOWN_COLUMN",
818
+ `Unknown column "${args[0]!.name}"${lookup.suggestion ? ` — did you mean "${lookup.suggestion}"?` : ""}`,
819
+ lookup.suggestion,
820
+ );
821
+ }
822
+ const rawCell = cellAt(state.ctx.currentRow, lookup.index, state.ctx);
823
+ if (rawCell.trim().length === 0) return state.evaluate(args[1]!);
824
+ return evaluateCell(state.ctx.currentRow, lookup.index, state.ctx);
825
+ }
826
+ const v = state.evaluate(args[0]!);
827
+ if (v.kind === "error") return v;
828
+ if (typeof v.value === "string" && v.value.length === 0) return state.evaluate(args[1]!);
829
+ return v;
830
+ },
831
+ IFERROR: (args, state) => {
832
+ if (args.length !== 2) return argCountError("IFERROR", "2 arguments (formula, fallback)", args.length);
833
+ const v = state.evaluate(args[0]!);
834
+ if (v.kind === "error") return state.evaluate(args[1]!);
835
+ return v;
836
+ },
837
+
838
+ // --- Logical combinators ---
839
+ // Result is returned as numeric 1/0 to match the rest of the engine's
840
+ // boolean handling (comparison operators do the same). Short-circuit
841
+ // evaluation: AND stops on first false, OR stops on first true — so
842
+ // expensive sub-expressions in later args are skipped when the
843
+ // outcome is already decided.
844
+ AND: (args, state) => {
845
+ if (args.length === 0) return argCountError("AND", "at least 1 argument", args.length);
846
+ for (const a of args) {
847
+ const v = state.evaluate(a);
848
+ if (v.kind === "error") return v;
849
+ if (!isTruthy(v.value)) return ok(0);
850
+ }
851
+ return ok(1);
852
+ },
853
+ OR: (args, state) => {
854
+ if (args.length === 0) return argCountError("OR", "at least 1 argument", args.length);
855
+ for (const a of args) {
856
+ const v = state.evaluate(a);
857
+ if (v.kind === "error") return v;
858
+ if (isTruthy(v.value)) return ok(1);
859
+ }
860
+ return ok(0);
861
+ },
862
+ NOT: (args, state) => {
863
+ if (args.length !== 1) return argCountError("NOT", "1 argument", args.length);
864
+ const v = state.evaluate(args[0]!);
865
+ if (v.kind === "error") return v;
866
+ return ok(isTruthy(v.value) ? 0 : 1);
867
+ },
868
+ CONTAINS: (args, state) => {
869
+ if (args.length !== 2) return argCountError("CONTAINS", "2 arguments (haystack, needle)", args.length);
870
+ const h = state.evaluate(args[0]!);
871
+ if (h.kind === "error") return h;
872
+ const n = state.evaluate(args[1]!);
873
+ if (n.kind === "error") return n;
874
+ return ok(toString(h.value).includes(toString(n.value)) ? 1 : 0);
875
+ },
876
+
877
+ // --- String ---
878
+ CONCAT: (args, state) => {
879
+ let out = "";
880
+ for (const a of args) {
881
+ const v = state.evaluate(a);
882
+ if (v.kind === "error") return v;
883
+ out += toString(v.value);
884
+ }
885
+ return ok(out);
886
+ },
887
+ UPPER: (args, state) => {
888
+ if (args.length !== 1) return argCountError("UPPER", "1 argument (text)", args.length);
889
+ const v = state.evaluate(args[0]!);
890
+ if (v.kind === "error") return v;
891
+ return ok(toString(v.value).toUpperCase());
892
+ },
893
+ LOWER: (args, state) => {
894
+ if (args.length !== 1) return argCountError("LOWER", "1 argument (text)", args.length);
895
+ const v = state.evaluate(args[0]!);
896
+ if (v.kind === "error") return v;
897
+ return ok(toString(v.value).toLowerCase());
898
+ },
899
+ LEN: (args, state) => {
900
+ if (args.length !== 1) return argCountError("LEN", "1 argument (text)", args.length);
901
+ const v = state.evaluate(args[0]!);
902
+ if (v.kind === "error") return v;
903
+ return ok(toString(v.value).length);
904
+ },
905
+ SUBSTRING: (args, state) => {
906
+ if (args.length !== 3) return argCountError("SUBSTRING", "3 arguments (text, start, length) — 0-indexed", args.length);
907
+ const text = state.evaluate(args[0]!);
908
+ if (text.kind === "error") return text;
909
+ const start = state.evaluate(args[1]!);
910
+ if (start.kind === "error") return start;
911
+ const length = state.evaluate(args[2]!);
912
+ if (length.kind === "error") return length;
913
+ const s = toString(text.value);
914
+ const startN = toNumber(start.value);
915
+ const lenN = toNumber(length.value);
916
+ if (startN === null) return err("NON_NUMERIC", `SUBSTRING: start is not a number`);
917
+ if (lenN === null) return err("NON_NUMERIC", `SUBSTRING: length is not a number`);
918
+ const startI = Math.max(0, Math.floor(startN));
919
+ const endI = Math.max(startI, startI + Math.floor(lenN));
920
+ return ok(s.slice(startI, endI));
921
+ },
922
+ TRIM: (args, state) => {
923
+ if (args.length !== 1) return argCountError("TRIM", "1 argument (text)", args.length);
924
+ const v = state.evaluate(args[0]!);
925
+ if (v.kind === "error") return v;
926
+ return ok(toString(v.value).trim());
927
+ },
928
+ LEFT: (args, state) => {
929
+ if (args.length !== 2) return argCountError("LEFT", "2 arguments (text, n)", args.length);
930
+ const text = state.evaluate(args[0]!);
931
+ if (text.kind === "error") return text;
932
+ const nArg = state.evaluate(args[1]!);
933
+ if (nArg.kind === "error") return nArg;
934
+ const n = toNumber(nArg.value);
935
+ if (n === null) return err("NON_NUMERIC", `LEFT: n is not a number`);
936
+ return ok(toString(text.value).slice(0, Math.max(0, Math.floor(n))));
937
+ },
938
+ RIGHT: (args, state) => {
939
+ if (args.length !== 2) return argCountError("RIGHT", "2 arguments (text, n)", args.length);
940
+ const text = state.evaluate(args[0]!);
941
+ if (text.kind === "error") return text;
942
+ const nArg = state.evaluate(args[1]!);
943
+ if (nArg.kind === "error") return nArg;
944
+ const n = toNumber(nArg.value);
945
+ if (n === null) return err("NON_NUMERIC", `RIGHT: n is not a number`);
946
+ const s = toString(text.value);
947
+ const take = Math.max(0, Math.floor(n));
948
+ return ok(take === 0 ? "" : s.slice(-take));
949
+ },
950
+ REPLACE: (args, state) => {
951
+ if (args.length !== 3) return argCountError("REPLACE", "3 arguments (text, search, replacement)", args.length);
952
+ const text = state.evaluate(args[0]!);
953
+ if (text.kind === "error") return text;
954
+ const search = state.evaluate(args[1]!);
955
+ if (search.kind === "error") return search;
956
+ const replacement = state.evaluate(args[2]!);
957
+ if (replacement.kind === "error") return replacement;
958
+ const searchStr = toString(search.value);
959
+ if (searchStr.length === 0) {
960
+ // `replaceAll` with empty needle inserts the replacement between
961
+ // every character — almost never the user's intent and slow on
962
+ // long strings. Reject explicitly.
963
+ return err("PARSE_ERROR", `REPLACE: search string must be non-empty`);
964
+ }
965
+ return ok(toString(text.value).replaceAll(searchStr, toString(replacement.value)));
966
+ },
967
+
968
+ // --- Date / time helpers ---
969
+ // Plain-ISO formatting — local timezone, no offset suffix. Same
970
+ // convention as the `/now` `/date` slash commands so a doc-wide
971
+ // search for a date string matches both sources.
972
+ NOW: (args) => {
973
+ if (args.length !== 0) return argCountError("NOW", "no arguments", args.length);
974
+ const d = new Date();
975
+ const pad = (n: number) => n.toString().padStart(2, "0");
976
+ return ok(
977
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`,
978
+ );
979
+ },
980
+ TODAY: (args) => {
981
+ if (args.length !== 0) return argCountError("TODAY", "no arguments", args.length);
982
+ const d = new Date();
983
+ const pad = (n: number) => n.toString().padStart(2, "0");
984
+ return ok(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`);
985
+ },
986
+ DATEDIFF: (args, state) => {
987
+ if (args.length < 2 || args.length > 3) return argCountError("DATEDIFF", "2 or 3 arguments (d1, d2, unit?)", args.length);
988
+ const d1V = state.evaluate(args[0]!);
989
+ if (d1V.kind === "error") return d1V;
990
+ const d2V = state.evaluate(args[1]!);
991
+ if (d2V.kind === "error") return d2V;
992
+ const d1 = new Date(toString(d1V.value));
993
+ const d2 = new Date(toString(d2V.value));
994
+ if (Number.isNaN(d1.getTime())) return err("PARSE_ERROR", `DATEDIFF: first argument is not a valid date`);
995
+ if (Number.isNaN(d2.getTime())) return err("PARSE_ERROR", `DATEDIFF: second argument is not a valid date`);
996
+ let unit = "days";
997
+ if (args.length === 3) {
998
+ const u = state.evaluate(args[2]!);
999
+ if (u.kind === "error") return u;
1000
+ unit = toString(u.value).toLowerCase();
1001
+ }
1002
+ const diffMs = d2.getTime() - d1.getTime();
1003
+ switch (unit) {
1004
+ case "ms":
1005
+ case "milliseconds":
1006
+ return ok(diffMs);
1007
+ case "s":
1008
+ case "seconds":
1009
+ return ok(diffMs / 1000);
1010
+ case "m":
1011
+ case "minutes":
1012
+ return ok(diffMs / (1000 * 60));
1013
+ case "h":
1014
+ case "hours":
1015
+ return ok(diffMs / (1000 * 60 * 60));
1016
+ case "d":
1017
+ case "days":
1018
+ return ok(diffMs / (1000 * 60 * 60 * 24));
1019
+ default:
1020
+ return err("PARSE_ERROR", `DATEDIFF: unknown unit "${unit}" — use one of ms / s / m / h / d (or full names)`);
1021
+ }
1022
+ },
1023
+ };
1024
+
1025
+ // Function aliases — same impl, alternate name.
1026
+ FUNCTIONS["MEAN"] = FUNCTIONS["AVG"]!;
1027
+ FUNCTIONS["ROWMEAN"] = FUNCTIONS["ROWAVG"]!;
1028
+
1029
+ const FUNCTION_NAMES = Object.keys(FUNCTIONS);
1030
+
1031
+ // -- Binop application --------------------------------------------------------
1032
+
1033
+ const applyBinop = (op: BinOp, l: EvalValue, r: EvalValue): EvalResult => {
1034
+ if (op === "==" || op === "!=") {
1035
+ // Equality: if both look numeric, compare as numbers; else as strings.
1036
+ const ln = toNumber(l);
1037
+ const rn = toNumber(r);
1038
+ const equal = ln !== null && rn !== null ? ln === rn : toString(l) === toString(r);
1039
+ return ok((op === "==" ? equal : !equal) ? 1 : 0);
1040
+ }
1041
+ const ln = toNumber(l);
1042
+ const rn = toNumber(r);
1043
+ if (ln === null || rn === null) {
1044
+ return err("NON_NUMERIC", `Cannot compare/compute non-numeric values with "${op}"`);
1045
+ }
1046
+ switch (op) {
1047
+ case "+":
1048
+ return ok(ln + rn);
1049
+ case "-":
1050
+ return ok(ln - rn);
1051
+ case "*":
1052
+ return ok(ln * rn);
1053
+ case "/":
1054
+ if (rn === 0) return err("DIV_BY_ZERO", `Division by zero`);
1055
+ return ok(ln / rn);
1056
+ case "<":
1057
+ return ok(ln < rn ? 1 : 0);
1058
+ case "<=":
1059
+ return ok(ln <= rn ? 1 : 0);
1060
+ case ">":
1061
+ return ok(ln > rn ? 1 : 0);
1062
+ case ">=":
1063
+ return ok(ln >= rn ? 1 : 0);
1064
+ }
1065
+ };
1066
+
1067
+ // -- Visitor ------------------------------------------------------------------
1068
+
1069
+ const evaluateAst = (ast: AST, ctx: EvalContext): EvalResult => {
1070
+ const state: EvalState = {
1071
+ ctx,
1072
+ evaluate: (node) => evaluateAst(node, ctx),
1073
+ };
1074
+
1075
+ switch (ast.kind) {
1076
+ case "num":
1077
+ return ok(ast.value);
1078
+ case "str":
1079
+ return ok(ast.value);
1080
+ case "col": {
1081
+ const lookup = lookupColumn(ast.name, ctx);
1082
+ if (!("index" in lookup)) {
1083
+ return err(
1084
+ "UNKNOWN_COLUMN",
1085
+ `Unknown column "${ast.name}"${lookup.suggestion ? ` — did you mean "${lookup.suggestion}"?` : ""}`,
1086
+ lookup.suggestion,
1087
+ );
1088
+ }
1089
+ return evaluateCell(ctx.currentRow, lookup.index, ctx);
1090
+ }
1091
+ case "neg": {
1092
+ const v = evaluateAst(ast.operand, ctx);
1093
+ if (v.kind === "error") return v;
1094
+ const n = toNumber(v.value);
1095
+ if (n === null) return err("NON_NUMERIC", `Cannot negate non-numeric value`);
1096
+ return ok(-n);
1097
+ }
1098
+ case "binop": {
1099
+ const l = evaluateAst(ast.left, ctx);
1100
+ if (l.kind === "error") return l;
1101
+ const r = evaluateAst(ast.right, ctx);
1102
+ if (r.kind === "error") return r;
1103
+ return applyBinop(ast.op, l.value, r.value);
1104
+ }
1105
+ case "call": {
1106
+ const fn = FUNCTIONS[ast.name.toUpperCase()];
1107
+ if (!fn) {
1108
+ const suggestion = findClosest(ast.name.toUpperCase(), FUNCTION_NAMES);
1109
+ return err("UNKNOWN_FUNCTION", `Unknown function "${ast.name}"${suggestion ? ` — did you mean "${suggestion}"?` : ""}`, suggestion);
1110
+ }
1111
+ return fn(ast.args, state);
1112
+ }
1113
+ }
1114
+ };
1115
+
1116
+ // =============================================================================
1117
+ // Public API
1118
+ // =============================================================================
1119
+
1120
+ /**
1121
+ * Evaluate a formula string against a table context.
1122
+ *
1123
+ * The leading `=` is required (it's the marker that distinguishes a
1124
+ * formula from a literal cell value). Whitespace is ignored.
1125
+ */
1126
+ export const evaluateFormula = (source: string, ctx: EvalContext): EvalResult => {
1127
+ if (!source.startsWith("=")) {
1128
+ return err("PARSE_ERROR", `Formula must start with "="`);
1129
+ }
1130
+ const body = source.slice(1).trim();
1131
+ if (body.length === 0) {
1132
+ return err("PARSE_ERROR", `Empty formula`);
1133
+ }
1134
+ const tokens = tokenize(body);
1135
+ if (!Array.isArray(tokens)) return tokens;
1136
+ const ast = new Parser(tokens).parse();
1137
+ if ("kind" in ast && ast.kind === "error") return ast;
1138
+ return evaluateAst(ast as AST, ctx);
1139
+ };
1140
+
1141
+ /** Format an `EvalResult` value for display in a cell. */
1142
+ export const formatValue = (value: EvalValue): string => {
1143
+ const progress = parseProgressValue(value);
1144
+ if (progress) return progress.label;
1145
+ if (typeof value === "boolean") return value ? "1" : "0";
1146
+ if (typeof value === "string") return value;
1147
+ if (Number.isNaN(value)) return "NaN";
1148
+ if (!Number.isFinite(value)) return value > 0 ? "∞" : "-∞";
1149
+ // Strip trailing zeros from decimal display but keep up to 6 places.
1150
+ if (Number.isInteger(value)) return String(value);
1151
+ return value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
1152
+ };
1153
+
1154
+ /** Whether a cell value is a formula (starts with `=`, no extra space). */
1155
+ export const isFormula = (cell: string): boolean => cell.startsWith("=");
1156
+
1157
+ /** Aggregate-function names — used by the total-row heuristic. */
1158
+ const AGGREGATE_FNS = ["SUM", "AVG", "MEAN", "MIN", "MAX", "COUNT", "MEDIAN", "ROWSUM", "ROWAVG", "ROWMEAN"];
1159
+
1160
+ /**
1161
+ * Heuristic: a row is a "total" / summary row when at least half of
1162
+ * its FORMULA cells use aggregate functions (`SUM`, `AVG`, `MEDIAN`,
1163
+ * etc.). Non-formula cells (literal text, empty cells) are ignored
1164
+ * entirely — only the formulas in the row factor into the ratio. So
1165
+ * a row like `["Total", "", "", "=SUM(price)"]` is true (1 / 1
1166
+ * formula cell is an aggregate) and a row of mostly hand-typed
1167
+ * numbers stays false even if it has one computed cell.
1168
+ *
1169
+ * Renderers tag matching `<tr>` with `md-table-total-row` for the
1170
+ * subtle bg + bold styling.
1171
+ */
1172
+ export const isTotalRow = (rowTexts: string[]): boolean => {
1173
+ let formulaCells = 0;
1174
+ let aggregateCells = 0;
1175
+ for (const text of rowTexts) {
1176
+ if (!isFormula(text)) continue;
1177
+ formulaCells++;
1178
+ const stripped = text.slice(1).trim().toUpperCase();
1179
+ for (const fn of AGGREGATE_FNS) {
1180
+ if (stripped === fn || stripped === `${fn}()` || stripped.startsWith(`${fn}(`)) {
1181
+ aggregateCells++;
1182
+ break;
1183
+ }
1184
+ }
1185
+ }
1186
+ return formulaCells > 0 && aggregateCells / formulaCells >= 0.5;
1187
+ };