1use crate::edit::LineEdit;
5use improv_core_model::{functions, Model};
6
7fn ident_char(c: char) -> bool {
9 c.is_alphanumeric() || c == '_' || c == '.'
10}
11
12pub fn word_start(text: &str, caret: usize) -> usize {
14 text[..caret]
15 .char_indices()
16 .rev()
17 .take_while(|&(_, c)| ident_char(c))
18 .last()
19 .map_or(caret, |(i, _)| i)
20}
21
22pub fn source_name(name: &str) -> String {
24 let mut chars = name.chars();
25 let bare = chars.next().is_some_and(|c| c.is_alphabetic() || c == '_')
26 && chars.all(|c| c.is_alphanumeric() || c == '_')
27 && !["NOT", "AND", "OR", "OVER", "TRUE", "FALSE"]
28 .iter()
29 .any(|k| name.eq_ignore_ascii_case(k));
30 if bare {
31 name.to_string()
32 } else {
33 format!("'{}'", name.replace('\'', "''"))
34 }
35}
36
37pub fn candidates(model: &Model, prefix: &str) -> Vec<String> {
40 if prefix.is_empty() {
41 return Vec::new();
42 }
43 let p = prefix.to_lowercase();
44 let mut names: Vec<&str> = model
45 .measures
46 .values()
47 .map(|m| m.name.0.as_str())
48 .chain(model.categories.values().map(|c| c.name.0.as_str()))
49 .filter(|n| n.to_lowercase().starts_with(&p))
50 .collect();
51 names.sort_unstable();
52 names.dedup();
53 let mut out: Vec<String> = names.into_iter().map(source_name).collect();
54 out.extend(
55 functions::complete(prefix)
56 .iter()
57 .map(|(n, _)| format!("{n}(")),
58 );
59 out
60}
61
62fn common_prefix(cands: &[String]) -> &str {
65 let first = &cands[0];
66 let mut end = first.len();
67 for c in &cands[1..] {
68 end = first
69 .char_indices()
70 .zip(c.chars())
71 .find(|((_, a), b)| !a.eq_ignore_ascii_case(b))
72 .map_or(end.min(c.len()), |((i, _), _)| end.min(i));
73 }
74 &first[..end]
75}
76
77#[derive(Debug, Clone, PartialEq)]
80pub struct Cycle {
81 start: usize,
82 cands: Vec<String>,
83 shown: Option<usize>,
84}
85
86pub fn tab(model: &Model, e: &mut LineEdit, cycle: &mut Option<Cycle>) -> Option<String> {
91 if let Some(c) = cycle {
92 let i = c.shown.map_or(0, |i| (i + 1) % c.cands.len());
93 c.shown = Some(i);
94 e.replace(c.start, &c.cands[i]);
95 return Some(listing(&c.cands));
96 }
97 let start = word_start(&e.text, e.caret);
98 let prefix = &e.text[start..e.caret];
99 if prefix.is_empty() {
100 return None;
101 }
102 let cands = candidates(model, prefix);
103 match cands.len() {
104 0 => Some(format!("no completions for '{prefix}'")),
105 1 => {
106 e.replace(start, &cands[0]);
107 None
108 }
109 _ => {
110 let common = common_prefix(&cands);
111 if common != prefix {
112 e.replace(start, common);
113 }
114 let msg = listing(&cands);
115 *cycle = Some(Cycle {
116 start,
117 cands,
118 shown: None,
119 });
120 Some(msg)
121 }
122 }
123}
124
125fn listing(cands: &[String]) -> String {
126 format!("Tab cycles: {}", cands.join(" "))
127}
128
129pub fn ghost(text: &str, caret: usize) -> Option<String> {
133 if caret != text.len() {
134 return None;
135 }
136 let (def, arg) = functions::call_at(text, caret)?;
137 let names = def.param_names();
138 let typing = !text.trim_end().ends_with(['(', ',', ';']);
139 let rest = names.get(arg + usize::from(typing)..).unwrap_or_default();
140 let mut out = rest.join(", ");
141 if typing && !rest.is_empty() {
142 out.insert_str(0, ", ");
143 }
144 out.push(')');
145 Some(out)
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::app::tests::revenue_model;
152
153 fn tab_in(model: &Model, text: &str, cycle: &mut Option<Cycle>) -> (String, Option<String>) {
154 let mut e = LineEdit::from(text);
155 let msg = tab(model, &mut e, cycle);
156 (e.text, msg)
157 }
158
159 #[test]
160 fn word_before_the_caret() {
161 assert_eq!(word_start("Price * Qua", 11), 8);
162 assert_eq!(word_start("FLOOR.MA", 8), 0);
163 assert_eq!(word_start("1 + ", 4), 4);
164 assert_eq!(word_start("é", 2), 0);
165 assert_eq!(word_start("1 + net_rev", 11), 4);
166 }
167
168 #[test]
169 fn candidates_are_model_names_then_functions() {
170 let m = revenue_model();
171 let c = candidates(&m, "pr");
172 assert_eq!(&c[..2], ["Price", "Product"]);
173 assert!(c.contains(&"PRODUCT(".to_string()));
174 assert!(candidates(&m, "").is_empty());
175 assert_eq!(source_name("Unit Price"), "'Unit Price'");
176 assert_eq!(source_name("it's"), "'it''s'");
177 assert_eq!(source_name("Over"), "'Over'");
178 assert_eq!(source_name("_tmp"), "_tmp");
179 assert_eq!(source_name("2024"), "'2024'");
180 assert_eq!(source_name(""), "''");
181 }
182
183 #[test]
184 fn tab_completes_extends_and_cycles() {
185 let m = revenue_model();
186 let mut cy = None;
187 assert_eq!(tab_in(&m, "1 + pm", &mut cy), ("1 + PMT(".into(), None));
189 assert_eq!(tab_in(&m, "Price * Quan", &mut cy).0, "Price * Quantity");
190 assert!(cy.is_none());
191 assert_eq!(tab_in(&m, "1 + ", &mut cy), ("1 + ".into(), None));
193 let (t, msg) = tab_in(&m, "zzq", &mut cy);
194 assert_eq!(
195 (t.as_str(), msg.unwrap()),
196 ("zzq", "no completions for 'zzq'".into())
197 );
198 let mut e = LineEdit::from("sumx");
200 let msg = tab(&m, &mut e, &mut cy).unwrap();
201 assert_eq!(e.text, "SUMX");
202 assert!(msg.contains("SUMX2MY2("), "{msg}");
203 assert!(msg.contains("SUMX2PY2("), "{msg}");
204 let mut cy2 = None;
206 let (t, msg) = tab_in(&m, "SUMX", &mut cy2);
207 assert_eq!(t, "SUMX");
208 assert!(msg.unwrap().starts_with("Tab cycles: SUMXMY2("));
209 assert!(cy2.is_some());
210 let mut seen = Vec::new();
212 for _ in 0..4 {
213 tab(&m, &mut e, &mut cy);
214 seen.push(e.text.clone());
215 }
216 assert_eq!(seen, ["SUMXMY2(", "SUMX2MY2(", "SUMX2PY2(", "SUMXMY2("]);
217 let mut cy = None;
219 assert_eq!(tab_in(&m, "pr", &mut cy).0, "Pr");
220 assert_eq!(common_prefix(&["PMT(".into(), "pmtx".into()]), "PMT");
221 assert_eq!(common_prefix(&["ab".into(), "abc".into()]), "ab");
222 }
223
224 #[test]
225 fn ghost_text_is_the_parameters_left() {
226 assert_eq!(ghost("PMT(", 4).unwrap(), "rate, nper, pv, [fv], [type])");
227 assert_eq!(ghost("PMT(1, ", 7).unwrap(), "nper, pv, [fv], [type])");
228 assert_eq!(ghost("PMT(1, 2", 8).unwrap(), ", pv, [fv], [type])");
229 assert_eq!(ghost("PMT(1,2,3,4,5", 13).unwrap(), ")");
230 assert_eq!(ghost("PI(", 3).unwrap(), ")");
231 assert_eq!(ghost("PMT(1", 2), None, "caret not at the end");
232 assert_eq!(ghost("Price", 5), None, "not in a call");
233 }
234}