Skip to main content

improv_tui/
complete.rs

1//! Formula-edit assistance, terminal-free: Tab completion of the identifier
2//! before the caret, and the ghost text of the parameters still to type.
3
4use crate::edit::LineEdit;
5use improv_core_model::{functions, Model};
6
7/// Whether `c` can be part of a bare identifier (`FLOOR.MATH` included).
8fn ident_char(c: char) -> bool {
9    c.is_alphanumeric() || c == '_' || c == '.'
10}
11
12/// Byte offset where the identifier ending at `caret` starts.
13pub fn word_start(text: &str, caret: usize) -> usize {
14    text[..caret]
15        .char_indices()
16        .rev()
17        .take_while(|&(_, c)| ident_char(c))
18        .last()
19        .map_or(caret, |(i, _)| i)
20}
21
22/// `name` as formula source: bare if it can be, else `'quoted'`.
23pub fn source_name(name: &str) -> String {
24    let mut chars = name.chars();
25    let bare = chars.next().is_some_and(|c| c.is_alphabetic() || c == '_')
26        && chars.all(|c| c.is_alphanumeric() || c == '_')
27        && !["NOT", "AND", "OR", "OVER", "TRUE", "FALSE"]
28            .iter()
29            .any(|k| name.eq_ignore_ascii_case(k));
30    if bare {
31        name.to_string()
32    } else {
33        format!("'{}'", name.replace('\'', "''"))
34    }
35}
36
37/// What Tab may put in place of `prefix`: the model's measure and category
38/// names (case-insensitive prefix match, sorted), then functions as `NAME(`.
39pub fn candidates(model: &Model, prefix: &str) -> Vec<String> {
40    if prefix.is_empty() {
41        return Vec::new();
42    }
43    let p = prefix.to_lowercase();
44    let mut names: Vec<&str> = model
45        .measures
46        .values()
47        .map(|m| m.name.0.as_str())
48        .chain(model.categories.values().map(|c| c.name.0.as_str()))
49        .filter(|n| n.to_lowercase().starts_with(&p))
50        .collect();
51    names.sort_unstable();
52    names.dedup();
53    let mut out: Vec<String> = names.into_iter().map(source_name).collect();
54    out.extend(
55        functions::complete(prefix)
56            .iter()
57            .map(|(n, _)| format!("{n}(")),
58    );
59    out
60}
61
62/// The longest prefix all of `cands` share, compared case-insensitively,
63/// spelled as the first one spells it.
64fn common_prefix(cands: &[String]) -> &str {
65    let first = &cands[0];
66    let mut end = first.len();
67    for c in &cands[1..] {
68        end = first
69            .char_indices()
70            .zip(c.chars())
71            .find(|((_, a), b)| !a.eq_ignore_ascii_case(b))
72            .map_or(end.min(c.len()), |((i, _), _)| end.min(i));
73    }
74    &first[..end]
75}
76
77/// Repeated-Tab state: the candidates for the word at `start`, and which one
78/// the line shows (`None` until the second Tab).
79#[derive(Debug, Clone, PartialEq)]
80pub struct Cycle {
81    start: usize,
82    cands: Vec<String>,
83    shown: Option<usize>,
84}
85
86/// One Tab in `e`. A single candidate replaces the word; several extend it
87/// to their common prefix, and each further Tab (with `cycle` kept from the
88/// last one) puts the next candidate in. Returns the status line to show:
89/// the candidates, or that there are none.
90pub fn tab(model: &Model, e: &mut LineEdit, cycle: &mut Option<Cycle>) -> Option<String> {
91    if let Some(c) = cycle {
92        let i = c.shown.map_or(0, |i| (i + 1) % c.cands.len());
93        c.shown = Some(i);
94        e.replace(c.start, &c.cands[i]);
95        return Some(listing(&c.cands));
96    }
97    let start = word_start(&e.text, e.caret);
98    let prefix = &e.text[start..e.caret];
99    if prefix.is_empty() {
100        return None;
101    }
102    let cands = candidates(model, prefix);
103    match cands.len() {
104        0 => Some(format!("no completions for '{prefix}'")),
105        1 => {
106            e.replace(start, &cands[0]);
107            None
108        }
109        _ => {
110            let common = common_prefix(&cands);
111            if common != prefix {
112                e.replace(start, common);
113            }
114            let msg = listing(&cands);
115            *cycle = Some(Cycle {
116                start,
117                cands,
118                shown: None,
119            });
120            Some(msg)
121        }
122    }
123}
124
125fn listing(cands: &[String]) -> String {
126    format!("Tab cycles: {}", cands.join("  "))
127}
128
129/// The parameters still to type, when the caret ends `text` inside a call:
130/// `PMT(1, ` gives `nper, pv, [fv], [type])`, `PMT(1, 2` gives
131/// `, pv, [fv], [type])`.
132pub fn ghost(text: &str, caret: usize) -> Option<String> {
133    if caret != text.len() {
134        return None;
135    }
136    let (def, arg) = functions::call_at(text, caret)?;
137    let names = def.param_names();
138    let typing = !text.trim_end().ends_with(['(', ',', ';']);
139    let rest = names.get(arg + usize::from(typing)..).unwrap_or_default();
140    let mut out = rest.join(", ");
141    if typing && !rest.is_empty() {
142        out.insert_str(0, ", ");
143    }
144    out.push(')');
145    Some(out)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::app::tests::revenue_model;
152
153    fn tab_in(model: &Model, text: &str, cycle: &mut Option<Cycle>) -> (String, Option<String>) {
154        let mut e = LineEdit::from(text);
155        let msg = tab(model, &mut e, cycle);
156        (e.text, msg)
157    }
158
159    #[test]
160    fn word_before_the_caret() {
161        assert_eq!(word_start("Price * Qua", 11), 8);
162        assert_eq!(word_start("FLOOR.MA", 8), 0);
163        assert_eq!(word_start("1 + ", 4), 4);
164        assert_eq!(word_start("é", 2), 0);
165        assert_eq!(word_start("1 + net_rev", 11), 4);
166    }
167
168    #[test]
169    fn candidates_are_model_names_then_functions() {
170        let m = revenue_model();
171        let c = candidates(&m, "pr");
172        assert_eq!(&c[..2], ["Price", "Product"]);
173        assert!(c.contains(&"PRODUCT(".to_string()));
174        assert!(candidates(&m, "").is_empty());
175        assert_eq!(source_name("Unit Price"), "'Unit Price'");
176        assert_eq!(source_name("it's"), "'it''s'");
177        assert_eq!(source_name("Over"), "'Over'");
178        assert_eq!(source_name("_tmp"), "_tmp");
179        assert_eq!(source_name("2024"), "'2024'");
180        assert_eq!(source_name(""), "''");
181    }
182
183    #[test]
184    fn tab_completes_extends_and_cycles() {
185        let m = revenue_model();
186        let mut cy = None;
187        // One match: a function completes with its parenthesis.
188        assert_eq!(tab_in(&m, "1 + pm", &mut cy), ("1 + PMT(".into(), None));
189        assert_eq!(tab_in(&m, "Price * Quan", &mut cy).0, "Price * Quantity");
190        assert!(cy.is_none());
191        // Nothing to complete, or nothing matches.
192        assert_eq!(tab_in(&m, "1 + ", &mut cy), ("1 + ".into(), None));
193        let (t, msg) = tab_in(&m, "zzq", &mut cy);
194        assert_eq!(
195            (t.as_str(), msg.unwrap()),
196            ("zzq", "no completions for 'zzq'".into())
197        );
198        // Several: extend to the common prefix and list them.
199        let mut e = LineEdit::from("sumx");
200        let msg = tab(&m, &mut e, &mut cy).unwrap();
201        assert_eq!(e.text, "SUMX");
202        assert!(msg.contains("SUMX2MY2("), "{msg}");
203        assert!(msg.contains("SUMX2PY2("), "{msg}");
204        // Already at the common prefix: the text stays, the list shows.
205        let mut cy2 = None;
206        let (t, msg) = tab_in(&m, "SUMX", &mut cy2);
207        assert_eq!(t, "SUMX");
208        assert!(msg.unwrap().starts_with("Tab cycles: SUMXMY2("));
209        assert!(cy2.is_some());
210        // Further Tabs cycle through them (shortest first), wrapping.
211        let mut seen = Vec::new();
212        for _ in 0..4 {
213            tab(&m, &mut e, &mut cy);
214            seen.push(e.text.clone());
215        }
216        assert_eq!(seen, ["SUMXMY2(", "SUMX2MY2(", "SUMX2PY2(", "SUMXMY2("]);
217        // The common prefix is case-insensitive: "pr" matches Price and PROB(.
218        let mut cy = None;
219        assert_eq!(tab_in(&m, "pr", &mut cy).0, "Pr");
220        assert_eq!(common_prefix(&["PMT(".into(), "pmtx".into()]), "PMT");
221        assert_eq!(common_prefix(&["ab".into(), "abc".into()]), "ab");
222    }
223
224    #[test]
225    fn ghost_text_is_the_parameters_left() {
226        assert_eq!(ghost("PMT(", 4).unwrap(), "rate, nper, pv, [fv], [type])");
227        assert_eq!(ghost("PMT(1, ", 7).unwrap(), "nper, pv, [fv], [type])");
228        assert_eq!(ghost("PMT(1, 2", 8).unwrap(), ", pv, [fv], [type])");
229        assert_eq!(ghost("PMT(1,2,3,4,5", 13).unwrap(), ")");
230        assert_eq!(ghost("PI(", 3).unwrap(), ")");
231        assert_eq!(ghost("PMT(1", 2), None, "caret not at the end");
232        assert_eq!(ghost("Price", 5), None, "not in a call");
233    }
234}