Skip to main content

improv_tui/
diagnose.rs

1//! Located formula errors, terminal-free: the span of text a
2//! [`ParseError`] is about, its message, and a hint for the common cases
3//! (a misspelled name, the wrong number of arguments, an unclosed `(`).
4
5use crate::complete::source_name;
6use improv_core_model::{functions, Model, ParseError};
7use std::ops::Range;
8
9/// A parse error located in the formula text.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Diagnostic {
12    /// Byte range of the offending token, if the error has a position.
13    pub span: Option<Range<usize>>,
14    pub message: String,
15    pub hint: Option<String>,
16}
17
18/// Locate `err` (from parsing `text`) and add a hint where one helps.
19pub fn diagnose(model: &Model, text: &str, err: &ParseError) -> Diagnostic {
20    let mut span = err.position.and_then(|p| token_span(text, p));
21    let msg = &err.message;
22    let mut hint = None;
23    if let Some(name) = ["unknown measure: ", "unknown category: "]
24        .iter()
25        .find_map(|p| msg.strip_prefix(p))
26    {
27        // Called like a function, a name may be one; in a category's place
28        // it cannot be. (The parser reports an unquoted `NAME(` itself.)
29        let category = msg.starts_with("unknown category");
30        let call = !category
31            && span
32                .as_ref()
33                .is_some_and(|s| text[s.end..].trim_start().starts_with('('));
34        hint = did_you_mean(model, name, call, category);
35    } else if let Some((name, _)) = msg
36        .split_once(" takes ")
37        .filter(|_| msg.contains("argument"))
38    {
39        if let Some(d) = functions::by_name(name).next() {
40            hint = Some(format!("usage: {}", d.hint()));
41            span = call_name(text, name, err.position.unwrap_or(text.len())).or(span);
42        }
43    } else if msg.starts_with("expected ')'") || msg == "unexpected end of input" {
44        if let Some(open) = unclosed_paren(text) {
45            span = Some(open..open + 1);
46            hint = Some("this '(' is never closed: add ')'".into());
47        }
48    }
49    Diagnostic {
50        span,
51        message: msg.clone(),
52        hint,
53    }
54}
55
56/// The token starting at byte `pos`: a quoted name, a word, a number, or
57/// one character. Past the end, the last character.
58fn token_span(text: &str, pos: usize) -> Option<Range<usize>> {
59    let pos = if pos >= text.len() {
60        text.char_indices().next_back()?.0
61    } else {
62        pos
63    };
64    let rest = text.get(pos..)?;
65    let first = rest.chars().next()?;
66    let len = if first == '\'' {
67        // To the closing quote ('' is an escaped quote), else the end.
68        let b = rest.as_bytes();
69        let mut i = 1;
70        while i < b.len() {
71            if b[i] == b'\'' {
72                if b.get(i + 1) == Some(&b'\'') {
73                    i += 2;
74                    continue;
75                }
76                i += 1;
77                break;
78            }
79            i += 1;
80        }
81        i.min(b.len())
82    } else if first.is_alphanumeric() || first == '_' || first == '@' {
83        rest.char_indices()
84            .skip(1)
85            .find(|&(_, c)| !(c.is_alphanumeric() || c == '_' || c == '.'))
86            .map_or(rest.len(), |(i, _)| i)
87    } else {
88        first.len_utf8()
89    };
90    Some(pos..pos + len)
91}
92
93/// The last `name(` before `before`, as the range of the name.
94fn call_name(text: &str, name: &str, before: usize) -> Option<Range<usize>> {
95    let lower = text.to_lowercase();
96    let n = name.to_lowercase();
97    if lower.len() != text.len() {
98        return None; // offsets would not line up
99    }
100    let mut end = before.min(text.len());
101    while let Some(i) = lower[..end].rfind(&n) {
102        let after = i + n.len();
103        let starts_word = lower[..i]
104            .chars()
105            .next_back()
106            .is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'));
107        if starts_word && text[after..].trim_start().starts_with('(') {
108            return Some(i..after);
109        }
110        end = i;
111    }
112    None
113}
114
115/// Byte offset of the innermost `(` left open at the end of `text`;
116/// parentheses in strings and quoted names do not count.
117fn unclosed_paren(text: &str) -> Option<usize> {
118    let mut open = Vec::new();
119    let mut quote = None;
120    for (i, c) in text.char_indices() {
121        match (quote, c) {
122            (Some(q), c) if c == q => quote = None,
123            (Some(_), _) => {}
124            (None, '"' | '\'') => quote = Some(c),
125            (None, '(') => open.push(i),
126            (None, ')') => {
127                open.pop();
128            }
129            _ => {}
130        }
131    }
132    open.pop()
133}
134
135/// Levenshtein distance between two strings, by chars.
136fn distance(a: &str, b: &str) -> usize {
137    let b: Vec<char> = b.chars().collect();
138    let mut row: Vec<usize> = (0..=b.len()).collect();
139    for (i, ca) in a.chars().enumerate() {
140        let mut diag = row[0];
141        row[0] = i + 1;
142        for (j, cb) in b.iter().enumerate() {
143            let next = (diag + usize::from(ca != *cb))
144                .min(row[j] + 1)
145                .min(row[j + 1] + 1);
146            diag = row[j + 1];
147            row[j + 1] = next;
148        }
149    }
150    row[b.len()]
151}
152
153/// "did you mean X?" for the names closest to `name` by edit distance:
154/// functions when it is called, else categories (and measures, unless only
155/// a category fits there).
156fn did_you_mean(model: &Model, name: &str, call: bool, category: bool) -> Option<String> {
157    let names: Vec<String> = if call {
158        functions::all()
159            .flat_map(|d| std::iter::once(d.name).chain(d.aliases.iter().copied()))
160            .map(str::to_string)
161            .collect()
162    } else {
163        let cats = model.categories.values().map(|c| &c.name.0);
164        let measures = model.measures.values().map(|m| &m.name.0);
165        let measures = measures.filter(|_| !category);
166        cats.chain(measures).map(|n| source_name(n)).collect()
167    };
168    let want = name.to_lowercase();
169    let limit = (want.chars().count() / 3).max(1);
170    let mut close: Vec<(usize, String)> = names
171        .into_iter()
172        .map(|n| {
173            let bare = n.trim_matches('\'').to_lowercase();
174            (distance(&want, &bare), n)
175        })
176        .filter(|(d, _)| *d <= limit)
177        .collect();
178    close.sort();
179    close.dedup();
180    let best: Vec<String> = close.into_iter().take(3).map(|(_, n)| n).collect();
181    (!best.is_empty()).then(|| format!("did you mean {}?", best.join(" or ")))
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::app::tests::revenue_model;
188    use improv_core_model::parser::parse_expr;
189
190    fn diag(text: &str) -> Diagnostic {
191        let m = revenue_model();
192        let err = parse_expr(&m, text).expect_err(text);
193        diagnose(&m, text, &err)
194    }
195
196    fn spanned(text: &str) -> (&str, Option<String>) {
197        let d = diag(text);
198        (&text[d.span.expect("located")], d.hint)
199    }
200
201    #[test]
202    fn unknown_names_suggest_the_closest() {
203        assert_eq!(
204            spanned("Price * Quantty"),
205            ("Quantty", Some("did you mean Quantity?".into()))
206        );
207        // A misspelled function: the parser's message names the suggestion
208        // (`unknown function: PMTT; did you mean PMT?`), so no second hint.
209        let d = diag("PMTT(1, 2, 3)");
210        assert_eq!(&"PMTT(1, 2, 3)"[d.span.expect("located")], "PMTT");
211        assert!(d.message.contains("did you mean PMT"), "{}", d.message);
212        assert_eq!(d.hint, None);
213        assert_eq!(
214            spanned("SUM(Price OVER Tyme)"),
215            ("Tyme", Some("did you mean Time?".into()))
216        );
217        // A category typo before `(` is still a category, not a function.
218        assert_eq!(
219            spanned("SUM(Price OVER Tyme(1))"),
220            ("Tyme", Some("did you mean Time?".into()))
221        );
222        // A quoted name called like a function may be one.
223        assert_eq!(
224            spanned("'Pmtt'(1)"),
225            ("'Pmtt'", Some("did you mean PMT?".into()))
226        );
227        // Nothing close: no hint.
228        assert_eq!(spanned("Price * Zebra"), ("Zebra", None));
229    }
230
231    #[test]
232    fn wrong_argument_counts_show_the_signature() {
233        assert_eq!(
234            spanned("1 + PMT(1) + 2"),
235            (
236                "PMT",
237                Some("usage: PMT(rate, nper, pv, [fv], [type])".into())
238            )
239        );
240        assert_eq!(spanned("abs(1, 2)").1, Some("usage: ABS(number)".into()));
241        // Not a built-in (an inline LAMBDA): the parser's position, no usage.
242        let d = diag("LAMBDA(x, x + 1)(1, 2)");
243        assert_eq!((d.span, d.hint), (Some(21..22), None));
244    }
245
246    #[test]
247    fn unclosed_parens_point_at_the_open_one() {
248        let hint = Some("this '(' is never closed: add ')'".into());
249        let d = diag("PMT(1, (2 + 3)");
250        assert_eq!((d.span, d.hint), (Some(3..4), hint.clone()));
251        assert_eq!(diag("(Price + 1").span, Some(0..1));
252        assert_eq!(diag("ABS(Price +").hint, hint);
253        // `)` inside a string does not close anything.
254        assert_eq!(unclosed_paren("LEN(\")\""), Some(3));
255        assert_eq!(unclosed_paren("'a(b' + 1"), None);
256        // Balanced but missing a comma: the parser's own position.
257        let d = diag("PMT(1 2)");
258        assert_eq!((d.span, d.hint), (Some(6..7), None));
259    }
260
261    #[test]
262    fn tokens_and_edges() {
263        assert_eq!(token_span("'Unit Pr", 0), Some(0..8));
264        assert_eq!(token_span("'it''s' + 1", 0), Some(0..7));
265        assert_eq!(token_span("1 + 22.5", 4), Some(4..8));
266        assert_eq!(token_span("Price $ 2", 6), Some(6..7));
267        assert_eq!(token_span("_net_rev + 1", 0), Some(0..8));
268        assert_eq!(token_span("@x + 1", 0), Some(0..2));
269        assert_eq!(token_span("Price +", 99), Some(6..7));
270        assert_eq!(token_span("", 0), None);
271        assert_eq!(token_span("é", 1), None, "not a char boundary");
272        assert_eq!(call_name("ABSX(1) + abs(2)", "ABS", 16), Some(10..13));
273        assert_eq!(call_name("İ abs(1)", "ABS", 9), None);
274        // Only a whole word followed by `(`: the later `xabs(` is skipped.
275        assert_eq!(call_name("abs(1) + xabs(2)", "ABS", 16), Some(0..3));
276        assert_eq!(call_name("my_abs(1)", "ABS", 9), None);
277        assert_eq!(call_name("x.abs(1)", "ABS", 8), None);
278        assert_eq!(call_name("abs + 1", "ABS", 7), None);
279        assert_eq!(distance("kitten", "sitting"), 3);
280        assert_eq!(distance("", "abc"), 3);
281        // No position: just the message.
282        let m = revenue_model();
283        let e = ParseError {
284            message: "empty expression".into(),
285            position: None,
286        };
287        assert_eq!(diagnose(&m, "", &e).span, None);
288    }
289}