Skip to main content

ravenrs_sheet/engine/
recent.rs

1//! Formulas › Recently Used: the functions most recently entered in a formula.
2//!
3//! The list is [`crate::engine::Prefs::recent_functions`], an application list like Excel's, saved with
4//! the other preferences. [`crate::engine::Session::execute`] calls [`note`] for each `cell.*` and
5//! `range.*` command it journals, from any source (the interface, the control channel, MCP or a
6//! script), so the list never depends on the journal, which drops its oldest entries.
7
8use std::collections::HashSet;
9use std::sync::OnceLock;
10
11use crate::engine::cmd::edit::is_formula_input;
12use crate::formula::lexer::{Tok, tokenize};
13use crate::formula::normalize_function_name;
14use serde::Deserialize;
15use serde_json::Value as Json;
16
17/// How many functions the list keeps.
18pub const MAX: usize = 10;
19/// The longest formula read (Excel's formula length limit): a longer one was not typed.
20const FORMULA_MAX: usize = 8_192;
21/// The most formulas read from one command: a large paste needs only its last few.
22const SCAN_BUDGET: usize = 2_000;
23
24/// What the list holds before any function has been used.
25pub fn defaults() -> Vec<String> {
26    ["SUM", "AVERAGE", "IF", "COUNT", "MAX", "MIN", "SUMIF", "XLOOKUP", "PMT", "TODAY"].map(String::from).to_vec()
27}
28
29/// Whether `name` (upper case, without `_xlfn.`) is a function of the catalog.
30fn known(name: &str) -> bool {
31    static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
32    NAMES
33        .get_or_init(|| {
34            crate::engine::cmd::formulas::function_list().iter().filter_map(|f| f["name"].as_str().map(str::to_ascii_uppercase)).collect()
35        })
36        .contains(name)
37}
38
39/// The functions a formula calls, outermost first, known functions only. A formula is what a
40/// cell takes as one (`=…`, or `+…`/`-…` as Excel takes `+SUM(1)`).
41pub fn functions_in_formula(text: &str) -> Vec<String> {
42    if !is_formula_input(text) {
43        return vec![];
44    }
45    let body = text.strip_prefix('=').unwrap_or(text);
46    if body.len() > FORMULA_MAX {
47        return vec![];
48    }
49    let mut out: Vec<String> = Vec::new();
50    for t in tokenize(body).unwrap_or_default() {
51        if let Tok::Func(name) = t.tok {
52            let name = normalize_function_name(&name);
53            if known(&name) && !out.contains(&name) {
54                out.push(name);
55            }
56        }
57    }
58    out
59}
60
61/// The formula texts a command's params enter, the last first, at most `budget` of them.
62fn formulas_newest_first<'a>(id: &str, params: &'a Json, budget: usize) -> Vec<&'a str> {
63    fn walk<'a>(v: &'a Json, out: &mut Vec<&'a str>, depth: usize, budget: usize) {
64        match v {
65            Json::String(s) if is_formula_input(s) && out.len() < budget => out.push(s),
66            // A range's values are rows of cells; anything deeper is not a cell value.
67            Json::Array(a) if depth < 2 => {
68                for x in a.iter().rev() {
69                    if out.len() >= budget {
70                        return;
71                    }
72                    walk(x, out, depth + 1, budget);
73                }
74            }
75            _ => {}
76        }
77    }
78    let mut out = Vec::new();
79    if id.starts_with("cell.") || id.starts_with("range.") {
80        for key in ["formula", "values", "value", "input"] {
81            if let Some(v) = params.get(key) {
82                walk(v, &mut out, 0, budget);
83            }
84        }
85    }
86    out
87}
88
89/// Moves `names` (most recent first) to the front of `recent`, keeping at most [`MAX`].
90pub fn push(recent: &mut Vec<String>, names: &[String]) {
91    let mut next: Vec<String> = Vec::with_capacity(MAX);
92    for n in names.iter().chain(recent.iter()) {
93        if next.len() == MAX {
94            break;
95        }
96        if !next.contains(n) {
97            next.push(n.clone());
98        }
99    }
100    *recent = next;
101}
102
103/// Moves the functions that the formulas a command entered call to the front of `recent`. The
104/// last formulas are read first (they are the most recent), within [`SCAN_BUDGET`].
105pub(crate) fn note(recent: &mut Vec<String>, id: &str, params: &Json) {
106    let mut names: Vec<String> = Vec::new();
107    for f in formulas_newest_first(id, params, SCAN_BUDGET) {
108        for n in functions_in_formula(f) {
109            if !names.contains(&n) {
110                names.push(n);
111            }
112        }
113        if names.len() >= MAX {
114            break;
115        }
116    }
117    if !names.is_empty() {
118        push(recent, &names);
119    }
120}
121
122/// Reads a saved list leniently: known function names only, at most [`MAX`]. A value that is not
123/// a list gives the default list, so a hand-edited or hostile `prefs.json` loses this list, never
124/// the other preferences.
125pub(crate) fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
126    let raw = Json::deserialize(d)?;
127    let Some(list) = raw.as_array() else { return Ok(defaults()) };
128    let mut out = Vec::new();
129    for name in list.iter().filter_map(Json::as_str) {
130        if out.len() == MAX {
131            break;
132        }
133        let name = name.to_ascii_uppercase();
134        if known(&name) && !out.contains(&name) {
135            out.push(name);
136        }
137    }
138    Ok(out)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use serde_json::json;
145
146    #[test]
147    fn formulas_name_their_functions_outermost_first() {
148        assert_eq!(functions_in_formula("=IF(SUM(A1:A3)>2,_xlfn.XLOOKUP(1,B:B,C:C),sum(1))"), ["IF", "SUM", "XLOOKUP"]);
149        assert!(functions_in_formula("=MYLAMBDA(1)+A1").is_empty(), "only known functions");
150        assert!(functions_in_formula("SUM(1)").is_empty(), "not a formula");
151        assert!(functions_in_formula("=\"SUM(\"").is_empty(), "inside a string");
152        assert!(functions_in_formula(&format!("=SUM({})", "1+".repeat(9000))).is_empty(), "too long");
153        assert!(functions_in_formula("=((((").is_empty());
154        assert!(functions_in_formula("=").is_empty());
155        // A formula may be typed with + or - instead of =, as input_to_cell takes it.
156        assert_eq!(functions_in_formula("+SUM(1)"), ["SUM"]);
157        assert_eq!(functions_in_formula("-ABS(A1)"), ["ABS"]);
158        assert!(functions_in_formula("-5").is_empty() && functions_in_formula("+1e3").is_empty(), "numbers");
159        assert!(functions_in_formula("+").is_empty() && functions_in_formula("- SUM(1)").is_empty(), "not formulas");
160    }
161
162    #[test]
163    fn commands_give_their_formulas_newest_first() {
164        let read = |id: &str, params: Json| formulas_newest_first(id, &params, SCAN_BUDGET).into_iter().map(String::from).collect::<Vec<_>>();
165        assert_eq!(read("range.setValues", json!({"values": [["=SUM(1)", 2], ["x", "=PMT(1,2,3)"]]})), ["=PMT(1,2,3)", "=SUM(1)"]);
166        assert_eq!(read("cell.set", json!({"input": "=NPV(1,2)"})), ["=NPV(1,2)"]);
167        assert_eq!(read("range.setValues", json!({"values": [["+SUM(1)", "-5", "-ABS(2)"]]})), ["-ABS(2)", "+SUM(1)"]);
168        assert!(read("home.bold", json!({"input": "=IRR(1)"})).is_empty(), "only cell.* and range.* enter formulas");
169        assert!(read("range.setValues", json!({"values": [[["=FV(1,2,3)"]]]})).is_empty(), "deeper than rows of cells");
170        // A large paste is read from its end, within the budget.
171        let rows: Vec<Json> = (0..5_000).map(|i| json!([format!("=ABS({i})")])).collect();
172        let paste = json!({ "values": rows });
173        let last = formulas_newest_first("range.setValues", &paste, 3);
174        assert_eq!(last, ["=ABS(4999)", "=ABS(4998)", "=ABS(4997)"]);
175    }
176
177    #[test]
178    fn recent_keeps_ten_distinct_newest_first() {
179        let mut r = defaults();
180        assert!(r.iter().all(|n| known(n)), "the default list names known functions");
181        push(&mut r, &["PMT".into(), "NPV".into()]);
182        assert_eq!(r.len(), MAX);
183        assert_eq!(&r[..3], ["PMT", "NPV", "SUM"]);
184        assert_eq!(r.iter().filter(|n| *n == "PMT").count(), 1);
185        let many: Vec<String> =
186            ["ABS", "ACOS", "AND", "CHOOSE", "DATE", "EXP", "FIND", "INDEX", "LEFT", "LN", "MOD", "NOW"].map(String::from).to_vec();
187        push(&mut r, &many);
188        assert_eq!(r, many[..MAX]);
189    }
190
191    #[test]
192    fn a_saved_list_is_read_leniently() {
193        let prefs = |v: Json| serde_json::from_value::<crate::engine::Prefs>(v).unwrap();
194        assert_eq!(prefs(json!({"r1c1": true})).recent_functions, defaults(), "a prefs.json from before the list gets the default");
195        // A hostile value loses only itself, never the other preferences.
196        let bad = prefs(json!({"r1c1": true, "recentFunctions": 5}));
197        assert!(bad.r1c1);
198        assert_eq!(bad.recent_functions, defaults());
199        let mut odd: Vec<Json> = ["npv", "NOT_A_FUNCTION", "SUM", "NPV"].iter().map(|n| json!(n)).chain([json!(3), json!(null)]).collect();
200        odd.extend(crate::engine::cmd::formulas::function_list().into_iter().take(40).map(|f| f["name"].clone()));
201        let odd = prefs(json!({ "recentFunctions": odd }));
202        assert_eq!(&odd.recent_functions[..2], ["NPV", "SUM"]);
203        assert_eq!(odd.recent_functions.len(), MAX);
204        let back = prefs(serde_json::to_value(&odd).unwrap());
205        assert_eq!(back.recent_functions, odd.recent_functions, "the list survives a save and reload");
206    }
207}