Skip to main content

improv_gui/
completion.rs

1//! Formula autocomplete, window-free: the word being typed before the
2//! caret, what may complete it (functions, measures, categories), the ghost
3//! text painted after the caret, and the edit that accepting a candidate
4//! makes. The formula editors draw the list, the ghost and the signature
5//! tooltip from these.
6
7use improv_core_model::functions::{self, FnDef};
8use improv_core_model::Model;
9
10/// At most this many candidates are listed.
11pub const MAX_ITEMS: usize = 8;
12
13/// What a candidate names.
14#[derive(Debug, Clone, Copy)]
15pub enum Kind {
16    Function(&'static FnDef),
17    Measure,
18    Category,
19}
20
21/// One completion: the name shown, and what accepting it inserts.
22#[derive(Debug, Clone)]
23pub struct Candidate {
24    /// `PMT`, or the model's own spelling (`Unit Cost`).
25    pub name: String,
26    /// The source text: `PMT(`, `Price`, `'Unit Cost'`.
27    pub insert: String,
28    pub kind: Kind,
29}
30
31impl Candidate {
32    /// A function by `name` (its canonical name or an alias).
33    pub fn function(name: &str, def: &'static FnDef) -> Self {
34        Candidate {
35            name: name.to_string(),
36            insert: format!("{name}("),
37            kind: Kind::Function(def),
38        }
39    }
40
41    /// `PMT(rate, nper, pv, [fv], [type])`, or a model name as it is.
42    pub fn hint(&self) -> String {
43        match self.kind {
44            Kind::Function(d) => call_hint(&self.name, d),
45            _ => self.name.clone(),
46        }
47    }
48
49    /// The one-line description, or what a model name names.
50    pub fn doc(&self) -> &'static str {
51        match self.kind {
52            Kind::Function(d) => short_doc(d),
53            Kind::Measure => "measure",
54            Kind::Category => "category",
55        }
56    }
57}
58
59/// `d`'s description without a `NAME(…)` call that spells its parameters
60/// (`payment per period: PMT(rate, nper, …)`), which a hint already shows.
61pub fn short_doc(d: &FnDef) -> &'static str {
62    let doc = d.doc;
63    let call = format!("{}(", d.name);
64    match doc.find(&call) {
65        Some(at) if doc[at..].ends_with(')') => doc[..at].trim_end_matches([' ', ':', ',']),
66        _ => doc,
67    }
68}
69
70/// `NAME(params)` under the name the user typed (an alias keeps its own).
71fn call_hint(name: &str, d: &FnDef) -> String {
72    format!("{name}({})", d.param_names().join(", "))
73}
74
75fn ident_char(c: char) -> bool {
76    c.is_alphanumeric() || c == '_' || c == '.'
77}
78
79/// Whether byte `i` of `text` is inside a `"string"`.
80fn in_string(text: &str, i: usize) -> bool {
81    text[..i].matches('"').count() % 2 == 1
82}
83
84/// Byte offset where the word ending at `caret` starts: the identifier
85/// run before it, or an open `'quoted name` (spaces and all).
86pub fn word_start(text: &str, caret: usize) -> usize {
87    let before = &text[..caret];
88    if before.matches('\'').count() % 2 == 1 {
89        if let Some(q) = before.rfind('\'') {
90            return q;
91        }
92    }
93    before
94        .char_indices()
95        .rev()
96        .take_while(|&(_, c)| ident_char(c))
97        .last()
98        .map_or(caret, |(i, _)| i)
99}
100
101/// The word being typed before `caret`, if it can be completed: not
102/// empty, starting with a letter, `_` or `'`, and not in a string.
103pub fn prefix(text: &str, caret: usize) -> Option<(usize, &str)> {
104    if !text.is_char_boundary(caret) {
105        return None;
106    }
107    let start = word_start(text, caret);
108    let word = &text[start..caret];
109    let first = word.chars().next()?;
110    let name = first.is_alphabetic() || first == '_' || first == '\'';
111    (name && !in_string(text, start)).then_some((start, word))
112}
113
114/// What may complete `word`, case-insensitively: the model's measures,
115/// then its categories (each sorted), then the functions, shortest first.
116/// A `'quoted` word completes model names only.
117pub fn candidates(model: &Model, word: &str) -> Vec<Candidate> {
118    let quoted = word.strip_prefix('\'');
119    let p = quoted.unwrap_or(word).to_lowercase();
120    if p.is_empty() && quoted.is_none() {
121        return Vec::new();
122    }
123    let named = |names: Vec<&str>, kind: Kind| {
124        let mut names: Vec<&str> = names
125            .into_iter()
126            .filter(|n| n.to_lowercase().starts_with(&p))
127            .collect();
128        names.sort_unstable();
129        names.dedup();
130        names
131            .into_iter()
132            .filter_map(move |n| {
133                Some(Candidate {
134                    name: n.to_string(),
135                    insert: crate::app::ident(n)?,
136                    kind,
137                })
138            })
139            .collect::<Vec<_>>()
140    };
141    let mut out = named(
142        model.measures.values().map(|m| m.name.0.as_str()).collect(),
143        Kind::Measure,
144    );
145    out.extend(named(
146        model
147            .categories
148            .values()
149            .map(|c| c.name.0.as_str())
150            .collect(),
151        Kind::Category,
152    ));
153    if quoted.is_none() {
154        out.extend(
155            functions::complete(word)
156                .into_iter()
157                .map(|(n, d)| Candidate::function(n, d)),
158        );
159    }
160    out
161}
162
163/// The list to offer while typing at `caret`: [`candidates`] for the word
164/// there, at most [`MAX_ITEMS`]. Empty once the word is exactly a measure
165/// or category name, so Enter after `Price * Quantity` commits, and when the
166/// caret is inside a word or right before a `(` (the call is written).
167pub fn offer(model: &Model, text: &str, caret: usize) -> Vec<Candidate> {
168    let Some((_, word)) = prefix(text, caret) else {
169        return Vec::new();
170    };
171    if text[caret..]
172        .chars()
173        .next()
174        .is_some_and(|c| c == '(' || ident_char(c))
175    {
176        return Vec::new();
177    }
178    let mut c = candidates(model, word);
179    let whole = c
180        .iter()
181        .any(|c| !matches!(c.kind, Kind::Function(_)) && c.insert == word);
182    if whole {
183        return Vec::new();
184    }
185    c.truncate(MAX_ITEMS);
186    c
187}
188
189/// The ghost text after the caret, which must end `text`. While
190/// completing (`cand` is the selected candidate for the word there) it is
191/// the rest of that name, and a function's `(params)`; else, inside a call,
192/// the parameters from the current argument on: after `PMT(0.05, ` it is
193/// `nper, pv, [fv], [type])`, after `PMT(0.05` it is `, nper, …)`.
194pub fn ghost(text: &str, caret: usize, cand: Option<&Candidate>) -> Option<String> {
195    if caret != text.len() {
196        return None;
197    }
198    if let (Some(c), Some((_, word))) = (cand, prefix(text, caret)) {
199        let full = match c.kind {
200            Kind::Function(d) => call_hint(&c.name, d),
201            _ => c.insert.clone(),
202        };
203        let head = full.get(..word.len())?;
204        return head
205            .eq_ignore_ascii_case(word)
206            .then(|| full[word.len()..].to_string())
207            .filter(|g| !g.is_empty());
208    }
209    let (def, arg) = functions::call_at(text, caret)?;
210    let names = def.param_names();
211    let typing = !text.trim_end().ends_with(['(', ',', ';']);
212    let start = clamp_variadic(def, &names, arg + usize::from(typing));
213    let rest = names.get(start..).unwrap_or_default();
214    let mut out = rest.join(", ");
215    if typing && !rest.is_empty() {
216        out.insert_str(0, ", ");
217    } else if text.ends_with([',', ';']) {
218        out.insert(0, ' ');
219    }
220    out.push(')');
221    Some(out)
222}
223
224/// Argument `i` of `def` as an index into its `names`: a variadic tail
225/// repeats, so an index past it lands on the `…` (or the last name).
226fn clamp_variadic(def: &FnDef, names: &[String], i: usize) -> usize {
227    if !def.variadic || names.is_empty() {
228        return i;
229    }
230    let tail = names
231        .iter()
232        .position(|n| n == "…")
233        .unwrap_or(names.len() - 1);
234    i.min(tail)
235}
236
237/// Replace the word at `caret` (through the end of the identifier the
238/// caret is in) with `cand`: the new text and the caret after it. A
239/// function lands as `NAME(` with the caret after the `(`, reusing a `(`
240/// that already follows.
241pub fn accept(text: &str, caret: usize, cand: &Candidate) -> (String, usize) {
242    let start = word_start(text, caret);
243    let end = caret
244        + text[caret..]
245            .char_indices()
246            .find(|&(_, c)| !ident_char(c))
247            .map_or(text.len() - caret, |(i, _)| i);
248    let after = &text[end..];
249    let reuse = matches!(cand.kind, Kind::Function(_)) && after.starts_with('(');
250    let insert = if reuse { &cand.name } else { &cand.insert };
251    let out = format!("{}{insert}{after}", &text[..start]);
252    // Past a reused `(` too.
253    (out, start + insert.len() + usize::from(reuse))
254}
255
256/// One editor's popup: the selected candidate, and whether Esc closed it
257/// (it stays closed until the text changes).
258#[derive(Debug, Clone, Default)]
259pub struct Popup {
260    /// The word the selection belongs to; a new word selects its first.
261    word: String,
262    pub sel: usize,
263    /// The text Esc was pressed on.
264    dismissed: Option<String>,
265}
266
267impl Popup {
268    /// Follow the editor: `text`, the `word` at its caret, and `n` candidates
269    /// for it. Returns whether the list shows.
270    pub fn sync(&mut self, text: &str, word: &str, n: usize) -> bool {
271        if self.word != word {
272            word.clone_into(&mut self.word);
273            self.sel = 0;
274        }
275        if self.dismissed.as_deref().is_some_and(|d| d != text) {
276            self.dismissed = None;
277        }
278        self.sel = self.sel.min(n.saturating_sub(1));
279        n > 0 && self.dismissed.is_none()
280    }
281
282    /// Esc: close it until `text` changes.
283    pub fn dismiss(&mut self, text: &str) {
284        self.dismissed = Some(text.to_string());
285    }
286
287    /// Up (`-1`) or Down (`1`) through `n` candidates, wrapping.
288    pub fn step(&mut self, delta: isize, n: usize) {
289        if n > 0 {
290            self.sel = (self.sel as isize + delta).rem_euclid(n as isize) as usize;
291        }
292    }
293}
294
295/// `def`'s hint split around argument `arg`: (before, current, after), so
296/// the current parameter can be drawn bold. No `arg` (or one past a
297/// fixed parameter list) leaves `current` empty.
298pub fn hint_parts(name: &str, def: &FnDef, arg: Option<usize>) -> (String, String, String) {
299    let names = def.param_names();
300    let Some(i) = arg
301        .map(|a| clamp_variadic(def, &names, a))
302        .filter(|&i| i < names.len())
303    else {
304        return (call_hint(name, def), String::new(), String::new());
305    };
306    let mut before = format!("{name}(");
307    for n in &names[..i] {
308        before.push_str(n);
309        before.push_str(", ");
310    }
311    let mut after: String = names[i + 1..].iter().map(|n| format!(", {n}")).collect();
312    after.push(')');
313    (before, names[i].clone(), after)
314}
315
316#[cfg(test)]
317#[cfg_attr(coverage_nightly, coverage(off))]
318mod tests {
319    use super::*;
320    use improv_core_model::{CategoryId, Measure, MeasureId, MeasureKind, Name, ValueType};
321
322    fn model() -> Model {
323        let mut m = Model::new();
324        m.add_category(CategoryId(1), "Time");
325        m.add_category(CategoryId(2), "Product");
326        for (id, name) in [(100, "Price"), (101, "Quantity"), (102, "Unit Cost")] {
327            m.add_measure(Measure {
328                id: MeasureId(id),
329                name: Name(name.into()),
330                value_type: ValueType::Number,
331                categories: vec![CategoryId(1)],
332                kind: MeasureKind::Input,
333                description: None,
334            });
335        }
336        m
337    }
338
339    fn names(c: &[Candidate]) -> Vec<&str> {
340        c.iter().map(|c| c.insert.as_str()).collect()
341    }
342
343    fn func(name: &str) -> Candidate {
344        let d = functions::by_name(name).next().expect(name);
345        Candidate::function(name, d)
346    }
347
348    #[test]
349    fn the_word_before_the_caret() {
350        assert_eq!(prefix("Price * Qua", 11), Some((8, "Qua")));
351        assert_eq!(prefix("FLOOR.MA", 8), Some((0, "FLOOR.MA")));
352        assert_eq!(prefix("SUM(Pr", 6), Some((4, "Pr")));
353        assert_eq!(prefix("1 + ", 4), None, "no word");
354        assert_eq!(prefix("10", 2), None, "a number is no name");
355        assert_eq!(prefix("\"Pri", 4), None, "inside a string");
356        assert_eq!(prefix("'Unit Co", 8), Some((0, "'Unit Co")));
357        assert_eq!(prefix("'Unit Cost' * Pr", 16), Some((14, "Pr")));
358        assert_eq!(prefix("é", 2), Some((0, "é")));
359        assert_eq!(prefix("é", 1), None, "not a char boundary");
360        assert_eq!(word_start("Price", 0), 0);
361    }
362
363    #[test]
364    fn candidates_are_measures_categories_then_functions() {
365        let m = model();
366        let c = candidates(&m, "pr");
367        assert_eq!(&names(&c)[..2], ["Price", "Product"]);
368        assert!(matches!(
369            (c[0].kind, c[1].kind),
370            (Kind::Measure, Kind::Category)
371        ));
372        assert!(names(&c).contains(&"PRODUCT("), "{:?}", names(&c));
373        assert_eq!(names(&candidates(&m, "PM")), ["PMT("]);
374        assert_eq!(names(&candidates(&m, "pm")), ["PMT("], "case-insensitive");
375        assert_eq!(
376            names(&candidates(&m, "un"))[0],
377            "'Unit Cost'",
378            "quoted to insert"
379        );
380        assert_eq!(names(&candidates(&m, "'unit")), ["'Unit Cost'"]);
381        assert_eq!(names(&candidates(&m, "'")).len(), 5, "model names only");
382        assert!(candidates(&m, "").is_empty());
383        assert!(candidates(&m, "zzzz").is_empty());
384        assert_eq!((c[0].hint().as_str(), c[0].doc()), ("Price", "measure"));
385        assert_eq!(c[1].doc(), "category");
386        assert!(
387            func("PMT").doc().contains("payment"),
388            "{}",
389            func("PMT").doc()
390        );
391    }
392
393    #[test]
394    fn the_offer_is_capped_and_closes_on_a_whole_name() {
395        let m = model();
396        assert_eq!(offer(&m, "S", 1).len(), MAX_ITEMS);
397        assert!(offer(&m, "Price", 5).is_empty(), "a whole measure name");
398        assert!(offer(&m, "Time", 4).is_empty(), "a whole category name");
399        assert!(!offer(&m, "time", 4).is_empty(), "names are case-sensitive");
400        assert!(!offer(&m, "PMT", 3).is_empty(), "a function without (");
401        assert!(offer(&m, "1 + ", 4).is_empty());
402        assert!(offer(&m, "PM(1)", 2).is_empty(), "the call is written");
403        assert!(offer(&m, "Prxx", 2).is_empty(), "inside a word");
404        assert_eq!(
405            offer(&m, "Pr * 2", 2)[0].insert,
406            "Price",
407            "before an operator"
408        );
409    }
410
411    #[test]
412    fn ghost_while_completing() {
413        let m = model();
414        let top = |t: &str| offer(&m, t, t.len()).into_iter().next();
415        let g = |t: &str| ghost(t, t.len(), top(t).as_ref());
416        assert_eq!(g("PM").as_deref(), Some("T(rate, nper, pv, [fv], [type])"));
417        assert_eq!(g("pm").as_deref(), Some("T(rate, nper, pv, [fv], [type])"));
418        assert_eq!(g("Pri").as_deref(), Some("ce"));
419        assert_eq!(g("1 + Qu").as_deref(), Some("antity"));
420        assert_eq!(g("'Unit").as_deref(), Some(" Cost'"));
421        // The quoted insert does not extend a bare prefix: no ghost.
422        assert_eq!(g("Uni"), None);
423        assert_eq!(g("PI").as_deref(), Some("()"));
424        // Not at the end of the text: nothing.
425        assert_eq!(ghost("PM + 1", 2, top("PM").as_ref()), None);
426    }
427
428    #[test]
429    fn ghost_inside_a_call() {
430        let g = |t: &str| ghost(t, t.len(), None);
431        assert_eq!(g("PMT(").as_deref(), Some("rate, nper, pv, [fv], [type])"));
432        assert_eq!(g("PMT(0.05, ").as_deref(), Some("nper, pv, [fv], [type])"));
433        assert_eq!(g("PMT(0.05,").as_deref(), Some(" nper, pv, [fv], [type])"));
434        assert_eq!(g("PMT(0.05").as_deref(), Some(", nper, pv, [fv], [type])"));
435        // Into the optional ones, then past them all.
436        assert_eq!(g("PMT(1, 2, 3, ").as_deref(), Some("[fv], [type])"));
437        assert_eq!(g("PMT(1, 2, 3, 4, 5").as_deref(), Some(")"));
438        // A variadic tail repeats.
439        assert_eq!(g("CONCAT(").as_deref(), Some("text1, …)"));
440        assert_eq!(g("CONCAT(a, b, ").as_deref(), Some("…)"));
441        assert_eq!(g("CONCAT(a, b").as_deref(), Some(", …)"));
442        // The innermost call; none outside one.
443        assert_eq!(g("PMT(ABS(").as_deref(), Some("number)"));
444        assert_eq!(
445            g("PMT(ABS(1), ").as_deref(),
446            Some("nper, pv, [fv], [type])")
447        );
448        assert_eq!(g("Price + 1"), None);
449        assert_eq!(ghost("PMT(", 2, None), None, "caret not at the end");
450    }
451
452    #[test]
453    fn accepting_replaces_the_word_and_places_the_caret() {
454        let m = model();
455        assert_eq!(accept("PM", 2, &func("PMT")), ("PMT(".into(), 4));
456        assert_eq!(
457            accept("1 + pm", 6, &func("PMT")),
458            ("1 + PMT(".into(), 8),
459            "the typed case is replaced"
460        );
461        // A `(` already there is reused.
462        assert_eq!(accept("PM(1, 2)", 2, &func("PMT")), ("PMT(1, 2)".into(), 4));
463        // The whole word under the caret goes.
464        let price = &candidates(&m, "Pr")[0];
465        assert_eq!(accept("Pr * 2", 2, price), ("Price * 2".into(), 5));
466        assert_eq!(accept("Prxx * 2", 2, price), ("Price * 2".into(), 5));
467        let unit = &candidates(&m, "'un")[0];
468        assert_eq!(accept("1 + 'un", 7, unit), ("1 + 'Unit Cost'".into(), 15));
469        assert_eq!(accept("1 + Un", 6, unit), ("1 + 'Unit Cost'".into(), 15));
470    }
471
472    #[test]
473    fn short_docs_drop_a_spelled_call() {
474        let d = |n: &str| functions::by_name(n).next().expect(n);
475        assert_eq!(short_doc(d("PMT")), "payment per period");
476        assert_eq!(short_doc(d("ABS")), d("ABS").doc, "nothing to drop");
477    }
478
479    #[test]
480    fn the_popup_selects_wraps_and_stays_closed_until_an_edit() {
481        let mut p = Popup::default();
482        assert!(p.sync("PM", "PM", 3));
483        p.step(1, 3);
484        p.step(1, 3);
485        assert_eq!(p.sel, 2);
486        p.step(1, 3);
487        assert_eq!(p.sel, 0, "wraps");
488        p.step(-1, 3);
489        assert_eq!(p.sel, 2);
490        assert!(p.sync("PM", "PM", 2) && p.sel == 1, "clamped to the list");
491        assert!(
492            p.sync("PMT", "PMT", 2) && p.sel == 0,
493            "a new word starts over"
494        );
495        p.dismiss("PMT");
496        assert!(!p.sync("PMT", "PMT", 2), "Esc closed it");
497        assert!(!p.sync("PMT ", "", 0), "nothing for a finished word");
498        assert!(p.sync("PMTx", "PMTx", 1), "an edit reopens it");
499        assert!(!p.sync("1", "", 0), "nothing to offer");
500    }
501
502    #[test]
503    fn hint_parts_mark_the_current_argument() {
504        let d = |n: &str| functions::by_name(n).next().expect(n);
505        let parts = |n: &str, a| {
506            let (b, c, e) = hint_parts(n, d(n), a);
507            format!("{b}<{c}>{e}")
508        };
509        assert_eq!(parts("PMT", Some(0)), "PMT(<rate>, nper, pv, [fv], [type])");
510        assert_eq!(parts("PMT", Some(3)), "PMT(rate, nper, pv, <[fv]>, [type])");
511        assert_eq!(parts("PMT", Some(9)), "PMT(rate, nper, pv, [fv], [type])<>");
512        assert_eq!(parts("PMT", None), "PMT(rate, nper, pv, [fv], [type])<>");
513        assert_eq!(parts("CONCAT", Some(4)), "CONCAT(text1, <…>)");
514        assert_eq!(parts("PI", Some(0)), "PI()<>");
515    }
516}