Skip to main content

improv_gui/
diagnostics.rs

1//! Located formula errors, window-free: the byte span of the formula an
2//! error is about, its message, and a "why" hint for the common cases (a
3//! misspelled name, the wrong number of arguments, a `(` left open, an
4//! aggregation without `OVER`). The formula editors underline the span and
5//! show the message and the hint under the row.
6
7use improv_core_model::{functions, Model, ParseError};
8use std::ops::Range;
9
10/// An error located in a formula's text.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Diagnostic {
13    /// Byte range to underline, if the error could be placed.
14    pub span: Option<Range<usize>>,
15    pub message: String,
16    /// Why, or what to do: shown dimmed under the message.
17    pub hint: Option<String>,
18}
19
20impl Diagnostic {
21    /// A message with no place and no hint.
22    pub fn plain(message: impl Into<String>) -> Self {
23        Diagnostic {
24            span: None,
25            message: message.into(),
26            hint: None,
27        }
28    }
29
30    /// The same diagnostic for text that starts `by` bytes later (the
31    /// `Name = ` before a formula).
32    pub fn shifted(mut self, by: usize) -> Self {
33        self.span = self.span.map(|r| r.start + by..r.end + by);
34        self
35    }
36}
37
38/// Locate `err` (from parsing `text`) and add a hint where one helps.
39pub fn parse_error(model: &Model, text: &str, err: &ParseError) -> Diagnostic {
40    let msg = err.message.as_str();
41    let mut span = err.position.and_then(|p| token_at(text, p));
42    let mut hint = None;
43    if let Some(name) = msg.strip_prefix("unknown measure: ") {
44        hint = unknown_measure(model, name);
45    } else if let Some(name) = msg.strip_prefix("unknown category: ") {
46        hint = unknown_category(model, name);
47    } else if let Some((name, given)) = arity(msg) {
48        // The message already spells the parameters: say what is off.
49        if let Some(d) = functions::by_name(name).next() {
50            hint = given.parse::<usize>().ok().map(|n| arity_hint(d, n));
51            span = call_name(text, name, err.position.unwrap_or(text.len())).or(span);
52        }
53    } else if msg.starts_with("expected ')'") || msg == "unexpected end of input" {
54        if let Some(open) = unclosed_paren(text) {
55            span = Some(open..open + 1);
56            hint = Some("( opened here is never closed: add )".into());
57        }
58    } else if let Some(name) = msg.split_once(" aggregates: ").map(|(n, _)| n) {
59        if let Some(d) = functions::aggregation(name) {
60            hint = Some(format!(
61                "{} reduces a measure over a category, so it needs OVER: {}",
62                d.name,
63                d.hint()
64            ));
65            span = call_name(text, name, err.position.unwrap_or(text.len())).or(span);
66        }
67    } else if msg.ends_with(" does not aggregate OVER a category") {
68        hint = Some("only aggregations take OVER (SUM, AVERAGE, MIN, MAX, COUNT, …)".into());
69    }
70    Diagnostic {
71        span,
72        message: msg.to_string(),
73        hint,
74    }
75}
76
77/// A compile (type or dimension) error for `text`, which has no position:
78/// underline the first function or measure the message names that also
79/// appears in the text.
80pub fn compile_error(model: &Model, text: &str, message: &str) -> Diagnostic {
81    let message = message.strip_prefix("compile error: ").unwrap_or(message);
82    Diagnostic {
83        span: mentioned_span(model, text, message),
84        message: message.to_string(),
85        hint: None,
86    }
87}
88
89/// What is off about calling `d` with `n` arguments.
90fn arity_hint(d: &functions::FnDef, n: usize) -> String {
91    let names = d.param_names();
92    if n < d.required {
93        let missing: Vec<&str> = names
94            .iter()
95            .skip(n)
96            .take(d.required - n)
97            .map(String::as_str)
98            .collect();
99        format!("missing {}: {n} given", missing.join(", "))
100    } else {
101        let at_most = names.len();
102        format!("at most {at_most} argument(s): {n} given; remove the extra")
103    }
104}
105
106/// `"PMT takes …, got 2 argument(s)"`: the function and the count given.
107fn arity(msg: &str) -> Option<(&str, &str)> {
108    let (name, rest) = msg.split_once(" takes ")?;
109    let given = rest.rsplit_once(", got ")?.1.strip_suffix(" argument(s)")?;
110    Some((name, given))
111}
112
113fn unknown_measure(model: &Model, name: &str) -> Option<String> {
114    if model.category_by_name(name).is_some() {
115        return Some(format!(
116            "{name} is a category, not a measure: reduce over it, e.g. SUM(<measure> OVER {name})"
117        ));
118    }
119    if let Some(d) = functions::by_name(name).next() {
120        return Some(format!("{} is a function: {}", d.name, d.hint()));
121    }
122    let measures = model.measures.values().map(|m| m.name.0.as_str());
123    let mut near = closest(name, measures);
124    if near.is_empty() {
125        near = functions::closest(name, 3)
126            .into_iter()
127            .map(|n| format!("{n}(…)"))
128            .collect();
129    }
130    did_you_mean(near)
131}
132
133fn unknown_category(model: &Model, name: &str) -> Option<String> {
134    if model.measure_by_name(name).is_some() {
135        return Some(format!("{name} is a measure, not a category"));
136    }
137    did_you_mean(closest(
138        name,
139        model.categories.values().map(|c| c.name.0.as_str()),
140    ))
141}
142
143fn did_you_mean(near: Vec<String>) -> Option<String> {
144    (!near.is_empty()).then(|| format!("did you mean {}?", near.join(", ")))
145}
146
147/// Up to three of `names` nearest `word` by edit distance (ignoring case),
148/// within a third of its length (at least 2), nearest first.
149fn closest<'a>(word: &str, names: impl Iterator<Item = &'a str>) -> Vec<String> {
150    let w = word.to_lowercase();
151    let limit = (w.chars().count() / 3).max(2);
152    let mut scored: Vec<(usize, &str)> = names
153        .map(|n| (functions::edit_distance(&w, &n.to_lowercase()), n))
154        .filter(|&(d, _)| d <= limit)
155        .collect();
156    scored.sort();
157    scored.dedup_by(|a, b| a.1 == b.1);
158    scored
159        .into_iter()
160        .take(3)
161        .map(|(_, n)| n.to_string())
162        .collect()
163}
164
165fn ident_char(c: char) -> bool {
166    c.is_alphanumeric() || c == '_' || c == '.'
167}
168
169/// The token starting at byte `pos`: a `'quoted name'`, a word, or one
170/// character. At or past the end, the last character.
171pub fn token_at(text: &str, pos: usize) -> Option<Range<usize>> {
172    let mut pos = pos.min(text.len());
173    if pos == text.len() {
174        pos = text.char_indices().next_back()?.0;
175    }
176    while !text.is_char_boundary(pos) {
177        pos -= 1;
178    }
179    let rest = &text[pos..];
180    let first = rest.chars().next()?;
181    let len = if first == '\'' {
182        // To the closing quote ('' is an escaped quote), else the end.
183        let mut it = rest.char_indices().skip(1).peekable();
184        let mut end = rest.len();
185        while let Some((i, c)) = it.next() {
186            if c == '\'' {
187                if it.peek().is_some_and(|&(_, n)| n == '\'') {
188                    it.next();
189                    continue;
190                }
191                end = i + 1;
192                break;
193            }
194        }
195        end
196    } else if ident_char(first) || first == '@' {
197        rest.char_indices()
198            .skip(1)
199            .find(|&(_, c)| !ident_char(c))
200            .map_or(rest.len(), |(i, _)| i)
201    } else {
202        first.len_utf8()
203    };
204    Some(pos..pos + len)
205}
206
207/// Whether byte `i` of `text` is inside a `"string"`.
208fn in_string(text: &str, i: usize) -> bool {
209    text[..i].matches('"').count() % 2 == 1
210}
211
212/// Every whole-word occurrence of `name` in `text` outside strings
213/// (ignoring ASCII case when `nocase`).
214fn occurrences<'a>(
215    text: &'a str,
216    name: &'a str,
217    nocase: bool,
218) -> impl Iterator<Item = Range<usize>> + 'a {
219    text.char_indices().filter_map(move |(i, _)| {
220        let s = text.get(i..i + name.len())?;
221        let same = if nocase {
222            s.eq_ignore_ascii_case(name)
223        } else {
224            s == name
225        };
226        let before = text[..i].chars().next_back().is_none_or(|c| !ident_char(c));
227        let after = text[i + name.len()..]
228            .chars()
229            .next()
230            .is_none_or(|c| !ident_char(c));
231        (same && before && after && !in_string(text, i)).then_some(i..i + name.len())
232    })
233}
234
235/// The span of the last `name(` call starting before byte `before`.
236fn call_name(text: &str, name: &str, before: usize) -> Option<Range<usize>> {
237    occurrences(text, name, true)
238        .filter(|r| r.start < before.max(1) && text[r.end..].trim_start().starts_with('('))
239        .last()
240}
241
242/// Byte offset of the innermost `(` left open at the end of `text`;
243/// parentheses in strings and quoted names do not count.
244fn unclosed_paren(text: &str) -> Option<usize> {
245    let mut open = Vec::new();
246    let mut quote = None;
247    for (i, c) in text.char_indices() {
248        match (quote, c) {
249            (Some(q), c) if c == q => quote = None,
250            (Some(_), _) => {}
251            (None, '"' | '\'') => quote = Some(c),
252            (None, '(') => open.push(i),
253            (None, ')') => {
254                open.pop();
255            }
256            _ => {}
257        }
258    }
259    open.pop()
260}
261
262/// The span in `text` of the first function or measure `message` names:
263/// a measure by its exact name, a function by its upper-case name (as
264/// messages print it) written as a call. Earliest in the message wins.
265fn mentioned_span(model: &Model, text: &str, message: &str) -> Option<Range<usize>> {
266    let mut named: Vec<(usize, Range<usize>)> = Vec::new();
267    for m in model.measures.values() {
268        let name = m.name.0.as_str();
269        if name.is_empty() {
270            continue;
271        }
272        let (Some(at), Some(r)) = (
273            occurrences(message, name, false).next(),
274            occurrences(text, name, false).next(),
275        ) else {
276            continue;
277        };
278        named.push((at.start, r));
279    }
280    for word in message.split(|c: char| !ident_char(c)) {
281        // `word` is a slice of `message`: its offset is exact.
282        let start = word.as_ptr() as usize - message.as_ptr() as usize;
283        let upper = !word.is_empty() && word.chars().all(|c| !c.is_lowercase());
284        if !upper || functions::by_name(word).next().is_none() {
285            continue;
286        }
287        if let Some(r) =
288            occurrences(text, word, true).find(|r| text[r.end..].trim_start().starts_with('('))
289        {
290            named.push((start, r));
291        }
292    }
293    named.into_iter().min_by_key(|(at, _)| *at).map(|(_, r)| r)
294}
295
296#[cfg(test)]
297#[cfg_attr(coverage_nightly, coverage(off))]
298mod tests {
299    use super::*;
300    use improv_core_model::{parser, CategoryId, Measure, MeasureId, MeasureKind, Name, ValueType};
301
302    fn model() -> Model {
303        let mut m = Model::new();
304        m.add_category(CategoryId(1), "Time");
305        m.add_category(CategoryId(2), "Product");
306        for (id, name) in [(100, "Price"), (101, "Quantity"), (102, "Unit Cost")] {
307            m.add_measure(Measure {
308                id: MeasureId(id),
309                name: Name(name.into()),
310                value_type: ValueType::Number,
311                categories: vec![CategoryId(1), CategoryId(2)],
312                kind: MeasureKind::Input,
313                description: None,
314            });
315        }
316        m
317    }
318
319    fn diag(text: &str) -> Diagnostic {
320        let m = model();
321        let e = parser::parse_expr(&m, text).expect_err(text);
322        parse_error(&m, text, &e)
323    }
324
325    fn marked(text: &str, d: &Diagnostic) -> String {
326        d.span
327            .clone()
328            .map(|r| text[r].to_string())
329            .unwrap_or_default()
330    }
331
332    #[test]
333    fn unknown_names_suggest_the_closest() {
334        let t = "Prise * Quantity";
335        let d = diag(t);
336        assert_eq!(marked(t, &d), "Prise");
337        assert_eq!(d.message, "unknown measure: Prise");
338        assert_eq!(d.hint.as_deref(), Some("did you mean Price?"));
339
340        let t = "SUM(Quantity OVER Tyme)";
341        let d = diag(t);
342        assert_eq!(marked(t, &d), "Tyme");
343        assert_eq!(d.hint.as_deref(), Some("did you mean Time?"));
344
345        // No close measure: the closest functions, as calls.
346        let h = diag("Averag + 1").hint.unwrap();
347        assert!(h.starts_with("did you mean AVERAGE(…)"), "{h}");
348        // A function or a category where a measure belongs.
349        let h = diag("ABS + 1").hint.unwrap();
350        assert!(h.starts_with("ABS is a function: ABS("), "{h}");
351        assert!(diag("Time + 1")
352            .hint
353            .unwrap()
354            .contains("SUM(<measure> OVER Time)"));
355        assert_eq!(
356            diag("SUM(Quantity OVER Price)").hint.as_deref(),
357            Some("Price is a measure, not a category")
358        );
359        // Nothing near: no hint.
360        assert_eq!(diag("Zzzzzzzzzz + 1").hint, None);
361    }
362
363    /// The parser names the closest functions itself; no hint repeats it.
364    #[test]
365    fn unknown_function_is_not_suggested_twice() {
366        let t = "PMTT(0.05, 10, 100)";
367        let d = diag(t);
368        assert_eq!(marked(t, &d), "PMTT");
369        assert!(d.message.contains("did you mean PMT"), "{}", d.message);
370        assert_eq!(d.hint, None);
371    }
372
373    #[test]
374    fn arity_names_the_parameters_and_marks_the_call() {
375        let t = "1 + PMT(0.05, 10)";
376        let d = diag(t);
377        assert!(d.message.contains("PMT(rate, nper, pv, [fv], [type])"));
378        assert_eq!(d.hint.as_deref(), Some("missing pv: 2 given"));
379        assert_eq!(marked(t, &d), "PMT");
380        // The innermost of two calls by that name; too many arguments.
381        let t = "ABS(1) + abs(1, 2)";
382        assert_eq!(d_span(t), 9..12);
383        assert_eq!(
384            diag(t).hint.as_deref(),
385            Some("at most 1 argument(s): 2 given; remove the extra")
386        );
387    }
388
389    fn d_span(t: &str) -> Range<usize> {
390        diag(t).span.expect("placed")
391    }
392
393    #[test]
394    fn unclosed_paren_marks_the_open_one() {
395        let t = "ABS((Price + 1)";
396        let d = diag(t);
397        assert_eq!(d.span, Some(3..4));
398        assert_eq!(
399            d.hint.as_deref(),
400            Some("( opened here is never closed: add )")
401        );
402        // A ")" in a string does not close it.
403        let t = "LEN(\")\"";
404        assert_eq!(d_span(t), 3..4);
405    }
406
407    #[test]
408    fn aggregation_without_over_shows_the_form() {
409        let t = "IRR(Quantity)";
410        let d = diag(t);
411        assert_eq!(marked(t, &d), "IRR");
412        let h = d.hint.unwrap();
413        let irr = functions::aggregation("IRR")
414            .expect("IRR aggregates")
415            .hint();
416        assert!(h.contains("needs OVER") && h.ends_with(&irr), "{h}");
417        let d = diag("ABS(Quantity OVER Time)");
418        assert!(d.hint.unwrap().starts_with("only aggregations take OVER"));
419    }
420
421    #[test]
422    fn token_at_reads_quoted_names_words_and_the_end() {
423        let t = "'Unit Cost' * FLOOR.MATH(x)";
424        assert_eq!(token_at(t, 0), Some(0..11));
425        assert_eq!(token_at(t, 14), Some(14..24));
426        assert_eq!(token_at(t, 12), Some(12..13));
427        assert_eq!(token_at(t, 99), Some(26..27));
428        assert_eq!(token_at("'it''s", 0), Some(0..6));
429        assert_eq!(token_at("é", 1), Some(0..2));
430        assert_eq!(token_at("", 0), None);
431    }
432
433    /// Compile errors carry no position: the first function or measure
434    /// the message names that the text contains is underlined.
435    #[test]
436    fn compile_errors_underline_what_they_name() {
437        let m = model();
438        let span = |text: &str, msg: &str| {
439            let d = compile_error(&m, text, msg);
440            assert!(!d.message.starts_with("compile error"));
441            d.span.map(|r| text[r].to_string())
442        };
443        assert_eq!(
444            span(
445                "Price + SUM(Quantity OVER Product)",
446                "dimension mismatch: Price ranges over [Time, Product] and Quantity over [Time]"
447            )
448            .as_deref(),
449            Some("Price")
450        );
451        // A function named in upper case, written as a call; "Text" and
452        // "type" are words, not the TEXT and TYPE functions.
453        assert_eq!(
454            span(
455                "Quantity + len(Price)",
456                "compile error: type mismatch: LEN argument 1 must be Text, got Number (LEN(text))"
457            )
458            .as_deref(),
459            Some("len")
460        );
461        assert_eq!(
462            span(
463                "'Unit Cost' * 2",
464                "Unit Cost is Number but its formula yields Text"
465            )
466            .as_deref(),
467            Some("Unit Cost")
468        );
469        // A name only inside a string literal is not the text's.
470        assert_eq!(span("\"Price\" & 1", "Price is Text"), None);
471        assert_eq!(span("1 + 2", "IF test must be Boolean"), None);
472    }
473
474    #[test]
475    fn shifted_moves_the_span() {
476        let d = Diagnostic {
477            span: Some(1..3),
478            ..Diagnostic::plain("x")
479        };
480        assert_eq!(d.shifted(4).span, Some(5..7));
481        assert_eq!(Diagnostic::plain("x").shifted(4).span, None);
482    }
483}