1use crate::complete::source_name;
6use improv_core_model::{functions, Model, ParseError};
7use std::ops::Range;
8
9#[derive(Debug, Clone, PartialEq)]
11pub struct Diagnostic {
12 pub span: Option<Range<usize>>,
14 pub message: String,
15 pub hint: Option<String>,
16}
17
18pub 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 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
56fn 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 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
93fn 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; }
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
115fn 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
135fn 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
153fn 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 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 assert_eq!(
219 spanned("SUM(Price OVER Tyme(1))"),
220 ("Tyme", Some("did you mean Time?".into()))
221 );
222 assert_eq!(
224 spanned("'Pmtt'(1)"),
225 ("'Pmtt'", Some("did you mean PMT?".into()))
226 );
227 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 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 assert_eq!(unclosed_paren("LEN(\")\""), Some(3));
255 assert_eq!(unclosed_paren("'a(b' + 1"), None);
256 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 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 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}