1use improv_core_model::functions::{self, Category, FnDef};
6
7pub fn category_title(c: Category) -> &'static str {
9 match c {
10 Category::Aggregation => "Aggregations",
11 Category::Logical => "Logical",
12 Category::Information => "Information and errors",
13 Category::Math => "Math and trigonometry",
14 Category::Rounding => "Rounding",
15 Category::Text => "Text",
16 Category::DateTime => "Date and time",
17 Category::Statistical => "Statistical",
18 Category::Financial => "Financial",
19 Category::Dimensional => "Dimensional",
20 Category::Engineering => "Engineering",
21 }
22}
23
24pub type Group = (Category, Vec<&'static FnDef>);
26
27pub fn groups(query: &str) -> Vec<Group> {
31 let mut out: Vec<Group> = Vec::new();
32 for d in functions::search(query) {
33 match out.iter_mut().find(|(c, _)| *c == d.category) {
34 Some((_, fs)) => fs.push(d),
35 None => out.push((d.category, vec![d])),
36 }
37 }
38 if query.trim().is_empty() {
39 out.sort_by_key(|(c, _)| *c);
40 }
41 out
42}
43
44pub fn aliases(d: &FnDef) -> Option<String> {
46 (!d.aliases.is_empty()).then(|| format!("also {}", d.aliases.join(", ")))
47}
48
49pub fn insert_call(text: &str, caret: usize, name: &str) -> (String, usize) {
53 let caret = caret.min(text.len());
54 let caret = (0..=caret)
55 .rev()
56 .find(|&i| text.is_char_boundary(i))
57 .unwrap_or(0);
58 let word = text[..caret]
59 .char_indices()
60 .rev()
61 .take_while(|&(_, c)| c.is_alphanumeric() || c == '_' || c == '.')
62 .last()
63 .map_or(caret, |(i, _)| i);
64 let typed = &text[word..caret];
65 let start = if name.len() >= typed.len() && name[..typed.len()].eq_ignore_ascii_case(typed) {
66 word
67 } else {
68 caret
69 };
70 let after = &text[caret..];
71 let paren = if after.starts_with('(') { "" } else { "(" };
72 let out = format!("{}{name}{paren}{after}", &text[..start]);
73 (out, start + name.len() + 1)
74}
75
76#[derive(Default)]
79pub struct Browser {
80 pub open: bool,
81 pub query: String,
82 pub selected: Option<&'static FnDef>,
83 pub focus_search: bool,
85 results: Option<(String, Vec<Group>)>,
87}
88
89impl Browser {
90 pub fn toggle(&mut self) {
92 self.open = !self.open;
93 self.focus_search = self.open;
94 }
95
96 pub fn groups(&mut self) -> &[Group] {
99 if self.results.as_ref().is_none_or(|(q, _)| *q != self.query) {
100 let g = groups(&self.query);
101 let kept = self
102 .selected
103 .filter(|s| g.iter().any(|(_, fs)| fs.iter().any(|d| d.id == s.id)));
104 self.selected = kept.or_else(|| g.first().map(|(_, fs)| fs[0]));
105 self.results = Some((self.query.clone(), g));
106 }
107 &self.results.as_ref().expect("just set").1
108 }
109
110 pub fn step(&mut self, delta: isize) {
112 let sel = self.selected.map(|s| s.id);
113 let all: Vec<&'static FnDef> = self
114 .groups()
115 .iter()
116 .flat_map(|(_, fs)| fs.iter().copied())
117 .collect();
118 let Some(last) = all.len().checked_sub(1) else {
119 return;
120 };
121 let at = all.iter().position(|d| Some(d.id) == sel).unwrap_or(0);
122 let to = at.saturating_add_signed(delta).min(last);
123 self.selected = Some(all[to]);
124 }
125}
126
127#[cfg(test)]
128#[cfg_attr(coverage_nightly, coverage(off))]
129mod tests {
130 use super::*;
131
132 fn names(g: &[Group]) -> Vec<(Category, Vec<&str>)> {
133 g.iter()
134 .map(|(c, fs)| (*c, fs.iter().map(|d| d.name).collect()))
135 .collect()
136 }
137
138 #[test]
139 fn results_are_grouped_by_category() {
140 let all = groups("");
141 let cats: Vec<Category> = all.iter().map(|(c, _)| *c).collect();
142 let mut sorted = cats.clone();
143 sorted.sort();
144 sorted.dedup();
145 assert_eq!(cats, sorted, "every category once, in order");
146 let n: usize = all.iter().map(|(_, fs)| fs.len()).sum();
147 assert_eq!(n, functions::search("").len(), "every function once");
148 for (c, fs) in &all {
149 assert!(fs.iter().all(|d| d.category == *c));
150 assert!(fs.windows(2).all(|w| w[0].name <= w[1].name), "{c:?}");
151 }
152 }
153
154 #[test]
155 fn a_query_filters_and_its_best_match_leads() {
156 let g = groups("pmt");
157 let (cat, fs) = &names(&g)[0];
158 assert_eq!(*cat, Category::Financial);
159 assert_eq!(fs[0], "PMT", "the exact name first");
160 assert!(fs.contains(&"IPMT") && fs.contains(&"PPMT"), "{fs:?}");
161 let g = groups("avg");
163 assert_eq!(g[0].1[0].name, "AVERAGE");
164 assert!(groups("payment")
166 .iter()
167 .any(|(_, fs)| fs.iter().any(|d| d.name == "PMT")));
168 assert!(groups("no such thing at all").is_empty());
169 }
170
171 #[test]
172 fn the_browser_keeps_or_moves_its_selection() {
173 let mut b = Browser::default();
174 b.toggle();
175 assert!(b.open && b.focus_search);
176 b.query = "pmt".into();
177 assert_eq!(b.groups()[0].1[0].name, "PMT");
178 assert_eq!(b.selected.map(|d| d.name), Some("PMT"));
179 let second = b.groups()[0].1[1].name;
180 b.step(1);
181 assert_eq!(b.selected.map(|d| d.name), Some(second));
182 b.step(-5);
183 assert_eq!(b.selected.map(|d| d.name), Some("PMT"), "clamped");
184 b.step(1);
185 b.query = second.to_lowercase();
187 let _ = b.groups();
188 assert_eq!(b.selected.map(|d| d.name), Some(second));
189 b.query = "abs".into();
190 assert_eq!(b.groups()[0].1[0].name, "ABS");
191 assert_eq!(b.selected.map(|d| d.name), Some("ABS"));
192 b.query = "zzzzzzzz".into();
193 assert!(b.groups().is_empty());
194 assert!(b.selected.is_none());
195 b.step(1);
196 assert!(b.selected.is_none());
197 b.toggle();
198 assert!(!b.open && !b.focus_search);
199 }
200
201 #[test]
202 fn inserting_a_call_at_the_caret() {
203 assert_eq!(insert_call("", 0, "PMT"), ("PMT(".into(), 4));
204 assert_eq!(insert_call("1 + ", 4, "PMT"), ("1 + PMT(".into(), 8));
205 assert_eq!(
206 insert_call("1 + * 2", 4, "ABS"),
207 ("1 + ABS( * 2".into(), 8)
208 );
209 assert_eq!(insert_call("1 + pm", 6, "PMT"), ("1 + PMT(".into(), 8));
211 assert_eq!(insert_call("Price", 5, "ABS"), ("PriceABS(".into(), 9));
212 assert_eq!(insert_call("PM(1)", 2, "PMT"), ("PMT(1)".into(), 4));
214 assert_eq!(insert_call("é", 1, "PI"), ("PI(é".into(), 3));
216 assert_eq!(insert_call("x", 99, "PI"), ("xPI(".into(), 4));
217 }
218
219 #[test]
220 fn titles_and_aliases() {
221 assert_eq!(category_title(Category::DateTime), "Date and time");
222 let avg = functions::by_name("AVERAGE").find(|d| !d.aliases.is_empty());
223 assert!(aliases(avg.expect("AVERAGE has an alias")).is_some_and(|a| a.starts_with("also ")));
224 let pmt = functions::by_name("PMT").next().expect("PMT");
225 assert_eq!(aliases(pmt), None);
226 }
227}