Skip to main content

improv_core_model/
functions.rs

1//! The built-in function registry: ONE table for every function the formula
2//! language knows, read by the parser (names, aliases, arity), the compiler
3//! (argument and result types, kind), the GUI and CNL (spellings), and the
4//! generated function reference (`docs/src/functions.md`).
5//!
6//! Evaluation lives in the engine (`improv_engine::builtins`); a test there
7//! walks [`all`] and fails if any evaluable id has no implementation.
8//!
9//! A [`FuncId`] is persisted in every stored formula, so ids are stable
10//! forever: never renumber or reuse one. Aliases resolve to their canonical
11//! id at parse time, so stored formulas never hold an alias.
12
13use crate::formula::FuncId;
14use crate::value::ValueType;
15
16/// How a function is compiled.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Kind {
19    /// Element-wise over its arguments' aligned cells (`ABS`, `LEFT`, n-ary
20    /// `SUM(a, b)`).
21    Scalar,
22    /// Collapses the `OVER` categories: `SUM(x OVER Time)`. The arguments at
23    /// [`FnDef::over`] positions are measures reduced together (joined on
24    /// their full coordinate); the others are parameters, one value per
25    /// group (`LARGE(x OVER T, 2)`, `NPV(rate, cf OVER T)`).
26    Aggregate,
27    /// Like [`Kind::Aggregate`] but one result per item of the (single)
28    /// `OVER` category, which the result keeps: `RANK(x OVER T)`.
29    PerItem,
30    /// A list of results laid along the `INTO` category's items (the k-th
31    /// result at its k-th item): `SORT(x OVER T INTO Rank)`.
32    Array,
33    /// Evaluates only the arguments it needs (`IF`, `IFERROR`, …); the compiler
34    /// lowers it specially.
35    Lazy,
36}
37
38/// An argument's type.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Arg {
41    Number,
42    Text,
43    Boolean,
44    /// A date and time.
45    Date,
46    /// A number or a date (`YEAR`, `EDATE`, the bond functions): dates as
47    /// themselves, numbers as spreadsheet serial dates.
48    DateLike,
49    /// Any value type (`CONCAT`, `ISERROR`).
50    Any,
51    /// A category name, not a value (`POSITION(Time)`).
52    Category,
53}
54
55/// What a function returns.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Ret {
58    Fixed(ValueType),
59    /// The unified type of the value arguments (`IF`'s branches); the compiler
60    /// computes it per [`Kind::Lazy`] function.
61    Unify,
62    /// The type of argument `i` (`XLOOKUP`'s values, not its keys).
63    Arg(usize),
64}
65
66/// A function's documentation category (groups the reference page).
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
68pub enum Category {
69    Aggregation,
70    Logical,
71    Information,
72    Math,
73    Rounding,
74    Text,
75    DateTime,
76    Statistical,
77    Financial,
78    Dimensional,
79    Engineering,
80}
81
82/// Which arguments of an aggregation are reduced over the `OVER` categories.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Over {
85    /// These positions (`CORREL(x, y OVER T)` is `[0, 1]`).
86    At(&'static [usize]),
87    /// From `start`, every `step`-th argument (`COUNTIFS(c1 OVER T, k1, c2,
88    /// k2, …)` is `Every(0, 2)`).
89    Every(usize, usize),
90    /// These positions, then from `start` every `step`-th (`SORTBY(x, by1
91    /// OVER T, o1, by2, o2, …)` is `AtEvery(&[0, 1], 3, 2)`).
92    AtEvery(&'static [usize], usize, usize),
93    /// The leading arguments that are measure references, at least `min`
94    /// (`LINEST(y, x1, x2 OVER T, const, stats)`); the parser decides and
95    /// records it on the references' `over`, so later readers ask
96    /// [`FnDef::reduced`] with the argument.
97    Leading(usize),
98}
99
100impl Over {
101    /// Whether argument `i` is reduced.
102    pub fn contains(&self, i: usize) -> bool {
103        match self {
104            Over::At(v) => v.contains(&i),
105            Over::Every(start, step) => i >= *start && (i - start).is_multiple_of(*step),
106            Over::AtEvery(v, start, step) => {
107                v.contains(&i) || (i >= *start && (i - start).is_multiple_of(*step))
108            }
109            Over::Leading(min) => i < *min,
110        }
111    }
112}
113
114/// One built-in function.
115#[derive(Debug, Clone, Copy)]
116pub struct FnDef {
117    pub id: FuncId,
118    /// Canonical (Excel) spelling.
119    pub name: &'static str,
120    /// Other spellings that mean exactly this function.
121    pub aliases: &'static [&'static str],
122    pub kind: Kind,
123    pub category: Category,
124    /// Leading required arguments, then `optional` optional ones; with
125    /// `variadic`, the last parameter repeats (at least once).
126    pub params: &'static [Arg],
127    pub required: usize,
128    pub variadic: bool,
129    pub ret: Ret,
130    /// One-line description for the reference page.
131    pub doc: &'static str,
132    /// For [`Kind::Aggregate`] / [`Kind::PerItem`]: the reduced arguments.
133    pub over: Over,
134}
135
136impl FnDef {
137    /// Whether `n` arguments is a valid call.
138    pub fn accepts(&self, n: usize) -> bool {
139        n >= self.required && (self.variadic || n <= self.params.len())
140    }
141
142    /// The expected type of argument `i` (variadic tails repeat the last).
143    pub fn param(&self, i: usize) -> Arg {
144        self.params
145            .get(i)
146            .or(self.params.last())
147            .copied()
148            .unwrap_or(Arg::Any)
149    }
150
151    /// Whether argument `i` (as parsed, `arg`) is reduced over the OVER
152    /// categories. For [`Over::Leading`] the parser marked the reduced
153    /// references with their `over`.
154    pub fn reduced(&self, i: usize, arg: &crate::formula::Expr) -> bool {
155        match self.over {
156            Over::Leading(_) => {
157                matches!(arg, crate::formula::Expr::Ref(_, s) if !s.over.is_empty())
158            }
159            o => o.contains(i),
160        }
161    }
162
163    /// The parameters' names, for signature help and ghost text: from the
164    /// table in `functions/params.rs`, else those the description spells
165    /// (`PMT(rate, nper, pv, [fv], [type])`), else each parameter's type
166    /// (`number`). Optional ones are `[bracketed]`, a variadic tail is `…`,
167    /// a reduced one is `x OVER T`, and a list result ends in `INTO K`.
168    pub fn param_names(&self) -> Vec<String> {
169        self.named_params().unwrap_or_else(|| self.type_names())
170    }
171
172    /// The names from the table or the description, if either has them.
173    fn named_params(&self) -> Option<Vec<String>> {
174        // An aggregation and its scalar twin share a name: the entry
175        // with an OVER is the aggregation's.
176        let mut rows = params::PARAM_NAMES.iter().filter(|(n, _)| *n == self.name);
177        rows.clone()
178            .find(|(_, p)| p.contains(" OVER ") == self.kind.reduces())
179            .or_else(|| rows.next())
180            .map(|(_, p)| split_params(p))
181            .or_else(|| spelled_params(self.name, self.doc))
182    }
183
184    /// The fallback: each parameter's type, as [`Self::signature`] lists them.
185    fn type_names(&self) -> Vec<String> {
186        let mut sig = self.signature();
187        sig.truncate(sig.len() - 1);
188        let inner = &sig[self.name.len() + 1..];
189        if inner.is_empty() {
190            return Vec::new();
191        }
192        inner.split(", ").map(str::to_string).collect()
193    }
194
195    /// `PMT(rate, nper, pv, [fv], [type])`: the name and [`Self::param_names`].
196    pub fn hint(&self) -> String {
197        format!("{}({})", self.name, self.param_names().join(", "))
198    }
199
200    /// `ABS(x)`-style signature for docs and error messages.
201    pub fn signature(&self) -> String {
202        let names: Vec<String> = self
203            .params
204            .iter()
205            .enumerate()
206            .map(|(i, a)| {
207                let t = match a {
208                    Arg::Number => "number",
209                    Arg::Text => "text",
210                    Arg::Boolean => "boolean",
211                    Arg::Date => "date",
212                    Arg::DateLike => "date",
213                    Arg::Any => "value",
214                    Arg::Category => "category",
215                };
216                let t = if self.kind.reduces() && self.over.contains(i) {
217                    format!("{t} OVER category")
218                } else {
219                    t.to_string()
220                };
221                if i >= self.required {
222                    format!("[{t}]")
223                } else {
224                    t
225                }
226            })
227            .collect();
228        let mut s = names.join(", ");
229        if self.variadic {
230            s.push_str(", …");
231        }
232        format!("{}({s})", self.name)
233    }
234}
235
236use Arg::{Any as A, Boolean as B, Number as N, Text as T};
237use Category as C;
238use ValueType as V;
239
240#[allow(
241    clippy::too_many_arguments,
242    reason = "a table row: one argument per FnDef field"
243)]
244// Evaluated at compile time (they build the static table), so no test
245// can execute them: excluded from the runtime coverage report.
246#[cfg_attr(coverage_nightly, coverage(off))]
247pub(crate) const fn f(
248    id: u32,
249    name: &'static str,
250    kind: Kind,
251    category: Category,
252    params: &'static [Arg],
253    required: usize,
254    variadic: bool,
255    ret: Ret,
256    doc: &'static str,
257) -> FnDef {
258    FnDef {
259        id: FuncId(id),
260        name,
261        aliases: &[],
262        kind,
263        category,
264        params,
265        required,
266        variadic,
267        ret,
268        doc,
269        over: Over::At(&[0]),
270    }
271}
272
273/// `d` with its reduced arguments.
274#[cfg_attr(coverage_nightly, coverage(off))]
275pub const fn over(mut d: FnDef, over: Over) -> FnDef {
276    d.over = over;
277    d
278}
279
280#[cfg_attr(coverage_nightly, coverage(off))]
281pub(crate) const fn alias(mut d: FnDef, aliases: &'static [&'static str]) -> FnDef {
282    d.aliases = aliases;
283    d
284}
285
286/// A scalar of `params` (all required) returning `ret`.
287#[cfg_attr(coverage_nightly, coverage(off))]
288pub(crate) const fn s(
289    id: u32,
290    name: &'static str,
291    cat: Category,
292    params: &'static [Arg],
293    ret: ValueType,
294    doc: &'static str,
295) -> FnDef {
296    f(
297        id,
298        name,
299        Kind::Scalar,
300        cat,
301        params,
302        params.len(),
303        false,
304        Ret::Fixed(ret),
305        doc,
306    )
307}
308
309/// A scalar with `required` of `params` mandatory.
310#[cfg_attr(coverage_nightly, coverage(off))]
311pub(crate) const fn so(
312    id: u32,
313    name: &'static str,
314    cat: Category,
315    params: &'static [Arg],
316    required: usize,
317    ret: ValueType,
318    doc: &'static str,
319) -> FnDef {
320    f(
321        id,
322        name,
323        Kind::Scalar,
324        cat,
325        params,
326        required,
327        false,
328        Ret::Fixed(ret),
329        doc,
330    )
331}
332
333/// A variadic scalar: one or more `arg`s.
334#[cfg_attr(coverage_nightly, coverage(off))]
335pub(crate) const fn sv(
336    id: u32,
337    name: &'static str,
338    cat: Category,
339    arg: &'static [Arg],
340    ret: ValueType,
341    doc: &'static str,
342) -> FnDef {
343    f(
344        id,
345        name,
346        Kind::Scalar,
347        cat,
348        arg,
349        1,
350        true,
351        Ret::Fixed(ret),
352        doc,
353    )
354}
355
356#[cfg_attr(coverage_nightly, coverage(off))]
357pub(crate) const fn agg(
358    id: u32,
359    name: &'static str,
360    arg: &'static [Arg],
361    ret: ValueType,
362    doc: &'static str,
363) -> FnDef {
364    f(
365        id,
366        name,
367        Kind::Aggregate,
368        C::Aggregation,
369        arg,
370        1,
371        false,
372        Ret::Fixed(ret),
373        doc,
374    )
375}
376
377/// The P1 core: aggregations, logical, information, math, rounding, text.
378/// Order is documentation order within a category.
379#[rustfmt::skip]
380pub static CORE: &[FnDef] = &[
381    // --- aggregations: F(x OVER Cat) ---
382    alias(agg(1, "SUM", &[N], V::Number, "the sum over a category"), &["DSUM"]),
383    alias(agg(2, "AVERAGE", &[N], V::Number, "the mean over a category"), &["AVG", "DAVERAGE"]),
384    alias(agg(3, "MIN", &[N], V::Number, "the smallest value over a category"), &["DMIN"]),
385    alias(agg(4, "MAX", &[N], V::Number, "the largest value over a category"), &["DMAX"]),
386    alias(agg(5, "PRODUCT", &[N], V::Number, "the product over a category"), &["DPRODUCT"]),
387    agg(6, "SUMSQ", &[N], V::Number, "the sum of squares over a category"),
388    agg(7, "AND", &[B], V::Boolean, "whether every value over a category is true"),
389    agg(8, "OR", &[B], V::Boolean, "whether any value over a category is true"),
390    agg(9, "XOR", &[B], V::Boolean, "whether an odd number of values over a category are true"),
391    // --- scalar twins of the aggregations: F(a, b, …) ---
392    sv(22, "SUM", C::Math, &[N], V::Number, "the sum of the arguments"),
393    sv(23, "AVERAGE", C::Math, &[N], V::Number, "the mean of the arguments"),
394    alias(sv(20, "MIN", C::Math, &[N], V::Number, "the smallest argument"), &["MIN2"]),
395    alias(sv(21, "MAX", C::Math, &[N], V::Number, "the largest argument"), &["MAX2"]),
396    sv(24, "PRODUCT", C::Math, &[N], V::Number, "the product of the arguments"),
397    sv(25, "SUMSQ", C::Math, &[N], V::Number, "the sum of the squares of the arguments"),
398    sv(26, "AND", C::Logical, &[B], V::Boolean, "whether every argument is true"),
399    sv(27, "OR", C::Logical, &[B], V::Boolean, "whether any argument is true"),
400    sv(28, "XOR", C::Logical, &[B], V::Boolean, "whether an odd number of arguments are true"),
401    // --- logical ---
402    f(30, "IF", Kind::Lazy, C::Logical, &[B, A, A], 2, false, Ret::Unify, "then where the test is true, else (or no cell) where it is false"),
403    f(31, "IFS", Kind::Lazy, C::Logical, &[B, A], 2, true, Ret::Unify, "the value paired with the first true test"),
404    f(32, "SWITCH", Kind::Lazy, C::Logical, &[A, A, A], 3, true, Ret::Unify, "the result paired with the first value equal to the expression, else the default"),
405    f(33, "CHOOSE", Kind::Lazy, C::Logical, &[N, A], 2, true, Ret::Unify, "the index_num-th value (1-based)"),
406    f(34, "IFERROR", Kind::Lazy, C::Logical, &[A, A], 2, false, Ret::Unify, "the value, or the alternative where it is an error"),
407    f(35, "IFNA", Kind::Lazy, C::Logical, &[A, A], 2, false, Ret::Unify, "the value, or the alternative where it is #N/A"),
408    f(36, "CLEARERROR", Kind::Lazy, C::Logical, &[A, A], 1, false, Ret::Unify, "the value, or the alternative (or no cell) where it is an error"),
409    f(37, "CLEARZERO", Kind::Lazy, C::Logical, &[N, N], 1, false, Ret::Fixed(V::Number), "the value, or the alternative (or no cell) where it is zero"),
410    f(41, "COALESCE", Kind::Lazy, C::Logical, &[A, A], 2, true, Ret::Unify, "a cell wherever any argument that reads a measure has one: the first argument with a cell, else the last (the default)"),
411    so(38, "BETWEEN", C::Logical, &[N, N, N, B], 3, V::Boolean, "whether low ≤ x ≤ high (strict when inclusive is FALSE)"),
412    s(39, "TRUE", C::Logical, &[], V::Boolean, "TRUE"),
413    s(40, "FALSE", C::Logical, &[], V::Boolean, "FALSE"),
414    // --- information / errors ---
415    s(50, "ISERROR", C::Information, &[A], V::Boolean, "whether the value is any error"),
416    s(51, "ISERR", C::Information, &[A], V::Boolean, "whether the value is an error other than #N/A"),
417    s(52, "ISNA", C::Information, &[A], V::Boolean, "whether the value is #N/A"),
418    s(53, "NA", C::Information, &[], V::Number, "the #N/A error"),
419    s(54, "ERROR", C::Information, &[T], V::Number, "the error a code names (\"#DIV/0!\")"),
420    s(55, "ERROR.TYPE", C::Information, &[A], V::Number, "1 #NULL!, 2 #DIV/0!, 3 #VALUE!, 4 #REF!, 5 #NAME?, 6 #NUM!, 7 #N/A; #N/A for a non-error"),
421    // --- math ---
422    s(10, "ABS", C::Math, &[N], V::Number, "absolute value"),
423    s(15, "NEG", C::Math, &[N], V::Number, "negation"),
424    s(100, "SIGN", C::Math, &[N], V::Number, "−1, 0 or 1"),
425    s(14, "SQRT", C::Math, &[N], V::Number, "square root (#NUM! below zero)"),
426    s(101, "SQRTPI", C::Math, &[N], V::Number, "√(x·π)"),
427    s(102, "EXP", C::Math, &[N], V::Number, "e to the power x"),
428    s(103, "EXPM1", C::Math, &[N], V::Number, "eˣ − 1, accurate near 0"),
429    s(104, "LN", C::Math, &[N], V::Number, "natural logarithm"),
430    s(105, "LN1P", C::Math, &[N], V::Number, "ln(1 + x), accurate near 0"),
431    so(106, "LOG", C::Math, &[N, N], 1, V::Number, "logarithm to a base (default 10)"),
432    s(107, "LOG10", C::Math, &[N], V::Number, "base-10 logarithm"),
433    s(108, "LOG2", C::Math, &[N], V::Number, "base-2 logarithm"),
434    s(109, "POWER", C::Math, &[N, N], V::Number, "number raised to power (same as ^)"),
435    s(110, "MOD", C::Math, &[N, N], V::Number, "remainder, with the divisor's sign"),
436    s(111, "QUOTIENT", C::Math, &[N, N], V::Number, "integer part of a quotient"),
437    s(112, "PI", C::Math, &[], V::Number, "π"),
438    s(113, "DEGREES", C::Math, &[N], V::Number, "radians to degrees"),
439    s(114, "RADIANS", C::Math, &[N], V::Number, "degrees to radians"),
440    s(115, "SIN", C::Math, &[N], V::Number, "sine (radians)"),
441    s(116, "COS", C::Math, &[N], V::Number, "cosine"),
442    s(117, "TAN", C::Math, &[N], V::Number, "tangent"),
443    s(118, "ASIN", C::Math, &[N], V::Number, "arcsine"),
444    s(119, "ACOS", C::Math, &[N], V::Number, "arccosine"),
445    s(120, "ATAN", C::Math, &[N], V::Number, "arctangent"),
446    s(121, "ATAN2", C::Math, &[N, N], V::Number, "angle of the point (x, y)"),
447    s(122, "SINH", C::Math, &[N], V::Number, "hyperbolic sine"),
448    s(123, "COSH", C::Math, &[N], V::Number, "hyperbolic cosine"),
449    s(124, "TANH", C::Math, &[N], V::Number, "hyperbolic tangent"),
450    s(125, "ASINH", C::Math, &[N], V::Number, "inverse hyperbolic sine"),
451    s(126, "ACOSH", C::Math, &[N], V::Number, "inverse hyperbolic cosine"),
452    s(127, "ATANH", C::Math, &[N], V::Number, "inverse hyperbolic tangent"),
453    s(128, "SEC", C::Math, &[N], V::Number, "secant"),
454    s(129, "CSC", C::Math, &[N], V::Number, "cosecant"),
455    s(130, "COT", C::Math, &[N], V::Number, "cotangent"),
456    s(131, "SECH", C::Math, &[N], V::Number, "hyperbolic secant"),
457    s(132, "CSCH", C::Math, &[N], V::Number, "hyperbolic cosecant"),
458    s(133, "COTH", C::Math, &[N], V::Number, "hyperbolic cotangent"),
459    s(134, "ACOT", C::Math, &[N], V::Number, "inverse cotangent"),
460    s(135, "ACOTH", C::Math, &[N], V::Number, "inverse hyperbolic cotangent"),
461    s(136, "SINPI", C::Math, &[N], V::Number, "sin(πx), exact at integers"),
462    s(137, "COSPI", C::Math, &[N], V::Number, "cos(πx), exact at integers"),
463    s(138, "TANPI", C::Math, &[N], V::Number, "tan(πx), exact at integers"),
464    s(139, "COTPI", C::Math, &[N], V::Number, "cot(πx), exact at integers"),
465    sv(140, "HYPOT", C::Math, &[N], V::Number, "√(Σ xᵢ²) without overflow"),
466    s(141, "FACT", C::Math, &[N], V::Number, "factorial"),
467    s(142, "FACTDOUBLE", C::Math, &[N], V::Number, "double factorial n!!"),
468    s(143, "COMBIN", C::Math, &[N, N], V::Number, "combinations of number items taken number_chosen at a time"),
469    s(144, "COMBINA", C::Math, &[N, N], V::Number, "combinations with repetition"),
470    s(145, "PERMUT", C::Math, &[N, N], V::Number, "permutations of number items taken number_chosen at a time"),
471    s(146, "PERMUTATIONA", C::Math, &[N, N], V::Number, "permutations with repetition"),
472    sv(147, "GCD", C::Math, &[N], V::Number, "greatest common divisor"),
473    sv(148, "LCM", C::Math, &[N], V::Number, "least common multiple"),
474    sv(149, "MULTINOMIAL", C::Math, &[N], V::Number, "(Σ n)! / Π n!"),
475    sv(150, "RAWSUBTRACT", C::Math, &[N], V::Number, "the first argument minus the rest"),
476    sv(151, "INVSUMINV", C::Math, &[N], V::Number, "1 / Σ(1/xᵢ)"),
477    // --- rounding ---
478    so(11, "ROUND", C::Rounding, &[N, N], 1, V::Number, "to num_digits decimal places, halves away from zero"),
479    so(200, "ROUNDUP", C::Rounding, &[N, N], 1, V::Number, "away from zero to num_digits places"),
480    so(201, "ROUNDDOWN", C::Rounding, &[N, N], 1, V::Number, "toward zero to num_digits places"),
481    s(202, "ROUNDSIG", C::Rounding, &[N, N], V::Number, "to digits significant digits"),
482    so(203, "TRUNC", C::Rounding, &[N, N], 1, V::Number, "truncate to num_digits places"),
483    s(204, "INT", C::Rounding, &[N], V::Number, "down to an integer"),
484    so(12, "FLOOR", C::Rounding, &[N, N], 1, V::Number, "down to a multiple of significance (default 1)"),
485    s(13, "CEIL", C::Rounding, &[N], V::Number, "up to an integer"),
486    so(205, "CEILING", C::Rounding, &[N, N], 1, V::Number, "up to a multiple of significance (default 1)"),
487    so(206, "FLOOR.MATH", C::Rounding, &[N, N, N], 1, V::Number, "down to a multiple; mode ≠ 0 rounds negatives toward zero"),
488    so(207, "CEILING.MATH", C::Rounding, &[N, N, N], 1, V::Number, "up to a multiple; mode ≠ 0 rounds negatives away from zero"),
489    so(208, "FLOOR.PRECISE", C::Rounding, &[N, N], 1, V::Number, "down to a multiple, ignoring the multiple's sign"),
490    so(209, "CEILING.PRECISE", C::Rounding, &[N, N], 1, V::Number, "up to a multiple, ignoring the multiple's sign"),
491    so(210, "ISO.CEILING", C::Rounding, &[N, N], 1, V::Number, "same as CEILING.PRECISE"),
492    s(211, "MROUND", C::Rounding, &[N, N], V::Number, "to the nearest multiple"),
493    s(212, "EVEN", C::Rounding, &[N], V::Number, "away from zero to an even integer"),
494    s(213, "ODD", C::Rounding, &[N], V::Number, "away from zero to an odd integer"),
495    // --- text ---
496    s(300, "CHAR", C::Text, &[N], V::Text, "the character with code n (1–255)"),
497    s(301, "UNICHAR", C::Text, &[N], V::Text, "the Unicode character n"),
498    s(302, "CODE", C::Text, &[T], V::Number, "the code of the first character"),
499    s(303, "UNICODE", C::Text, &[T], V::Number, "the code point of the first character"),
500    s(304, "CLEAN", C::Text, &[T], V::Text, "remove non-printing characters"),
501    sv(305, "CONCAT", C::Text, &[A], V::Text, "join values as text (same as &)"),
502    sv(306, "CONCATENATE", C::Text, &[A], V::Text, "join values as text"),
503    s(307, "EXACT", C::Text, &[T, T], V::Boolean, "case-sensitive equality"),
504    so(308, "FIND", C::Text, &[T, T, N], 2, V::Number, "1-based position of find_text in within_text, case-sensitive"),
505    so(309, "SEARCH", C::Text, &[T, T, N], 2, V::Number, "1-based position, case-insensitive, with * and ? wildcards"),
506    so(310, "LEFT", C::Text, &[T, N], 1, V::Text, "the first num_chars characters (default 1)"),
507    so(311, "RIGHT", C::Text, &[T, N], 1, V::Text, "the last num_chars characters (default 1)"),
508    s(312, "MID", C::Text, &[T, N, N], V::Text, "num_chars characters from a 1-based start_num"),
509    s(313, "LEN", C::Text, &[T], V::Number, "the number of characters"),
510    s(314, "LOWER", C::Text, &[T], V::Text, "lowercase"),
511    s(315, "UPPER", C::Text, &[T], V::Text, "uppercase"),
512    s(316, "PROPER", C::Text, &[T], V::Text, "capitalize each word"),
513    s(317, "REPLACE", C::Text, &[T, N, N, T], V::Text, "replace num_chars characters from a 1-based start_num"),
514    s(318, "REPT", C::Text, &[T, N], V::Text, "repeat text number_times times"),
515    so(319, "SUBSTITUTE", C::Text, &[T, T, T, N], 3, V::Text, "replace every (or the instance_num-th) occurrence of old_text"),
516    s(320, "TRIM", C::Text, &[T], V::Text, "remove leading, trailing and repeated spaces"),
517    s(321, "VALUE", C::Text, &[T], V::Number, "text as a number"),
518    so(322, "TEXTBEFORE", C::Text, &[T, T, N, N, N, A], 2, V::Text, "text before the instance_num-th delimiter"),
519    so(323, "TEXTAFTER", C::Text, &[T, T, N, N, N, A], 2, V::Text, "text after the instance_num-th delimiter"),
520    s(324, "TEXTBETWEEN", C::Text, &[T, T, T], V::Text, "text between the first start delimiter and the next end delimiter"),
521    s(325, "COUNTMATCHES", C::Text, &[T, T], V::Number, "how many times search occurs in text"),
522];
523
524/// The reserved id of a `WHERE` clause: an aggregation's trailing argument
525/// `Call(WHERE, [predicate])` (`SUM(x OVER T WHERE x > 0)`). Not a callable
526/// function, so not in the registry.
527pub const WHERE: FuncId = FuncId(990);
528/// The reserved id of a window: an aggregation's trailing argument
529/// `Call(WINDOW, [from, to])` (`SUM(x OVER T[FIRST..THIS])`). Each bound is a
530/// number (an offset from this item; `0` is THIS) or the text `FIRST` /
531/// `LAST`.
532pub const WINDOW: FuncId = FuncId(991);
533/// The reserved id of an `INTO Category` clause: an array function's
534/// trailing argument `Call(INTO, [Category(K)])` (`SORT(x OVER T INTO K)`).
535pub const INTO: FuncId = FuncId(992);
536
537/// The first function id of a named LAMBDA call: lambda `id` is called as
538/// `FuncId(NAMED_LAMBDA_BASE + id)`. Above every built-in.
539pub const NAMED_LAMBDA_BASE: u32 = 1_000_000;
540
541/// The function id a call of named LAMBDA `id` is stored as.
542pub fn named_lambda(id: u32) -> FuncId {
543    FuncId(NAMED_LAMBDA_BASE.saturating_add(id))
544}
545
546/// The named LAMBDA a function id calls, if it is one.
547pub fn named_lambda_of(f: FuncId) -> Option<u32> {
548    f.0.checked_sub(NAMED_LAMBDA_BASE)
549}
550
551/// The reference standing for parameter `i` in a named LAMBDA's body.
552pub fn lambda_param(i: usize) -> crate::MeasureId {
553    crate::MeasureId(LAMBDA_PARAMS - i as u32)
554}
555
556/// The highest parameter placeholder id (below the fold placeholders).
557const LAMBDA_PARAMS: u32 = LAMBDA_SLOTS - 2048;
558
559/// The parameter index of a placeholder id (see [`lambda_param`]).
560pub fn lambda_param_of(id: crate::MeasureId) -> Option<usize> {
561    let d = LAMBDA_PARAMS.checked_sub(id.0)?;
562    (d < 256).then_some(d as usize)
563}
564
565/// The category standing for parameter `i` of a named LAMBDA whose body
566/// uses the parameter as a category (`MAX(x OVER c)`, `POSITION(c)`).
567pub fn lambda_category(i: usize) -> crate::CategoryId {
568    crate::CategoryId(LAMBDA_PARAMS - i as u32)
569}
570
571/// The parameter index of a category placeholder (see [`lambda_category`]).
572pub fn lambda_category_of(c: crate::CategoryId) -> Option<usize> {
573    lambda_param_of(crate::MeasureId(c.0))
574}
575
576/// `POSITION(T)`: an item's 1-based position along its category.
577pub const POSITION: FuncId = FuncId(900);
578
579/// The placeholder measure id for parameter `i` (0 the accumulator, 1 the
580/// value) of the `k`-th `REDUCE`/`SCAN` lambda in a formula. Placeholders
581/// sit at the top of the id space, above any real measure.
582pub fn lambda_slot(k: u32, i: u32) -> crate::MeasureId {
583    crate::MeasureId(LAMBDA_SLOTS - 2 * k - i)
584}
585
586/// The highest placeholder id; ids above `LAMBDA_SLOTS - 2048` are reserved.
587pub const LAMBDA_SLOTS: u32 = u32::MAX - 4096;
588
589/// The `(k, i)` of a placeholder id (see [`lambda_slot`]).
590pub fn lambda_slot_of(id: crate::MeasureId) -> Option<(u32, u32)> {
591    let d = LAMBDA_SLOTS.checked_sub(id.0)?;
592    (d < 2048).then_some((d / 2, d % 2))
593}
594
595mod analysis;
596mod array;
597mod date;
598mod dimensional;
599mod dist_extra;
600mod engineering;
601mod financial;
602mod financial_reduce;
603mod lookup;
604mod math_extra;
605mod options;
606mod params;
607mod statistical;
608mod statistical_reduce;
609
610/// Every family table, in documentation order.
611static FAMILIES: &[&[FnDef]] = &[
612    CORE,
613    date::DATE,
614    dimensional::DIMENSIONAL,
615    array::ARRAY,
616    statistical::STATISTICAL,
617    statistical_reduce::STATISTICAL_REDUCE,
618    financial::FINANCIAL,
619    financial_reduce::FINANCIAL_REDUCE,
620    lookup::LOOKUP,
621    engineering::ENGINEERING,
622    math_extra::MATH_EXTRA,
623    dist_extra::DIST_EXTRA,
624    options::OPTIONS,
625    analysis::ANALYSIS,
626];
627
628/// Every built-in function.
629pub fn all() -> impl Iterator<Item = &'static FnDef> {
630    FAMILIES.iter().flat_map(|f| f.iter())
631}
632
633/// The function with id `id`.
634pub fn by_id(id: FuncId) -> Option<&'static FnDef> {
635    all().find(|d| d.id == id)
636}
637
638/// The functions a name (canonical or alias, case-insensitive) can mean: an
639/// aggregation and its scalar twin share a name (`SUM(x OVER T)` vs
640/// `SUM(a, b)`), so a name maps to at most one of each [`Kind`].
641pub fn by_name(name: &str) -> impl Iterator<Item = &'static FnDef> + '_ {
642    all().filter(move |d| {
643        d.name.eq_ignore_ascii_case(name) || d.aliases.iter().any(|a| a.eq_ignore_ascii_case(name))
644    })
645}
646
647impl Kind {
648    /// Whether this kind reduces over `OVER` categories.
649    pub fn reduces(self) -> bool {
650        matches!(self, Kind::Aggregate | Kind::PerItem | Kind::Array)
651    }
652}
653
654/// `x, y OVER R, C, [z] INTO K` as its arguments: the categories listed
655/// after an OVER or INTO (capitalized) stay with their argument.
656fn split_params(list: &str) -> Vec<String> {
657    let mut out: Vec<String> = Vec::new();
658    for p in list.split(", ") {
659        match out.last_mut() {
660            Some(last)
661                if (last.contains(" OVER ") || last.contains(" INTO "))
662                    && p.starts_with(|c: char| c.is_ascii_uppercase()) =>
663            {
664                last.push_str(", ");
665                last.push_str(p);
666            }
667            _ => out.push(p.to_string()),
668        }
669    }
670    out
671}
672
673/// The parameter list a description spells as `NAME(a, b, [c])`, if it
674/// does (and the names look like names, not an example call).
675fn spelled_params(name: &str, doc: &str) -> Option<Vec<String>> {
676    let at = doc.find(&format!("{name}("))? + name.len() + 1;
677    let mut depth = 0usize;
678    let mut end = None;
679    for (i, c) in doc[at..].char_indices() {
680        match c {
681            '(' => depth += 1,
682            ')' if depth == 0 => {
683                end = Some(at + i);
684                break;
685            }
686            ')' => depth -= 1,
687            _ => {}
688        }
689    }
690    let inner = &doc[at..end?];
691    let parts: Vec<String> = split_params(inner)
692        .iter()
693        .map(|p| p.trim().to_string())
694        .collect();
695    let word = |p: &str| {
696        // The name before any OVER / INTO categories.
697        let p = p.split(" OVER ").next().unwrap_or(p);
698        let p = p.split(" INTO ").next().unwrap_or(p);
699        let p = p.trim_start_matches('[').trim_end_matches(']');
700        !p.is_empty()
701            && p.chars()
702                .all(|c| c.is_alphanumeric() || "_ …=.-".contains(c))
703            && !p.starts_with(|c: char| c.is_ascii_digit() || c == '-')
704    };
705    (!inner.is_empty() && parts.iter().all(|p| word(p))).then_some(parts)
706}
707
708/// Functions matching `query`, best first: an exact name or alias, then a
709/// prefix, then a word of the name or description. Case-insensitive; an
710/// empty query lists everything by name. The help browser's search and
711/// the formula editor's completion both use this.
712pub fn search(query: &str) -> Vec<&'static FnDef> {
713    let q = query.trim().to_ascii_uppercase();
714    let mut hits: Vec<(u8, &'static FnDef)> = all()
715        .filter_map(|d| {
716            let names = std::iter::once(d.name).chain(d.aliases.iter().copied());
717            let rank = if q.is_empty() {
718                Some(3)
719            } else if names.clone().any(|n| n == q) {
720                Some(0)
721            } else if names.clone().any(|n| n.starts_with(&q)) {
722                Some(1)
723            } else if names.clone().any(|n| n.contains(&q)) {
724                Some(2)
725            } else if d.doc.to_ascii_uppercase().contains(&q) {
726                Some(4)
727            } else {
728                None
729            };
730            rank.map(|r| (r, d))
731        })
732        .collect();
733    hits.sort_by(|a, b| {
734        a.0.cmp(&b.0)
735            .then(a.1.name.cmp(b.1.name))
736            .then(a.1.id.cmp(&b.1.id))
737    });
738    // One row per name: an aggregation and its scalar twin share a name.
739    let mut seen = std::collections::HashSet::new();
740    hits.into_iter()
741        .map(|(_, d)| d)
742        .filter(|d| seen.insert(d.name))
743        .collect()
744}
745
746/// Up to `n` function names closest to `word` by edit distance (a typo's
747/// likely meaning), nearest first; only those within a third of its
748/// length, at least 2.
749pub fn closest(word: &str, n: usize) -> Vec<&'static str> {
750    let w = word.to_ascii_uppercase();
751    let limit = (w.chars().count() / 3).max(2);
752    let mut scored: Vec<(usize, &'static str)> = all()
753        .flat_map(|d| std::iter::once(d.name).chain(d.aliases.iter().copied()))
754        .map(|name| (edit_distance(&w, name), name))
755        .filter(|(dist, _)| *dist <= limit)
756        .collect();
757    scored.sort();
758    scored.dedup_by(|a, b| a.1 == b.1);
759    scored.into_iter().take(n).map(|(_, name)| name).collect()
760}
761
762/// Levenshtein distance over chars.
763pub fn edit_distance(a: &str, b: &str) -> usize {
764    let b: Vec<char> = b.chars().collect();
765    let mut row: Vec<usize> = (0..=b.len()).collect();
766    for (i, ca) in a.chars().enumerate() {
767        let mut prev = row[0];
768        row[0] = i + 1;
769        for (j, cb) in b.iter().enumerate() {
770            let cur = row[j + 1];
771            row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
772            prev = cur;
773        }
774    }
775    row[b.len()]
776}
777
778/// Function names (canonical and aliases) starting with `prefix`
779/// (case-insensitive), canonical first, for completion.
780pub fn complete(prefix: &str) -> Vec<(&'static str, &'static FnDef)> {
781    let p = prefix.to_ascii_uppercase();
782    if p.is_empty() {
783        return Vec::new();
784    }
785    let mut out: Vec<(&'static str, &'static FnDef)> = Vec::new();
786    for d in all() {
787        for n in std::iter::once(d.name).chain(d.aliases.iter().copied()) {
788            if n.starts_with(&p) && !out.iter().any(|(m, _)| *m == n) {
789                out.push((n, d));
790            }
791        }
792    }
793    out.sort_by(|a, b| a.0.len().cmp(&b.0.len()).then(a.0.cmp(b.0)));
794    out
795}
796
797/// Where the caret sits in a call: the innermost unclosed `NAME(` before
798/// byte offset `caret` in `text`, and the 0-based argument it is in.
799/// Strings and nested parentheses are skipped. Signature help uses it.
800pub fn call_at(text: &str, caret: usize) -> Option<(&'static FnDef, usize)> {
801    let before = text.get(..caret)?;
802    // (name end, argument index) per open parenthesis.
803    let mut stack: Vec<(usize, usize)> = Vec::new();
804    let mut in_str = false;
805    for (i, c) in before.char_indices() {
806        match c {
807            '"' => in_str = !in_str,
808            _ if in_str => {}
809            '(' => stack.push((i, 0)),
810            ')' => {
811                stack.pop();
812            }
813            ',' | ';' => {
814                if let Some(top) = stack.last_mut() {
815                    top.1 += 1;
816                }
817            }
818            _ => {}
819        }
820    }
821    let (open, arg) = *stack.last()?;
822    let head = before[..open].trim_end();
823    let start = head
824        .rfind(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.' || c == '@'))
825        .map_or(0, |i| {
826            i + head[i..].chars().next().map_or(1, char::len_utf8)
827        });
828    let name = &head[start..];
829    let def = by_name(name).next()?;
830    Some((def, arg))
831}
832
833/// The aggregation (or per-item reduction) a name means, if any.
834pub fn aggregation(name: &str) -> Option<&'static FnDef> {
835    by_name(name).find(|d| d.kind.reduces())
836}
837
838/// The non-aggregating function a name means, if any.
839pub fn callable(name: &str) -> Option<&'static FnDef> {
840    by_name(name).find(|d| !d.kind.reduces())
841}
842
843/// Whether `id` reduces over `OVER` categories.
844pub fn is_aggregation(id: FuncId) -> bool {
845    by_id(id).is_some_and(|d| d.kind.reduces())
846}
847
848#[cfg(test)]
849#[cfg_attr(coverage_nightly, coverage(off))]
850mod tests {
851    /// Search ranks exact names, then prefixes, then substrings, then the
852    /// descriptions; completion lists prefixes, shortest first; call_at
853    /// finds the innermost open call and the argument the caret is in.
854    #[test]
855    fn search_complete_and_call_at() {
856        let names = |q: &str| search(q).iter().map(|d| d.name).take(3).collect::<Vec<_>>();
857        assert_eq!(names("pmt")[0], "PMT");
858        assert_eq!(names("ROUNDU"), ["ROUNDUP"]);
859        assert!(
860            search("payment").iter().any(|d| d.name == "PMT"),
861            "found by description"
862        );
863        assert_eq!(
864            search("").len(),
865            search("")
866                .iter()
867                .map(|d| d.name)
868                .collect::<HashSet<_>>()
869                .len()
870        );
871        assert!(search("zzzz-not-a-function").is_empty());
872        assert!(search("DSUM").iter().any(|d| d.name == "SUM"), "by alias");
873        let c: Vec<&str> = complete("roun").iter().map(|(n, _)| *n).collect();
874        assert_eq!(c[0], "ROUND");
875        assert!(c.contains(&"ROUNDUP"));
876        assert!(complete("").is_empty());
877        assert!(complete("DSU")
878            .iter()
879            .any(|(n, d)| *n == "DSUM" && d.name == "SUM"));
880        let at = |t: &str| call_at(t, t.len()).map(|(d, i)| (d.name, i));
881        assert_eq!(at("PMT("), Some(("PMT", 0)));
882        assert_eq!(at("PMT(0.05, 12, "), Some(("PMT", 2)));
883        assert_eq!(at("ROUND(PMT(0.05, 12, 100), "), Some(("ROUND", 1)));
884        assert_eq!(
885            at("ROUND(LEFT(\"a,b\", "),
886            Some(("LEFT", 1)),
887            "commas in strings skipped"
888        );
889        assert_eq!(
890            at("NORM.S.DIST(1; "),
891            Some(("NORM.S.DIST", 1)),
892            "dotted name, ; separator"
893        );
894        assert_eq!(at("PMT(1)"), None, "closed");
895        assert_eq!(at("(1 + "), None, "not a call");
896        assert_eq!(at("Nope("), None);
897        assert!(call_at("PMT(", 99).is_none());
898        // Hints: spelled in the description, from the table, or the types.
899        let hint = |n: &str| by_name(n).next().unwrap().hint();
900        assert_eq!(hint("PMT"), "PMT(rate, nper, pv, [fv], [type])");
901        assert_eq!(
902            hint("IF"),
903            "IF(logical_test, value_if_true, [value_if_false])"
904        );
905        assert_eq!(callable("SUM").unwrap().hint(), "SUM(number1, …)");
906        assert_eq!(aggregation("SUM").unwrap().hint(), "SUM(values OVER T)");
907        assert_eq!(
908            hint("MINVERSE"),
909            "MINVERSE(A OVER R, C INTO R2, C2)",
910            "categories stay with their argument"
911        );
912        assert_eq!(
913            spelled_params("X", "X(a, b OVER T, [c] INTO K)").map(|v| v.len()),
914            Some(3)
915        );
916        assert_eq!(hint("PI"), "PI()");
917        assert_eq!(closest("PMTT", 3)[0], "PMT");
918        assert_eq!(closest("xlookp", 1), ["XLOOKUP"]);
919        assert!(closest("qqqqqqqqqqqq", 3).is_empty());
920        assert_eq!(edit_distance("kitten", "sitting"), 3);
921        assert_eq!(edit_distance("", "ab"), 2);
922        assert!(
923            spelled_params("X", "X(1, 2)").is_none(),
924            "an example call is not names"
925        );
926        assert!(spelled_params("X", "no call here").is_none());
927        assert!(spelled_params("X", "X(a, (b)").is_none(), "unclosed");
928    }
929
930    /// Dump every hint (run with `--ignored --nocapture` to review them):
931    /// `T` where the names fall back to the types, else `N`.
932    #[test]
933    #[ignore]
934    fn dump_hints() {
935        for d in super::all() {
936            let generic = !d.params.is_empty() && d.named_params().is_none();
937            println!("{} {}", if generic { "T" } else { "N" }, d.hint());
938        }
939    }
940
941    /// Every function with parameters names them (in the table or its
942    /// description), one name per parameter: optional ones bracketed, a
943    /// variadic tail marked by one `…` item (`value1, …`, `x1, … OVER T`).
944    #[test]
945    fn every_function_names_its_parameters() {
946        for d in super::all() {
947            let names = d.param_names();
948            assert!(
949                d.params.is_empty() || d.named_params().is_some(),
950                "{} falls back to its types: add it to functions/params.rs",
951                d.hint()
952            );
953            let tail = names.iter().filter(|p| p.starts_with('…')).count();
954            assert_eq!(
955                tail,
956                usize::from(d.variadic),
957                "{}: one … iff variadic",
958                d.hint()
959            );
960            // SWITCH's [default] follows its repeated value/result pairs.
961            let extra = usize::from(d.name == "SWITCH");
962            assert_eq!(
963                names.len() - tail,
964                d.params.len() + extra,
965                "{}: one name per parameter",
966                d.hint()
967            );
968            for (i, p) in names.iter().filter(|p| !p.starts_with('…')).enumerate() {
969                assert_eq!(p.starts_with('['), i >= d.required, "{}: {p}", d.hint());
970            }
971            if d.kind.reduces() {
972                assert!(
973                    names.iter().any(|p| p.contains(" OVER ")),
974                    "{}: which argument is reduced",
975                    d.hint()
976                );
977            }
978            assert_eq!(
979                names.last().is_some_and(|p| p.contains(" INTO ")),
980                d.kind == Kind::Array,
981                "{}: a list result ends in INTO",
982                d.hint()
983            );
984        }
985    }
986
987    use super::*;
988    use std::collections::{HashMap, HashSet};
989
990    #[test]
991    fn ids_are_unique() {
992        let mut seen = HashSet::new();
993        for d in all() {
994            assert!(seen.insert(d.id), "{} reuses id {:?}", d.name, d.id);
995        }
996    }
997
998    /// Each spelling names at most one aggregation and one other function.
999    #[test]
1000    fn names_are_unambiguous() {
1001        let mut seen: HashMap<(String, bool), &str> = HashMap::new();
1002        for d in all() {
1003            for n in std::iter::once(&d.name).chain(d.aliases) {
1004                let k = (n.to_ascii_uppercase(), d.kind.reduces());
1005                if let Some(prev) = seen.insert(k, d.name) {
1006                    panic!("{n} names both {prev} and {}", d.name);
1007                }
1008            }
1009        }
1010    }
1011
1012    /// Persisted ids keep their meaning (stored formulas depend on these).
1013    #[test]
1014    fn frozen_ids() {
1015        for (id, name) in [
1016            (1, "SUM"),
1017            (2, "AVERAGE"),
1018            (3, "MIN"),
1019            (4, "MAX"),
1020            (10, "ABS"),
1021            (11, "ROUND"),
1022            (12, "FLOOR"),
1023            (13, "CEIL"),
1024            (14, "SQRT"),
1025            (15, "NEG"),
1026            (20, "MIN"),
1027            (21, "MAX"),
1028        ] {
1029            assert_eq!(by_id(FuncId(id)).map(|d| d.name), Some(name), "{id}");
1030        }
1031    }
1032
1033    #[test]
1034    fn lookups() {
1035        assert_eq!(aggregation("avg").map(|d| d.id), Some(FuncId(2)));
1036        assert_eq!(callable("sum").map(|d| d.id), Some(FuncId(22)));
1037        assert_eq!(callable("min2").map(|d| d.id), Some(FuncId(20)));
1038        assert_eq!(callable("Floor.Math").map(|d| d.id), Some(FuncId(206)));
1039        assert!(callable("nope").is_none());
1040        assert!(is_aggregation(FuncId(7)) && !is_aggregation(FuncId(26)));
1041        let round = by_id(FuncId(11)).unwrap();
1042        assert!(round.accepts(1) && round.accepts(2) && !round.accepts(3) && !round.accepts(0));
1043        let sum = callable("SUM").unwrap();
1044        assert!(sum.accepts(5) && !sum.accepts(0));
1045        assert_eq!(sum.param(4), Arg::Number);
1046        assert_eq!(round.signature(), "ROUND(number, [number])");
1047        assert_eq!(sum.signature(), "SUM(number, …)");
1048        assert_eq!(by_id(FuncId(39)).unwrap().param(0), Arg::Any);
1049    }
1050}
1051
1052/// The function reference page (`docs/src/functions.md`), generated from
1053/// [`all`] so the docs cannot drift from the code. A test compares it
1054/// with the committed file; `UPDATE_DOCS=1 cargo test -p improv_core_model`
1055/// rewrites it.
1056pub fn reference_markdown() -> String {
1057    let mut out = String::from(
1058        "# Function Reference\n\n\
1059         <!-- Generated from crates/core_model/src/functions.rs by\n     \
1060         `UPDATE_DOCS=1 cargo test -p improv_core_model`. Do not edit. -->\n\n\
1061         Every built-in function, by category. Names are case-insensitive;\n\
1062         `[x]` is optional (`[x=1]`: 1 if omitted), `…` repeats the\n\
1063         arguments before it, `x OVER T` is reduced over a category and\n\
1064         `INTO K` lays a list result along one. Positions are\n\
1065         1-based. An error argument (`#DIV/0!`, `#N/A`, …) makes the result\n\
1066         that error, except in the functions that test for errors. See\n\
1067         [The Formula Language](./formulas.md) for operators, types and\n\
1068         dimensions.\n",
1069    );
1070    let cats = [
1071        (Category::Aggregation, "Aggregations", "Written `F(Measure OVER Category)`: collapse one category. Each also has a scalar form over its arguments, listed under its category."),
1072        (Category::Logical, "Logical", "`IF` and the functions built on it evaluate only the branch they choose, so an error or missing cell in the other branch does not matter."),
1073        (Category::Information, "Information and errors", ""),
1074        (Category::Math, "Math and trigonometry", "Angles are in radians."),
1075        (Category::Rounding, "Rounding", "Rounding is decimal: `ROUND(2.675, 2)` is 2.68."),
1076        (Category::Text, "Text", "Lengths and positions count characters, not bytes."),
1077        (Category::DateTime, "Date and time", "Dates are date-time values (UTC, millisecond precision). A number given where a date is wanted is a spreadsheet serial date (1 = 1900-01-01)."),
1078        (Category::Dimensional, "Dimensional", "Functions of a cell's position in a category, and the ordered (previous/next, running) forms."),
1079        (Category::Statistical, "Statistical", ""),
1080        (Category::Financial, "Financial", "Rates are per period; cash flows are in item order along their `OVER` category."),
1081        (Category::Engineering, "Engineering", "Complex numbers are text, `\"3+4i\"` (suffix `i` or `j`); base conversions use 10-digit two's complement for negatives."),
1082    ];
1083    for (cat, title, intro) in cats {
1084        if all().all(|d| d.category != cat) {
1085            continue;
1086        }
1087        out.push_str(&format!("\n## {title}\n\n"));
1088        if !intro.is_empty() {
1089            out.push_str(intro);
1090            out.push_str("\n\n");
1091        }
1092        out.push_str("| Function | Returns | Description |\n|---|---|---|\n");
1093        for d in all().filter(|d| d.category == cat) {
1094            let sig = d.hint();
1095            let ret = match d.ret {
1096                Ret::Fixed(ValueType::Number) => "number",
1097                Ret::Fixed(ValueType::Boolean) => "boolean",
1098                Ret::Fixed(ValueType::Text) => "text",
1099                Ret::Fixed(ValueType::DateTime) => "date",
1100                Ret::Fixed(_) => "value",
1101                Ret::Unify => "the branches' type",
1102                Ret::Arg(_) => "the values' type",
1103            };
1104            // `[x]` in prose reads as a Markdown link: escape the brackets
1105            // (inside the code spans they are literal already).
1106            let mut doc = d.doc.replace('[', "\\[").replace(']', "\\]");
1107            if !d.aliases.is_empty() {
1108                doc.push_str(&format!(" (also `{}`)", d.aliases.join("`, `")));
1109            }
1110            out.push_str(&format!("| `{sig}` | {ret} | {doc} |\n"));
1111        }
1112    }
1113    out
1114}
1115
1116#[cfg(test)]
1117#[cfg_attr(coverage_nightly, coverage(off))]
1118mod docs_test {
1119    #[test]
1120    fn reference_page_is_current() {
1121        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/functions.md");
1122        let want = super::reference_markdown();
1123        if std::env::var_os("UPDATE_DOCS").is_some() {
1124            std::fs::write(path, &want).unwrap();
1125        }
1126        // A Windows checkout may have turned LF into CRLF.
1127        let have = std::fs::read_to_string(path)
1128            .unwrap_or_default()
1129            .replace("\r\n", "\n");
1130        assert!(
1131            have == want,
1132            "docs/src/functions.md is stale: run UPDATE_DOCS=1 cargo test -p improv_core_model"
1133        );
1134    }
1135}