1use improv_core_model::functions::{self, FnDef};
8use improv_core_model::Model;
9
10pub const MAX_ITEMS: usize = 8;
12
13#[derive(Debug, Clone, Copy)]
15pub enum Kind {
16 Function(&'static FnDef),
17 Measure,
18 Category,
19}
20
21#[derive(Debug, Clone)]
23pub struct Candidate {
24 pub name: String,
26 pub insert: String,
28 pub kind: Kind,
29}
30
31impl Candidate {
32 pub fn function(name: &str, def: &'static FnDef) -> Self {
34 Candidate {
35 name: name.to_string(),
36 insert: format!("{name}("),
37 kind: Kind::Function(def),
38 }
39 }
40
41 pub fn hint(&self) -> String {
43 match self.kind {
44 Kind::Function(d) => call_hint(&self.name, d),
45 _ => self.name.clone(),
46 }
47 }
48
49 pub fn doc(&self) -> &'static str {
51 match self.kind {
52 Kind::Function(d) => short_doc(d),
53 Kind::Measure => "measure",
54 Kind::Category => "category",
55 }
56 }
57}
58
59pub fn short_doc(d: &FnDef) -> &'static str {
62 let doc = d.doc;
63 let call = format!("{}(", d.name);
64 match doc.find(&call) {
65 Some(at) if doc[at..].ends_with(')') => doc[..at].trim_end_matches([' ', ':', ',']),
66 _ => doc,
67 }
68}
69
70fn call_hint(name: &str, d: &FnDef) -> String {
72 format!("{name}({})", d.param_names().join(", "))
73}
74
75fn ident_char(c: char) -> bool {
76 c.is_alphanumeric() || c == '_' || c == '.'
77}
78
79fn in_string(text: &str, i: usize) -> bool {
81 text[..i].matches('"').count() % 2 == 1
82}
83
84pub fn word_start(text: &str, caret: usize) -> usize {
87 let before = &text[..caret];
88 if before.matches('\'').count() % 2 == 1 {
89 if let Some(q) = before.rfind('\'') {
90 return q;
91 }
92 }
93 before
94 .char_indices()
95 .rev()
96 .take_while(|&(_, c)| ident_char(c))
97 .last()
98 .map_or(caret, |(i, _)| i)
99}
100
101pub fn prefix(text: &str, caret: usize) -> Option<(usize, &str)> {
104 if !text.is_char_boundary(caret) {
105 return None;
106 }
107 let start = word_start(text, caret);
108 let word = &text[start..caret];
109 let first = word.chars().next()?;
110 let name = first.is_alphabetic() || first == '_' || first == '\'';
111 (name && !in_string(text, start)).then_some((start, word))
112}
113
114pub fn candidates(model: &Model, word: &str) -> Vec<Candidate> {
118 let quoted = word.strip_prefix('\'');
119 let p = quoted.unwrap_or(word).to_lowercase();
120 if p.is_empty() && quoted.is_none() {
121 return Vec::new();
122 }
123 let named = |names: Vec<&str>, kind: Kind| {
124 let mut names: Vec<&str> = names
125 .into_iter()
126 .filter(|n| n.to_lowercase().starts_with(&p))
127 .collect();
128 names.sort_unstable();
129 names.dedup();
130 names
131 .into_iter()
132 .filter_map(move |n| {
133 Some(Candidate {
134 name: n.to_string(),
135 insert: crate::app::ident(n)?,
136 kind,
137 })
138 })
139 .collect::<Vec<_>>()
140 };
141 let mut out = named(
142 model.measures.values().map(|m| m.name.0.as_str()).collect(),
143 Kind::Measure,
144 );
145 out.extend(named(
146 model
147 .categories
148 .values()
149 .map(|c| c.name.0.as_str())
150 .collect(),
151 Kind::Category,
152 ));
153 if quoted.is_none() {
154 out.extend(
155 functions::complete(word)
156 .into_iter()
157 .map(|(n, d)| Candidate::function(n, d)),
158 );
159 }
160 out
161}
162
163pub fn offer(model: &Model, text: &str, caret: usize) -> Vec<Candidate> {
168 let Some((_, word)) = prefix(text, caret) else {
169 return Vec::new();
170 };
171 if text[caret..]
172 .chars()
173 .next()
174 .is_some_and(|c| c == '(' || ident_char(c))
175 {
176 return Vec::new();
177 }
178 let mut c = candidates(model, word);
179 let whole = c
180 .iter()
181 .any(|c| !matches!(c.kind, Kind::Function(_)) && c.insert == word);
182 if whole {
183 return Vec::new();
184 }
185 c.truncate(MAX_ITEMS);
186 c
187}
188
189pub fn ghost(text: &str, caret: usize, cand: Option<&Candidate>) -> Option<String> {
195 if caret != text.len() {
196 return None;
197 }
198 if let (Some(c), Some((_, word))) = (cand, prefix(text, caret)) {
199 let full = match c.kind {
200 Kind::Function(d) => call_hint(&c.name, d),
201 _ => c.insert.clone(),
202 };
203 let head = full.get(..word.len())?;
204 return head
205 .eq_ignore_ascii_case(word)
206 .then(|| full[word.len()..].to_string())
207 .filter(|g| !g.is_empty());
208 }
209 let (def, arg) = functions::call_at(text, caret)?;
210 let names = def.param_names();
211 let typing = !text.trim_end().ends_with(['(', ',', ';']);
212 let start = clamp_variadic(def, &names, arg + usize::from(typing));
213 let rest = names.get(start..).unwrap_or_default();
214 let mut out = rest.join(", ");
215 if typing && !rest.is_empty() {
216 out.insert_str(0, ", ");
217 } else if text.ends_with([',', ';']) {
218 out.insert(0, ' ');
219 }
220 out.push(')');
221 Some(out)
222}
223
224fn clamp_variadic(def: &FnDef, names: &[String], i: usize) -> usize {
227 if !def.variadic || names.is_empty() {
228 return i;
229 }
230 let tail = names
231 .iter()
232 .position(|n| n == "…")
233 .unwrap_or(names.len() - 1);
234 i.min(tail)
235}
236
237pub fn accept(text: &str, caret: usize, cand: &Candidate) -> (String, usize) {
242 let start = word_start(text, caret);
243 let end = caret
244 + text[caret..]
245 .char_indices()
246 .find(|&(_, c)| !ident_char(c))
247 .map_or(text.len() - caret, |(i, _)| i);
248 let after = &text[end..];
249 let reuse = matches!(cand.kind, Kind::Function(_)) && after.starts_with('(');
250 let insert = if reuse { &cand.name } else { &cand.insert };
251 let out = format!("{}{insert}{after}", &text[..start]);
252 (out, start + insert.len() + usize::from(reuse))
254}
255
256#[derive(Debug, Clone, Default)]
259pub struct Popup {
260 word: String,
262 pub sel: usize,
263 dismissed: Option<String>,
265}
266
267impl Popup {
268 pub fn sync(&mut self, text: &str, word: &str, n: usize) -> bool {
271 if self.word != word {
272 word.clone_into(&mut self.word);
273 self.sel = 0;
274 }
275 if self.dismissed.as_deref().is_some_and(|d| d != text) {
276 self.dismissed = None;
277 }
278 self.sel = self.sel.min(n.saturating_sub(1));
279 n > 0 && self.dismissed.is_none()
280 }
281
282 pub fn dismiss(&mut self, text: &str) {
284 self.dismissed = Some(text.to_string());
285 }
286
287 pub fn step(&mut self, delta: isize, n: usize) {
289 if n > 0 {
290 self.sel = (self.sel as isize + delta).rem_euclid(n as isize) as usize;
291 }
292 }
293}
294
295pub fn hint_parts(name: &str, def: &FnDef, arg: Option<usize>) -> (String, String, String) {
299 let names = def.param_names();
300 let Some(i) = arg
301 .map(|a| clamp_variadic(def, &names, a))
302 .filter(|&i| i < names.len())
303 else {
304 return (call_hint(name, def), String::new(), String::new());
305 };
306 let mut before = format!("{name}(");
307 for n in &names[..i] {
308 before.push_str(n);
309 before.push_str(", ");
310 }
311 let mut after: String = names[i + 1..].iter().map(|n| format!(", {n}")).collect();
312 after.push(')');
313 (before, names[i].clone(), after)
314}
315
316#[cfg(test)]
317#[cfg_attr(coverage_nightly, coverage(off))]
318mod tests {
319 use super::*;
320 use improv_core_model::{CategoryId, Measure, MeasureId, MeasureKind, Name, ValueType};
321
322 fn model() -> Model {
323 let mut m = Model::new();
324 m.add_category(CategoryId(1), "Time");
325 m.add_category(CategoryId(2), "Product");
326 for (id, name) in [(100, "Price"), (101, "Quantity"), (102, "Unit Cost")] {
327 m.add_measure(Measure {
328 id: MeasureId(id),
329 name: Name(name.into()),
330 value_type: ValueType::Number,
331 categories: vec![CategoryId(1)],
332 kind: MeasureKind::Input,
333 description: None,
334 });
335 }
336 m
337 }
338
339 fn names(c: &[Candidate]) -> Vec<&str> {
340 c.iter().map(|c| c.insert.as_str()).collect()
341 }
342
343 fn func(name: &str) -> Candidate {
344 let d = functions::by_name(name).next().expect(name);
345 Candidate::function(name, d)
346 }
347
348 #[test]
349 fn the_word_before_the_caret() {
350 assert_eq!(prefix("Price * Qua", 11), Some((8, "Qua")));
351 assert_eq!(prefix("FLOOR.MA", 8), Some((0, "FLOOR.MA")));
352 assert_eq!(prefix("SUM(Pr", 6), Some((4, "Pr")));
353 assert_eq!(prefix("1 + ", 4), None, "no word");
354 assert_eq!(prefix("10", 2), None, "a number is no name");
355 assert_eq!(prefix("\"Pri", 4), None, "inside a string");
356 assert_eq!(prefix("'Unit Co", 8), Some((0, "'Unit Co")));
357 assert_eq!(prefix("'Unit Cost' * Pr", 16), Some((14, "Pr")));
358 assert_eq!(prefix("é", 2), Some((0, "é")));
359 assert_eq!(prefix("é", 1), None, "not a char boundary");
360 assert_eq!(word_start("Price", 0), 0);
361 }
362
363 #[test]
364 fn candidates_are_measures_categories_then_functions() {
365 let m = model();
366 let c = candidates(&m, "pr");
367 assert_eq!(&names(&c)[..2], ["Price", "Product"]);
368 assert!(matches!(
369 (c[0].kind, c[1].kind),
370 (Kind::Measure, Kind::Category)
371 ));
372 assert!(names(&c).contains(&"PRODUCT("), "{:?}", names(&c));
373 assert_eq!(names(&candidates(&m, "PM")), ["PMT("]);
374 assert_eq!(names(&candidates(&m, "pm")), ["PMT("], "case-insensitive");
375 assert_eq!(
376 names(&candidates(&m, "un"))[0],
377 "'Unit Cost'",
378 "quoted to insert"
379 );
380 assert_eq!(names(&candidates(&m, "'unit")), ["'Unit Cost'"]);
381 assert_eq!(names(&candidates(&m, "'")).len(), 5, "model names only");
382 assert!(candidates(&m, "").is_empty());
383 assert!(candidates(&m, "zzzz").is_empty());
384 assert_eq!((c[0].hint().as_str(), c[0].doc()), ("Price", "measure"));
385 assert_eq!(c[1].doc(), "category");
386 assert!(
387 func("PMT").doc().contains("payment"),
388 "{}",
389 func("PMT").doc()
390 );
391 }
392
393 #[test]
394 fn the_offer_is_capped_and_closes_on_a_whole_name() {
395 let m = model();
396 assert_eq!(offer(&m, "S", 1).len(), MAX_ITEMS);
397 assert!(offer(&m, "Price", 5).is_empty(), "a whole measure name");
398 assert!(offer(&m, "Time", 4).is_empty(), "a whole category name");
399 assert!(!offer(&m, "time", 4).is_empty(), "names are case-sensitive");
400 assert!(!offer(&m, "PMT", 3).is_empty(), "a function without (");
401 assert!(offer(&m, "1 + ", 4).is_empty());
402 assert!(offer(&m, "PM(1)", 2).is_empty(), "the call is written");
403 assert!(offer(&m, "Prxx", 2).is_empty(), "inside a word");
404 assert_eq!(
405 offer(&m, "Pr * 2", 2)[0].insert,
406 "Price",
407 "before an operator"
408 );
409 }
410
411 #[test]
412 fn ghost_while_completing() {
413 let m = model();
414 let top = |t: &str| offer(&m, t, t.len()).into_iter().next();
415 let g = |t: &str| ghost(t, t.len(), top(t).as_ref());
416 assert_eq!(g("PM").as_deref(), Some("T(rate, nper, pv, [fv], [type])"));
417 assert_eq!(g("pm").as_deref(), Some("T(rate, nper, pv, [fv], [type])"));
418 assert_eq!(g("Pri").as_deref(), Some("ce"));
419 assert_eq!(g("1 + Qu").as_deref(), Some("antity"));
420 assert_eq!(g("'Unit").as_deref(), Some(" Cost'"));
421 assert_eq!(g("Uni"), None);
423 assert_eq!(g("PI").as_deref(), Some("()"));
424 assert_eq!(ghost("PM + 1", 2, top("PM").as_ref()), None);
426 }
427
428 #[test]
429 fn ghost_inside_a_call() {
430 let g = |t: &str| ghost(t, t.len(), None);
431 assert_eq!(g("PMT(").as_deref(), Some("rate, nper, pv, [fv], [type])"));
432 assert_eq!(g("PMT(0.05, ").as_deref(), Some("nper, pv, [fv], [type])"));
433 assert_eq!(g("PMT(0.05,").as_deref(), Some(" nper, pv, [fv], [type])"));
434 assert_eq!(g("PMT(0.05").as_deref(), Some(", nper, pv, [fv], [type])"));
435 assert_eq!(g("PMT(1, 2, 3, ").as_deref(), Some("[fv], [type])"));
437 assert_eq!(g("PMT(1, 2, 3, 4, 5").as_deref(), Some(")"));
438 assert_eq!(g("CONCAT(").as_deref(), Some("text1, …)"));
440 assert_eq!(g("CONCAT(a, b, ").as_deref(), Some("…)"));
441 assert_eq!(g("CONCAT(a, b").as_deref(), Some(", …)"));
442 assert_eq!(g("PMT(ABS(").as_deref(), Some("number)"));
444 assert_eq!(
445 g("PMT(ABS(1), ").as_deref(),
446 Some("nper, pv, [fv], [type])")
447 );
448 assert_eq!(g("Price + 1"), None);
449 assert_eq!(ghost("PMT(", 2, None), None, "caret not at the end");
450 }
451
452 #[test]
453 fn accepting_replaces_the_word_and_places_the_caret() {
454 let m = model();
455 assert_eq!(accept("PM", 2, &func("PMT")), ("PMT(".into(), 4));
456 assert_eq!(
457 accept("1 + pm", 6, &func("PMT")),
458 ("1 + PMT(".into(), 8),
459 "the typed case is replaced"
460 );
461 assert_eq!(accept("PM(1, 2)", 2, &func("PMT")), ("PMT(1, 2)".into(), 4));
463 let price = &candidates(&m, "Pr")[0];
465 assert_eq!(accept("Pr * 2", 2, price), ("Price * 2".into(), 5));
466 assert_eq!(accept("Prxx * 2", 2, price), ("Price * 2".into(), 5));
467 let unit = &candidates(&m, "'un")[0];
468 assert_eq!(accept("1 + 'un", 7, unit), ("1 + 'Unit Cost'".into(), 15));
469 assert_eq!(accept("1 + Un", 6, unit), ("1 + 'Unit Cost'".into(), 15));
470 }
471
472 #[test]
473 fn short_docs_drop_a_spelled_call() {
474 let d = |n: &str| functions::by_name(n).next().expect(n);
475 assert_eq!(short_doc(d("PMT")), "payment per period");
476 assert_eq!(short_doc(d("ABS")), d("ABS").doc, "nothing to drop");
477 }
478
479 #[test]
480 fn the_popup_selects_wraps_and_stays_closed_until_an_edit() {
481 let mut p = Popup::default();
482 assert!(p.sync("PM", "PM", 3));
483 p.step(1, 3);
484 p.step(1, 3);
485 assert_eq!(p.sel, 2);
486 p.step(1, 3);
487 assert_eq!(p.sel, 0, "wraps");
488 p.step(-1, 3);
489 assert_eq!(p.sel, 2);
490 assert!(p.sync("PM", "PM", 2) && p.sel == 1, "clamped to the list");
491 assert!(
492 p.sync("PMT", "PMT", 2) && p.sel == 0,
493 "a new word starts over"
494 );
495 p.dismiss("PMT");
496 assert!(!p.sync("PMT", "PMT", 2), "Esc closed it");
497 assert!(!p.sync("PMT ", "", 0), "nothing for a finished word");
498 assert!(p.sync("PMTx", "PMTx", 1), "an edit reopens it");
499 assert!(!p.sync("1", "", 0), "nothing to offer");
500 }
501
502 #[test]
503 fn hint_parts_mark_the_current_argument() {
504 let d = |n: &str| functions::by_name(n).next().expect(n);
505 let parts = |n: &str, a| {
506 let (b, c, e) = hint_parts(n, d(n), a);
507 format!("{b}<{c}>{e}")
508 };
509 assert_eq!(parts("PMT", Some(0)), "PMT(<rate>, nper, pv, [fv], [type])");
510 assert_eq!(parts("PMT", Some(3)), "PMT(rate, nper, pv, <[fv]>, [type])");
511 assert_eq!(parts("PMT", Some(9)), "PMT(rate, nper, pv, [fv], [type])<>");
512 assert_eq!(parts("PMT", None), "PMT(rate, nper, pv, [fv], [type])<>");
513 assert_eq!(parts("CONCAT", Some(4)), "CONCAT(text1, <…>)");
514 assert_eq!(parts("PI", Some(0)), "PI()<>");
515 }
516}