···11+/**
22+ * Formula Bar Syntax Highlighting (Chainlink #94).
33+ *
44+ * Tokenizes formula strings for display with colored spans.
55+ * Uses position-preserving tokenization so the highlighted output
66+ * maps exactly back to the original text.
77+ */
88+99+// Known error values in spreadsheets
1010+const ERROR_PATTERN = /^#(REF!|N\/A|VALUE!|ERROR!|NAME\?|NULL!|NUM!|DIV\/0!)/;
1111+1212+// Known function names (uppercase) — used to distinguish functions from cell refs
1313+const KNOWN_FUNCTIONS = new Set([
1414+ 'SUM', 'AVERAGE', 'COUNT', 'COUNTA', 'MIN', 'MAX', 'MEDIAN', 'STDEV',
1515+ 'ABS', 'ROUND', 'ROUNDUP', 'ROUNDDOWN', 'INT', 'MOD', 'POWER', 'SQRT',
1616+ 'LOG', 'LN', 'EXP', 'PI', 'RAND',
1717+ 'IF', 'AND', 'OR', 'NOT', 'IFERROR',
1818+ 'CONCATENATE', 'LEN', 'LEFT', 'RIGHT', 'MID',
1919+ 'UPPER', 'LOWER', 'TRIM', 'SUBSTITUTE', 'FIND', 'SEARCH',
2020+ 'TEXT', 'VALUE',
2121+ 'NOW', 'TODAY', 'DATE', 'YEAR', 'MONTH', 'DAY',
2222+ 'VLOOKUP', 'HLOOKUP', 'INDEX', 'MATCH',
2323+ 'SUMIF', 'COUNTIF', 'AVERAGEIF',
2424+]);
2525+2626+const CELL_REF_PATTERN = /^\$?[A-Z]{1,3}\$?\d+$/i;
2727+2828+/**
2929+ * Tokenize a formula string for syntax highlighting.
3030+ * Returns tokens with original positions preserved so the highlighted
3131+ * output reconstructs the exact formula text.
3232+ *
3333+ * @param {string} formula - The formula string (including leading '=')
3434+ * @returns {Array<{text: string, type: string, start: number, end: number}>}
3535+ */
3636+export function tokenizeForHighlighting(formula) {
3737+ const tokens = [];
3838+ let i = 0;
3939+ const s = formula;
4040+4141+ while (i < s.length) {
4242+ const start = i;
4343+4444+ // Leading '=' operator
4545+ if (i === 0 && s[i] === '=') {
4646+ tokens.push({ text: '=', type: 'operator', start: 0, end: 1 });
4747+ i++;
4848+ continue;
4949+ }
5050+5151+ // Whitespace
5252+ if (s[i] === ' ' || s[i] === '\t') {
5353+ let ws = '';
5454+ while (i < s.length && (s[i] === ' ' || s[i] === '\t')) {
5555+ ws += s[i++];
5656+ }
5757+ tokens.push({ text: ws, type: 'whitespace', start, end: i });
5858+ continue;
5959+ }
6060+6161+ // Error values: #REF!, #N/A, #VALUE!, etc.
6262+ if (s[i] === '#') {
6363+ const rest = s.slice(i);
6464+ const m = rest.match(ERROR_PATTERN);
6565+ if (m) {
6666+ const errText = '#' + m[1];
6767+ tokens.push({ text: errText, type: 'error', start: i, end: i + errText.length });
6868+ i += errText.length;
6969+ continue;
7070+ }
7171+ // Unknown # — just emit as operator
7272+ tokens.push({ text: '#', type: 'operator', start: i, end: i + 1 });
7373+ i++;
7474+ continue;
7575+ }
7676+7777+ // String literal
7878+ if (s[i] === '"') {
7979+ let str = '"';
8080+ i++;
8181+ while (i < s.length && s[i] !== '"') {
8282+ if (s[i] === '\\' && i + 1 < s.length) {
8383+ str += s[i++];
8484+ }
8585+ str += s[i++];
8686+ }
8787+ if (i < s.length) {
8888+ str += '"';
8989+ i++; // skip closing quote
9090+ }
9191+ tokens.push({ text: str, type: 'string', start, end: i });
9292+ continue;
9393+ }
9494+9595+ // Quoted sheet name: 'Sheet Name'!A1
9696+ if (s[i] === "'") {
9797+ let ref = "'";
9898+ i++;
9999+ while (i < s.length && s[i] !== "'") {
100100+ ref += s[i++];
101101+ }
102102+ if (i < s.length) {
103103+ ref += "'";
104104+ i++; // skip closing quote
105105+ }
106106+ // Expect ! after quoted sheet name
107107+ if (i < s.length && s[i] === '!') {
108108+ ref += '!';
109109+ i++;
110110+ // Read the cell ref after !
111111+ while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) {
112112+ ref += s[i++];
113113+ }
114114+ }
115115+ tokens.push({ text: ref, type: 'cell_ref', start, end: i });
116116+ continue;
117117+ }
118118+119119+ // Number
120120+ if (/[0-9]/.test(s[i]) || (s[i] === '.' && i + 1 < s.length && /[0-9]/.test(s[i + 1]))) {
121121+ let num = '';
122122+ while (i < s.length && /[0-9.eE]/.test(s[i])) {
123123+ num += s[i++];
124124+ // Handle sign in scientific notation
125125+ if (i < s.length && /[eE]/.test(s[i - 1]) && (s[i] === '+' || s[i] === '-')) {
126126+ num += s[i++];
127127+ }
128128+ }
129129+ tokens.push({ text: num, type: 'number', start, end: i });
130130+ continue;
131131+ }
132132+133133+ // Word: could be function, cell ref, boolean, cross-sheet ref, or identifier
134134+ if (/[A-Za-z$_]/.test(s[i])) {
135135+ let word = '';
136136+ while (i < s.length && /[A-Za-z0-9$_.]/.test(s[i])) {
137137+ word += s[i++];
138138+ }
139139+140140+ const upper = word.toUpperCase();
141141+142142+ // Boolean
143143+ if (upper === 'TRUE' || upper === 'FALSE') {
144144+ tokens.push({ text: word, type: 'boolean', start, end: i });
145145+ continue;
146146+ }
147147+148148+ // Cross-sheet ref: word!cellRef
149149+ if (i < s.length && s[i] === '!') {
150150+ let ref = word + '!';
151151+ i++; // skip !
152152+ while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) {
153153+ ref += s[i++];
154154+ }
155155+ tokens.push({ text: ref, type: 'cell_ref', start, end: i });
156156+ continue;
157157+ }
158158+159159+ // Function: next non-space char is '('
160160+ let peek = i;
161161+ while (peek < s.length && s[peek] === ' ') peek++;
162162+ if (peek < s.length && s[peek] === '(' && KNOWN_FUNCTIONS.has(upper)) {
163163+ tokens.push({ text: word, type: 'function', start, end: i });
164164+ continue;
165165+ }
166166+167167+ // Cell reference pattern
168168+ const stripped = word.replace(/\$/g, '');
169169+ if (CELL_REF_PATTERN.test(stripped)) {
170170+ tokens.push({ text: word, type: 'cell_ref', start, end: i });
171171+ continue;
172172+ }
173173+174174+ // Unknown identifier
175175+ tokens.push({ text: word, type: 'identifier', start, end: i });
176176+ continue;
177177+ }
178178+179179+ // Parentheses
180180+ if (s[i] === '(' || s[i] === ')') {
181181+ tokens.push({ text: s[i], type: 'paren', start: i, end: i + 1 });
182182+ i++;
183183+ continue;
184184+ }
185185+186186+ // Comma
187187+ if (s[i] === ',') {
188188+ tokens.push({ text: ',', type: 'operator', start: i, end: i + 1 });
189189+ i++;
190190+ continue;
191191+ }
192192+193193+ // Colon
194194+ if (s[i] === ':') {
195195+ tokens.push({ text: ':', type: 'operator', start: i, end: i + 1 });
196196+ i++;
197197+ continue;
198198+ }
199199+200200+ // Multi-character comparison operators
201201+ if (s[i] === '<') {
202202+ if (i + 1 < s.length && s[i + 1] === '=') {
203203+ tokens.push({ text: '<=', type: 'operator', start: i, end: i + 2 });
204204+ i += 2;
205205+ continue;
206206+ }
207207+ if (i + 1 < s.length && s[i + 1] === '>') {
208208+ tokens.push({ text: '<>', type: 'operator', start: i, end: i + 2 });
209209+ i += 2;
210210+ continue;
211211+ }
212212+ tokens.push({ text: '<', type: 'operator', start: i, end: i + 1 });
213213+ i++;
214214+ continue;
215215+ }
216216+217217+ if (s[i] === '>') {
218218+ if (i + 1 < s.length && s[i + 1] === '=') {
219219+ tokens.push({ text: '>=', type: 'operator', start: i, end: i + 2 });
220220+ i += 2;
221221+ continue;
222222+ }
223223+ tokens.push({ text: '>', type: 'operator', start: i, end: i + 1 });
224224+ i++;
225225+ continue;
226226+ }
227227+228228+ // Single-character operators
229229+ if ('+-*/^&='.includes(s[i])) {
230230+ tokens.push({ text: s[i], type: 'operator', start: i, end: i + 1 });
231231+ i++;
232232+ continue;
233233+ }
234234+235235+ // Unknown character — skip
236236+ tokens.push({ text: s[i], type: 'unknown', start: i, end: i + 1 });
237237+ i++;
238238+ }
239239+240240+ return tokens;
241241+}
242242+243243+/**
244244+ * Escape HTML special characters for safe insertion.
245245+ */
246246+function escapeHtml(text) {
247247+ return text
248248+ .replace(/&/g, '&')
249249+ .replace(/</g, '<')
250250+ .replace(/>/g, '>')
251251+ .replace(/"/g, '"');
252252+}
253253+254254+/**
255255+ * Render highlighted formula tokens as an HTML string.
256256+ * Each token is wrapped in a <span> with a class based on its type.
257257+ *
258258+ * @param {Array<{text: string, type: string, start: number, end: number}>} tokens
259259+ * @returns {string} HTML string
260260+ */
261261+export function renderHighlightedFormula(tokens) {
262262+ return tokens.map(t => {
263263+ const escaped = escapeHtml(t.text);
264264+ return `<span class="formula-token-${t.type}">${escaped}</span>`;
265265+ }).join('');
266266+}
+541
src/sheets/formula-tooltip.js
···11+/**
22+ * Rich Formula Tooltips with Parameter Highlighting (Chainlink #93).
33+ *
44+ * When typing inside a function call, shows a tooltip with the full
55+ * function signature, highlighting the current parameter based on
66+ * cursor position (counting commas and parens).
77+ */
88+99+/**
1010+ * Complete function metadata for all 57 supported functions.
1111+ * Each entry has a description and per-parameter info.
1212+ */
1313+export const FUNCTION_METADATA = {
1414+ // --- Math & Stats ---
1515+ SUM: {
1616+ desc: 'Adds all numbers in a range',
1717+ params: [
1818+ { name: 'range1', desc: 'First range to sum', required: true },
1919+ { name: 'range2', desc: 'Additional ranges to sum', required: false },
2020+ ],
2121+ },
2222+ AVERAGE: {
2323+ desc: 'Returns the arithmetic mean of the arguments',
2424+ params: [
2525+ { name: 'range1', desc: 'First range to average', required: true },
2626+ { name: 'range2', desc: 'Additional ranges', required: false },
2727+ ],
2828+ },
2929+ COUNT: {
3030+ desc: 'Counts the number of cells that contain numbers',
3131+ params: [
3232+ { name: 'range1', desc: 'First range to count', required: true },
3333+ { name: 'range2', desc: 'Additional ranges', required: false },
3434+ ],
3535+ },
3636+ COUNTA: {
3737+ desc: 'Counts the number of non-empty cells',
3838+ params: [
3939+ { name: 'range1', desc: 'First range to count', required: true },
4040+ { name: 'range2', desc: 'Additional ranges', required: false },
4141+ ],
4242+ },
4343+ MIN: {
4444+ desc: 'Returns the smallest value in a set of numbers',
4545+ params: [
4646+ { name: 'range1', desc: 'First range to evaluate', required: true },
4747+ { name: 'range2', desc: 'Additional ranges', required: false },
4848+ ],
4949+ },
5050+ MAX: {
5151+ desc: 'Returns the largest value in a set of numbers',
5252+ params: [
5353+ { name: 'range1', desc: 'First range to evaluate', required: true },
5454+ { name: 'range2', desc: 'Additional ranges', required: false },
5555+ ],
5656+ },
5757+ MEDIAN: {
5858+ desc: 'Returns the median of the given numbers',
5959+ params: [
6060+ { name: 'range1', desc: 'First range of values', required: true },
6161+ { name: 'range2', desc: 'Additional ranges', required: false },
6262+ ],
6363+ },
6464+ STDEV: {
6565+ desc: 'Estimates standard deviation based on a sample',
6666+ params: [
6767+ { name: 'range1', desc: 'First range of values', required: true },
6868+ { name: 'range2', desc: 'Additional ranges', required: false },
6969+ ],
7070+ },
7171+ ABS: {
7272+ desc: 'Returns the absolute value of a number',
7373+ params: [
7474+ { name: 'number', desc: 'The number to get the absolute value of', required: true },
7575+ ],
7676+ },
7777+ ROUND: {
7878+ desc: 'Rounds a number to a specified number of digits',
7979+ params: [
8080+ { name: 'number', desc: 'The number to round', required: true },
8181+ { name: 'num_digits', desc: 'Number of decimal places (default 0)', required: false },
8282+ ],
8383+ },
8484+ ROUNDUP: {
8585+ desc: 'Rounds a number up, away from zero',
8686+ params: [
8787+ { name: 'number', desc: 'The number to round up', required: true },
8888+ { name: 'num_digits', desc: 'Number of decimal places (default 0)', required: false },
8989+ ],
9090+ },
9191+ ROUNDDOWN: {
9292+ desc: 'Rounds a number down, toward zero',
9393+ params: [
9494+ { name: 'number', desc: 'The number to round down', required: true },
9595+ { name: 'num_digits', desc: 'Number of decimal places (default 0)', required: false },
9696+ ],
9797+ },
9898+ INT: {
9999+ desc: 'Rounds a number down to the nearest integer',
100100+ params: [
101101+ { name: 'number', desc: 'The number to round down', required: true },
102102+ ],
103103+ },
104104+ MOD: {
105105+ desc: 'Returns the remainder after dividing a number by a divisor',
106106+ params: [
107107+ { name: 'number', desc: 'The number to divide', required: true },
108108+ { name: 'divisor', desc: 'The divisor', required: true },
109109+ ],
110110+ },
111111+ POWER: {
112112+ desc: 'Returns a number raised to a power',
113113+ params: [
114114+ { name: 'number', desc: 'The base number', required: true },
115115+ { name: 'power', desc: 'The exponent', required: true },
116116+ ],
117117+ },
118118+ SQRT: {
119119+ desc: 'Returns the positive square root of a number',
120120+ params: [
121121+ { name: 'number', desc: 'The number to take the square root of', required: true },
122122+ ],
123123+ },
124124+ LOG: {
125125+ desc: 'Returns the logarithm of a number to a specified base',
126126+ params: [
127127+ { name: 'number', desc: 'The positive number to take the log of', required: true },
128128+ { name: 'base', desc: 'The base of the logarithm (default 10)', required: false },
129129+ ],
130130+ },
131131+ LN: {
132132+ desc: 'Returns the natural logarithm of a number',
133133+ params: [
134134+ { name: 'number', desc: 'The positive number to take the natural log of', required: true },
135135+ ],
136136+ },
137137+ EXP: {
138138+ desc: 'Returns e raised to the power of a number',
139139+ params: [
140140+ { name: 'number', desc: 'The exponent applied to the base e', required: true },
141141+ ],
142142+ },
143143+ PI: {
144144+ desc: 'Returns the value of pi (3.14159...)',
145145+ params: [],
146146+ },
147147+ RAND: {
148148+ desc: 'Returns a random number between 0 and 1',
149149+ params: [],
150150+ },
151151+152152+ // --- Logical ---
153153+ IF: {
154154+ desc: 'Returns one value if a condition is true and another if false',
155155+ params: [
156156+ { name: 'condition', desc: 'The logical test to evaluate', required: true },
157157+ { name: 'value_if_true', desc: 'Value returned when condition is true', required: true },
158158+ { name: 'value_if_false', desc: 'Value returned when condition is false', required: false },
159159+ ],
160160+ },
161161+ AND: {
162162+ desc: 'Returns TRUE if all arguments are true',
163163+ params: [
164164+ { name: 'logical1', desc: 'First condition to evaluate', required: true },
165165+ { name: 'logical2', desc: 'Additional conditions', required: false },
166166+ ],
167167+ },
168168+ OR: {
169169+ desc: 'Returns TRUE if any argument is true',
170170+ params: [
171171+ { name: 'logical1', desc: 'First condition to evaluate', required: true },
172172+ { name: 'logical2', desc: 'Additional conditions', required: false },
173173+ ],
174174+ },
175175+ NOT: {
176176+ desc: 'Reverses the logic of its argument',
177177+ params: [
178178+ { name: 'logical', desc: 'The value or expression to negate', required: true },
179179+ ],
180180+ },
181181+ IFERROR: {
182182+ desc: 'Returns a value if no error, otherwise returns an alternate value',
183183+ params: [
184184+ { name: 'value', desc: 'The value to check for an error', required: true },
185185+ { name: 'value_if_error', desc: 'Value to return if an error is found', required: true },
186186+ ],
187187+ },
188188+189189+ // --- Text ---
190190+ CONCATENATE: {
191191+ desc: 'Joins several text strings into one',
192192+ params: [
193193+ { name: 'text1', desc: 'First text string', required: true },
194194+ { name: 'text2', desc: 'Additional text strings to join', required: false },
195195+ ],
196196+ },
197197+ LEN: {
198198+ desc: 'Returns the number of characters in a text string',
199199+ params: [
200200+ { name: 'text', desc: 'The text string to measure', required: true },
201201+ ],
202202+ },
203203+ LEFT: {
204204+ desc: 'Returns the leftmost characters from a text string',
205205+ params: [
206206+ { name: 'text', desc: 'The source text string', required: true },
207207+ { name: 'num_chars', desc: 'Number of characters to extract (default 1)', required: false },
208208+ ],
209209+ },
210210+ RIGHT: {
211211+ desc: 'Returns the rightmost characters from a text string',
212212+ params: [
213213+ { name: 'text', desc: 'The source text string', required: true },
214214+ { name: 'num_chars', desc: 'Number of characters to extract (default 1)', required: false },
215215+ ],
216216+ },
217217+ MID: {
218218+ desc: 'Returns a specific number of characters from a text string',
219219+ params: [
220220+ { name: 'text', desc: 'The source text string', required: true },
221221+ { name: 'start_num', desc: 'Position of the first character (1-based)', required: true },
222222+ { name: 'num_chars', desc: 'Number of characters to extract', required: true },
223223+ ],
224224+ },
225225+ UPPER: {
226226+ desc: 'Converts text to uppercase',
227227+ params: [
228228+ { name: 'text', desc: 'The text to convert', required: true },
229229+ ],
230230+ },
231231+ LOWER: {
232232+ desc: 'Converts text to lowercase',
233233+ params: [
234234+ { name: 'text', desc: 'The text to convert', required: true },
235235+ ],
236236+ },
237237+ TRIM: {
238238+ desc: 'Removes leading and trailing spaces from text',
239239+ params: [
240240+ { name: 'text', desc: 'The text to trim', required: true },
241241+ ],
242242+ },
243243+ SUBSTITUTE: {
244244+ desc: 'Replaces occurrences of old text with new text in a string',
245245+ params: [
246246+ { name: 'text', desc: 'The text containing characters to replace', required: true },
247247+ { name: 'old_text', desc: 'The text to find and replace', required: true },
248248+ { name: 'new_text', desc: 'The replacement text', required: true },
249249+ { name: 'instance', desc: 'Which occurrence to replace (default: all)', required: false },
250250+ ],
251251+ },
252252+ FIND: {
253253+ desc: 'Finds the position of a text string within another (case-sensitive)',
254254+ params: [
255255+ { name: 'find_text', desc: 'The text to find', required: true },
256256+ { name: 'within_text', desc: 'The text to search within', required: true },
257257+ { name: 'start_num', desc: 'Position to start searching from (default 1)', required: false },
258258+ ],
259259+ },
260260+ SEARCH: {
261261+ desc: 'Finds the position of a text string within another (case-insensitive)',
262262+ params: [
263263+ { name: 'find_text', desc: 'The text to find', required: true },
264264+ { name: 'within_text', desc: 'The text to search within', required: true },
265265+ { name: 'start_num', desc: 'Position to start searching from (default 1)', required: false },
266266+ ],
267267+ },
268268+ TEXT: {
269269+ desc: 'Formats a number as text with a specified format',
270270+ params: [
271271+ { name: 'value', desc: 'The number to format', required: true },
272272+ { name: 'format_text', desc: 'Format pattern (e.g. "0.00", "#,##0")', required: true },
273273+ ],
274274+ },
275275+ VALUE: {
276276+ desc: 'Converts a text string that represents a number to a number',
277277+ params: [
278278+ { name: 'text', desc: 'The text to convert to a number', required: true },
279279+ ],
280280+ },
281281+282282+ // --- Date ---
283283+ NOW: {
284284+ desc: 'Returns the current date and time',
285285+ params: [],
286286+ },
287287+ TODAY: {
288288+ desc: 'Returns the current date',
289289+ params: [],
290290+ },
291291+ DATE: {
292292+ desc: 'Creates a date from year, month, and day components',
293293+ params: [
294294+ { name: 'year', desc: 'The year (e.g. 2024)', required: true },
295295+ { name: 'month', desc: 'The month (1-12)', required: true },
296296+ { name: 'day', desc: 'The day of the month (1-31)', required: true },
297297+ ],
298298+ },
299299+ YEAR: {
300300+ desc: 'Returns the year of a date',
301301+ params: [
302302+ { name: 'date', desc: 'The date to extract the year from', required: true },
303303+ ],
304304+ },
305305+ MONTH: {
306306+ desc: 'Returns the month of a date (1-12)',
307307+ params: [
308308+ { name: 'date', desc: 'The date to extract the month from', required: true },
309309+ ],
310310+ },
311311+ DAY: {
312312+ desc: 'Returns the day of the month of a date (1-31)',
313313+ params: [
314314+ { name: 'date', desc: 'The date to extract the day from', required: true },
315315+ ],
316316+ },
317317+318318+ // --- Lookup ---
319319+ VLOOKUP: {
320320+ desc: 'Searches first column of range for key and returns value from specified column',
321321+ params: [
322322+ { name: 'lookup_value', desc: 'The value to search for', required: true },
323323+ { name: 'table_array', desc: 'Range containing the data', required: true },
324324+ { name: 'col_index', desc: 'Column number to return (1-based)', required: true },
325325+ { name: 'range_lookup', desc: 'FALSE for exact match, TRUE for approximate', required: false },
326326+ ],
327327+ },
328328+ HLOOKUP: {
329329+ desc: 'Searches first row of range for key and returns value from specified row',
330330+ params: [
331331+ { name: 'lookup_value', desc: 'The value to search for', required: true },
332332+ { name: 'table_array', desc: 'Range containing the data', required: true },
333333+ { name: 'row_index', desc: 'Row number to return (1-based)', required: true },
334334+ { name: 'range_lookup', desc: 'FALSE for exact match, TRUE for approximate', required: false },
335335+ ],
336336+ },
337337+ INDEX: {
338338+ desc: 'Returns the value of a cell in a range at a given row and column',
339339+ params: [
340340+ { name: 'array', desc: 'The range of cells', required: true },
341341+ { name: 'row_num', desc: 'Row number in the range (1-based)', required: true },
342342+ { name: 'col_num', desc: 'Column number in the range (1-based)', required: false },
343343+ ],
344344+ },
345345+ MATCH: {
346346+ desc: 'Returns the relative position of a value in a range',
347347+ params: [
348348+ { name: 'lookup_value', desc: 'The value to search for', required: true },
349349+ { name: 'lookup_array', desc: 'The range to search', required: true },
350350+ { name: 'match_type', desc: '1 for less than, 0 for exact, -1 for greater than', required: false },
351351+ ],
352352+ },
353353+354354+ // --- Conditional ---
355355+ SUMIF: {
356356+ desc: 'Sums cells that meet a specified condition',
357357+ params: [
358358+ { name: 'range', desc: 'The range to evaluate against the criteria', required: true },
359359+ { name: 'criteria', desc: 'The condition to match (e.g. ">100")', required: true },
360360+ { name: 'sum_range', desc: 'The range to sum (default: same as range)', required: false },
361361+ ],
362362+ },
363363+ COUNTIF: {
364364+ desc: 'Counts cells that meet a specified condition',
365365+ params: [
366366+ { name: 'range', desc: 'The range to evaluate', required: true },
367367+ { name: 'criteria', desc: 'The condition to match', required: true },
368368+ ],
369369+ },
370370+ AVERAGEIF: {
371371+ desc: 'Averages cells that meet a specified condition',
372372+ params: [
373373+ { name: 'range', desc: 'The range to evaluate against the criteria', required: true },
374374+ { name: 'criteria', desc: 'The condition to match', required: true },
375375+ { name: 'average_range', desc: 'The range to average (default: same as range)', required: false },
376376+ ],
377377+ },
378378+};
379379+380380+/**
381381+ * Detect which function the cursor is currently inside, and which
382382+ * parameter index it's at.
383383+ *
384384+ * Parses backwards from the cursor position, counting parentheses
385385+ * and commas to determine the active function and parameter.
386386+ *
387387+ * @param {string} formula - The full formula string (including '=')
388388+ * @param {number} cursorPosition - The cursor position in the string
389389+ * @returns {{ functionName: string, paramIndex: number } | null}
390390+ */
391391+export function detectCurrentFunction(formula, cursorPosition) {
392392+ if (!formula || cursorPosition <= 0) return null;
393393+394394+ // Work with the portion up to the cursor
395395+ const text = formula.slice(0, cursorPosition);
396396+397397+ // Walk backwards tracking paren depth and commas
398398+ let depth = 0;
399399+ let commas = 0;
400400+ let inString = false;
401401+402402+ for (let i = text.length - 1; i >= 0; i--) {
403403+ const ch = text[i];
404404+405405+ // Track string literals (scanning backwards)
406406+ if (ch === '"') {
407407+ // Check if this quote is escaped
408408+ let escapes = 0;
409409+ let j = i - 1;
410410+ while (j >= 0 && text[j] === '\\') { escapes++; j--; }
411411+ if (escapes % 2 === 0) {
412412+ inString = !inString;
413413+ }
414414+ continue;
415415+ }
416416+417417+ if (inString) continue;
418418+419419+ if (ch === ')') {
420420+ depth++;
421421+ continue;
422422+ }
423423+424424+ if (ch === '(') {
425425+ if (depth > 0) {
426426+ depth--;
427427+ continue;
428428+ }
429429+430430+ // We found the matching open paren at depth 0.
431431+ // Look backwards from here to find the function name.
432432+ let nameEnd = i;
433433+ // Skip whitespace before the paren
434434+ let k = i - 1;
435435+ while (k >= 0 && text[k] === ' ') k--;
436436+437437+ // Read the function name
438438+ let nameStart = k;
439439+ while (nameStart >= 0 && /[A-Za-z0-9_]/.test(text[nameStart])) {
440440+ nameStart--;
441441+ }
442442+ nameStart++; // back to first char of name
443443+444444+ if (nameStart <= k) {
445445+ const name = text.slice(nameStart, k + 1).toUpperCase();
446446+ if (FUNCTION_METADATA[name]) {
447447+ return { functionName: name, paramIndex: commas };
448448+ }
449449+ }
450450+451451+ // Not a recognized function — keep going up
452452+ // (the open paren might be a grouping paren, not a function call)
453453+ // Reset commas for the outer level
454454+ commas = 0;
455455+ continue;
456456+ }
457457+458458+ if (ch === ',' && depth === 0) {
459459+ commas++;
460460+ continue;
461461+ }
462462+ }
463463+464464+ return null;
465465+}
466466+467467+/**
468468+ * Render the tooltip DOM element showing function info.
469469+ *
470470+ * @param {string} functionName - The function name (uppercase)
471471+ * @param {number} paramIndex - The current parameter index
472472+ * @param {HTMLElement} anchorElement - The element to position the tooltip near
473473+ * @returns {HTMLElement | null} The tooltip element, or null if function not found
474474+ */
475475+export function renderTooltip(functionName, paramIndex, anchorElement) {
476476+ const meta = FUNCTION_METADATA[functionName];
477477+ if (!meta) return null;
478478+479479+ // Remove any existing tooltip
480480+ hideTooltip();
481481+482482+ const tooltip = document.createElement('div');
483483+ tooltip.className = 'formula-tooltip';
484484+ tooltip.id = 'formula-tooltip';
485485+486486+ // Build signature line with highlighted current param
487487+ const sigParts = [];
488488+ sigParts.push(`<span class="formula-tooltip-fn">${functionName}</span>(`);
489489+ meta.params.forEach((p, idx) => {
490490+ if (idx > 0) sigParts.push(', ');
491491+ const isActive = idx === paramIndex;
492492+ const cls = isActive ? 'formula-tooltip-param-active' : 'formula-tooltip-param';
493493+ const bracket = p.required ? '' : ['[', ']'];
494494+ if (!p.required) sigParts.push('<span class="formula-tooltip-optional">[</span>');
495495+ sigParts.push(`<span class="${cls}">${p.name}</span>`);
496496+ if (!p.required) sigParts.push('<span class="formula-tooltip-optional">]</span>');
497497+ });
498498+ sigParts.push(')');
499499+500500+ // Build param description (for active param)
501501+ let paramDesc = '';
502502+ if (meta.params[paramIndex]) {
503503+ const p = meta.params[paramIndex];
504504+ paramDesc = `<div class="formula-tooltip-param-desc"><strong>${p.name}</strong>: ${p.desc}</div>`;
505505+ }
506506+507507+ tooltip.innerHTML = `
508508+ <div class="formula-tooltip-signature">${sigParts.join('')}</div>
509509+ ${paramDesc}
510510+ <div class="formula-tooltip-desc">${meta.desc}</div>
511511+ `;
512512+513513+ // Position near anchor
514514+ document.body.appendChild(tooltip);
515515+516516+ if (anchorElement) {
517517+ const rect = anchorElement.getBoundingClientRect();
518518+ const tooltipRect = tooltip.getBoundingClientRect();
519519+ const viewportHeight = window.innerHeight;
520520+521521+ // Prefer placing below the anchor
522522+ let top = rect.bottom + 4;
523523+ if (top + tooltipRect.height > viewportHeight) {
524524+ // Place above if not enough room below
525525+ top = rect.top - tooltipRect.height - 4;
526526+ }
527527+528528+ tooltip.style.left = `${rect.left}px`;
529529+ tooltip.style.top = `${top}px`;
530530+ }
531531+532532+ return tooltip;
533533+}
534534+535535+/**
536536+ * Remove the tooltip from the DOM.
537537+ */
538538+export function hideTooltip() {
539539+ const existing = document.getElementById('formula-tooltip');
540540+ if (existing) existing.remove();
541541+}
+282-2
src/sheets/formulas.js
···315315 // Named range identifier (not a function, not a cell ref)
316316 if (t.type === TokenType.IDENTIFIER) {
317317 this.advance();
318318+ // Check LET-scoped variables first
319319+ const identLower = t.value.toLowerCase();
320320+ if (this._letScope && identLower in this._letScope) {
321321+ return this._letScope[identLower];
322322+ }
318323 return this.resolveNamedRange(t.value);
319324 }
320325321326 if (t.type === TokenType.FUNCTION) {
322327 this.advance();
328328+ // Special handling for LET — needs compile-time name resolution
329329+ if (t.value === 'LET') {
330330+ return this.parseLet();
331331+ }
323332 this.expect(TokenType.LPAREN);
324333 const args = [];
325334 if (this.peek().type !== TokenType.RPAREN) {
326326- args.push(this.parseFunctionArg());
335335+ // Handle first arg — could be omitted if first token is COMMA
336336+ if (this.peek().type === TokenType.COMMA) {
337337+ args.push(undefined);
338338+ } else {
339339+ args.push(this.parseFunctionArg());
340340+ }
327341 while (this.peek().type === TokenType.COMMA) {
328342 this.advance();
329329- args.push(this.parseFunctionArg());
343343+ // Handle omitted arguments (consecutive commas or trailing comma before RPAREN)
344344+ if (this.peek().type === TokenType.COMMA || this.peek().type === TokenType.RPAREN) {
345345+ args.push(undefined);
346346+ } else {
347347+ args.push(this.parseFunctionArg());
348348+ }
330349 }
331350 }
332351 this.expect(TokenType.RPAREN);
···385404 return this.expression();
386405 }
387406407407+ // Parse LET(name1, value1, [name2, value2, ...], calculation)
408408+ parseLet() {
409409+ this.expect(TokenType.LPAREN);
410410+ const prevScope = this._letScope ? { ...this._letScope } : null;
411411+ if (!this._letScope) this._letScope = {};
412412+413413+ // Collect name/value pairs
414414+ // LET requires at least 3 args: name, value, calculation
415415+ // We read pairs of (identifier, expression) then a final expression
416416+ const names = [];
417417+ const values = [];
418418+419419+ // Read first name
420420+ while (true) {
421421+ // Current token should be an identifier (the variable name)
422422+ // The tokenizer may have produced FUNCTION, IDENTIFIER, or CELL_REF for the name
423423+ const nameToken = this.peek();
424424+ let varName;
425425+ if (nameToken.type === TokenType.IDENTIFIER || nameToken.type === TokenType.FUNCTION) {
426426+ this.advance();
427427+ varName = nameToken.value.toLowerCase();
428428+ } else if (nameToken.type === TokenType.CELL_REF) {
429429+ // Allow cell-ref-like identifiers as LET names (e.g., "x" wouldn't hit this, but handle gracefully)
430430+ this.advance();
431431+ varName = nameToken.value.toLowerCase();
432432+ } else {
433433+ throw new Error('LET: expected variable name');
434434+ }
435435+436436+ this.expect(TokenType.COMMA);
437437+ // Parse the value expression
438438+ const val = this.parseFunctionArg();
439439+ this._letScope[varName] = val;
440440+ names.push(varName);
441441+ values.push(val);
442442+443443+ // Now check: is the next thing a COMMA followed by what looks like another name/value pair,
444444+ // or is it RPAREN (meaning we just got the calculation as the value)?
445445+ // Actually, the grammar is: after value, if COMMA follows, there's either another name/value pair or the final calc.
446446+ // We need to peek ahead: COMMA + IDENTIFIER/FUNCTION + COMMA means more pairs.
447447+ // COMMA + expression + RPAREN means final calculation.
448448+ if (this.peek().type === TokenType.COMMA) {
449449+ this.advance(); // consume comma
450450+451451+ // Check if what follows is "identifier COMMA" pattern (another name/value pair)
452452+ // or if it's the final calculation expression
453453+ const savedPos = this.pos;
454454+ const nextToken = this.peek();
455455+ if ((nextToken.type === TokenType.IDENTIFIER || nextToken.type === TokenType.FUNCTION) &&
456456+ this.tokens[this.pos + 1]?.type === TokenType.COMMA) {
457457+ // Another name/value pair — continue the loop
458458+ continue;
459459+ }
460460+ // It's the final calculation
461461+ const result = this.parseFunctionArg();
462462+ this.expect(TokenType.RPAREN);
463463+ // Restore previous scope
464464+ this._letScope = prevScope;
465465+ return result;
466466+ } else if (this.peek().type === TokenType.RPAREN) {
467467+ // The "value" we just parsed IS the calculation (single name/value pair case)
468468+ // Wait, this means we had LET(name, calc) with only 2 args, which is invalid.
469469+ // Actually, in our loop: we read name, comma, value. If next is RPAREN,
470470+ // then value IS the final calculation expression.
471471+ // But LET needs at least name, value, calculation (3 args).
472472+ // Re-reading the logic: we consumed name + comma + value.
473473+ // If RPAREN follows, that means: LET(name, value) — only 2 args, invalid.
474474+ // But actually for LET(name, value, calc): after reading name+comma, we parse value.
475475+ // Then we see COMMA, advance, see it's the final calc, parse it, expect RPAREN.
476476+ // So hitting RPAREN here means LET(name, expression) — invalid, but let's be lenient
477477+ // and just return the value.
478478+ this.advance(); // consume RPAREN
479479+ this._letScope = prevScope;
480480+ return val;
481481+ }
482482+ }
483483+ }
484484+388485 resolveRange(startRef, endRef) {
389486 const start = parseRef(startRef);
390487 const end = parseRef(endRef);
···481578 case 'EXP': return Math.exp(toNum(args[0]));
482579 case 'PI': return Math.PI;
483580 case 'RAND': return Math.random();
581581+ case 'RANDBETWEEN': {
582582+ const bottom = Math.ceil(toNum(args[0]));
583583+ const top = Math.floor(toNum(args[1]));
584584+ return Math.floor(Math.random() * (top - bottom + 1)) + bottom;
585585+ }
484586485587 case 'IF': return args[0] ? args[1] : (args[2] ?? false);
486588 case 'AND': return flat(args).every(Boolean);
···600702 return Math.sqrt(variance);
601703 }
602704705705+ case 'XLOOKUP': {
706706+ const needle = args[0];
707707+ const lookupArr = Array.isArray(args[1]) ? args[1] : [args[1]];
708708+ const returnArr = Array.isArray(args[2]) ? args[2] : [args[2]];
709709+ const ifNotFound = args[3] !== undefined ? args[3] : '#N/A';
710710+ const matchMode = args[4] !== undefined ? toNum(args[4]) : 0;
711711+ const searchMode = args[5] !== undefined ? toNum(args[5]) : 1;
712712+713713+ // Flatten lookup and return arrays to 1D — use their linear length
714714+ const lookupLen = lookupArr.length;
715715+ const indices = [];
716716+ for (let i = 0; i < lookupLen; i++) indices.push(i);
717717+ if (searchMode === -1) indices.reverse();
718718+719719+ let foundIdx = -1;
720720+721721+ if (matchMode === 0) {
722722+ // Exact match
723723+ for (const i of indices) {
724724+ if (valuesEqual(lookupArr[i], needle)) { foundIdx = i; break; }
725725+ }
726726+ } else if (matchMode === 2) {
727727+ // Wildcard match
728728+ const pattern = wildcardToRegex(String(needle));
729729+ for (const i of indices) {
730730+ if (pattern.test(String(lookupArr[i]))) { foundIdx = i; break; }
731731+ }
732732+ } else if (matchMode === -1) {
733733+ // Exact or next smaller
734734+ let bestIdx = -1;
735735+ let bestVal = -Infinity;
736736+ for (let i = 0; i < lookupLen; i++) {
737737+ const v = lookupArr[i];
738738+ const cmp = compareValues(v, needle);
739739+ if (cmp === 0) { foundIdx = i; break; }
740740+ if (cmp < 0 && toNum(v) > bestVal) { bestVal = toNum(v); bestIdx = i; }
741741+ }
742742+ if (foundIdx === -1) foundIdx = bestIdx;
743743+ } else if (matchMode === 1) {
744744+ // Exact or next larger
745745+ let bestIdx = -1;
746746+ let bestVal = Infinity;
747747+ for (let i = 0; i < lookupLen; i++) {
748748+ const v = lookupArr[i];
749749+ const cmp = compareValues(v, needle);
750750+ if (cmp === 0) { foundIdx = i; break; }
751751+ if (cmp > 0 && toNum(v) < bestVal) { bestVal = toNum(v); bestIdx = i; }
752752+ }
753753+ if (foundIdx === -1) foundIdx = bestIdx;
754754+ }
755755+756756+ if (foundIdx === -1) return ifNotFound;
757757+ return returnArr[foundIdx] !== undefined ? returnArr[foundIdx] : ifNotFound;
758758+ }
759759+760760+ case 'SUMIFS': {
761761+ // SUMIFS(sum_range, criteria_range1, criteria1, [criteria_range2, criteria2], ...)
762762+ const sumRange = Array.isArray(args[0]) ? args[0] : [args[0]];
763763+ const criteriaCount = Math.floor((args.length - 1) / 2);
764764+ let sum = 0;
765765+ for (let i = 0; i < sumRange.length; i++) {
766766+ let allMatch = true;
767767+ for (let c = 0; c < criteriaCount; c++) {
768768+ const critRange = Array.isArray(args[1 + c * 2]) ? args[1 + c * 2] : [args[1 + c * 2]];
769769+ const criteria = args[2 + c * 2];
770770+ if (!matchCriteriaWild(critRange[i], criteria)) { allMatch = false; break; }
771771+ }
772772+ if (allMatch) sum += toNum(sumRange[i] ?? 0);
773773+ }
774774+ return sum;
775775+ }
776776+777777+ case 'COUNTIFS': {
778778+ // COUNTIFS(criteria_range1, criteria1, [criteria_range2, criteria2], ...)
779779+ const criteriaCount = Math.floor(args.length / 2);
780780+ if (criteriaCount === 0) return 0;
781781+ const firstRange = Array.isArray(args[0]) ? args[0] : [args[0]];
782782+ let count = 0;
783783+ for (let i = 0; i < firstRange.length; i++) {
784784+ let allMatch = true;
785785+ for (let c = 0; c < criteriaCount; c++) {
786786+ const critRange = Array.isArray(args[c * 2]) ? args[c * 2] : [args[c * 2]];
787787+ const criteria = args[1 + c * 2];
788788+ if (!matchCriteriaWild(critRange[i], criteria)) { allMatch = false; break; }
789789+ }
790790+ if (allMatch) count++;
791791+ }
792792+ return count;
793793+ }
794794+795795+ case 'AVERAGEIFS': {
796796+ // AVERAGEIFS(average_range, criteria_range1, criteria1, ...)
797797+ const avgRange = Array.isArray(args[0]) ? args[0] : [args[0]];
798798+ const criteriaCount = Math.floor((args.length - 1) / 2);
799799+ const vals = [];
800800+ for (let i = 0; i < avgRange.length; i++) {
801801+ let allMatch = true;
802802+ for (let c = 0; c < criteriaCount; c++) {
803803+ const critRange = Array.isArray(args[1 + c * 2]) ? args[1 + c * 2] : [args[1 + c * 2]];
804804+ const criteria = args[2 + c * 2];
805805+ if (!matchCriteriaWild(critRange[i], criteria)) { allMatch = false; break; }
806806+ }
807807+ if (allMatch) vals.push(toNum(avgRange[i] ?? 0));
808808+ }
809809+ return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : '#DIV/0!';
810810+ }
811811+812812+ case 'TEXTJOIN': {
813813+ const delimiter = String(args[0]);
814814+ const ignoreEmpty = Boolean(args[1]);
815815+ const values = [];
816816+ for (let i = 2; i < args.length; i++) {
817817+ if (Array.isArray(args[i])) {
818818+ for (const v of args[i]) values.push(v);
819819+ } else {
820820+ values.push(args[i]);
821821+ }
822822+ }
823823+ const filtered = ignoreEmpty ? values.filter(v => v !== '' && v !== null && v !== undefined) : values;
824824+ return filtered.map(String).join(delimiter);
825825+ }
826826+827827+ case 'CONCAT': {
828828+ const values = [];
829829+ for (const arg of args) {
830830+ if (Array.isArray(arg)) {
831831+ for (const v of arg) {
832832+ if (v !== '' && v !== null && v !== undefined) values.push(v);
833833+ }
834834+ } else {
835835+ values.push(arg);
836836+ }
837837+ }
838838+ return values.map(String).join('');
839839+ }
840840+841841+ case 'SWITCH': {
842842+ // SWITCH(expression, case1, value1, [case2, value2, ...], [default])
843843+ const expr = args[0];
844844+ const pairs = args.slice(1);
845845+ const hasDefault = pairs.length % 2 === 1;
846846+ const pairCount = Math.floor(pairs.length / 2);
847847+ for (let i = 0; i < pairCount; i++) {
848848+ if (valuesEqual(expr, pairs[i * 2])) return pairs[i * 2 + 1];
849849+ }
850850+ return hasDefault ? pairs[pairs.length - 1] : '#N/A';
851851+ }
852852+603853 default: return `#NAME? (${name})`;
604854 }
605855}
···626876 if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1));
627877 if (criteria.startsWith('=')) return String(value) === criteria.slice(1);
628878 return String(value).toLowerCase() === String(criteria).toLowerCase();
879879+ }
880880+ return value === criteria;
881881+}
882882+883883+/** Convert wildcard pattern (* and ?) to a RegExp */
884884+function wildcardToRegex(pattern) {
885885+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
886886+ const regexStr = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
887887+ return new RegExp('^' + regexStr + '$', 'i');
888888+}
889889+890890+/** matchCriteria with wildcard support for SUMIFS/COUNTIFS/AVERAGEIFS */
891891+function matchCriteriaWild(value, criteria) {
892892+ if (typeof criteria === 'string') {
893893+ if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2));
894894+ if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2));
895895+ if (criteria.startsWith('<>')) return String(value) !== criteria.slice(2);
896896+ if (criteria.startsWith('>')) return toNum(value) > toNum(criteria.slice(1));
897897+ if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1));
898898+ if (criteria.startsWith('=')) return String(value) === criteria.slice(1);
899899+ // Check for wildcards
900900+ if (criteria.includes('*') || criteria.includes('?')) {
901901+ return wildcardToRegex(criteria).test(String(value));
902902+ }
903903+ // Empty criteria matches empty cells
904904+ if (criteria === '') return value === '' || value === null || value === undefined;
905905+ return String(value).toLowerCase() === String(criteria).toLowerCase();
906906+ }
907907+ if (typeof criteria === 'number') {
908908+ return toNum(value) === criteria;
629909 }
630910 return value === criteria;
631911}