1use improv_core_model::{functions, Model, ParseError};
8use std::ops::Range;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Diagnostic {
13 pub span: Option<Range<usize>>,
15 pub message: String,
16 pub hint: Option<String>,
18}
19
20impl Diagnostic {
21 pub fn plain(message: impl Into<String>) -> Self {
23 Diagnostic {
24 span: None,
25 message: message.into(),
26 hint: None,
27 }
28 }
29
30 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
38pub 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 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
77pub 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
89fn 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
106fn 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
147fn 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
169pub 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 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
207fn in_string(text: &str, i: usize) -> bool {
209 text[..i].matches('"').count() % 2 == 1
210}
211
212fn 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
235fn 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
242fn 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
262fn 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 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 let h = diag("Averag + 1").hint.unwrap();
347 assert!(h.starts_with("did you mean AVERAGE(…)"), "{h}");
348 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 assert_eq!(diag("Zzzzzzzzzz + 1").hint, None);
361 }
362
363 #[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 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 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 #[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 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 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}