1use serde::Serialize;
2
3use std::cell::RefCell;
4use std::time::Instant;
5
6use crate::model::{Config, ExpandResult};
7use crate::shell::Shell;
8use crate::timings::{CommandExistsCall, Timings};
9
10#[derive(Debug, Clone, PartialEq, Serialize)]
12#[serde(tag = "reason", rename_all = "snake_case")]
13pub enum SkipReason {
14 SelfLoop,
16 ConditionFailed {
18 found_commands: Vec<String>,
19 missing_commands: Vec<String>,
20 },
21 NoShellEntry,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize)]
31#[serde(tag = "result", rename_all = "snake_case")]
32pub enum WhichResult {
33 Expanded {
35 key: String,
36 expansion: String,
37 rule_index: usize,
38 satisfied_conditions: Vec<String>,
40 skipped: Vec<(usize, SkipReason)>,
42 },
43 AllSkipped {
45 token: String,
46 skipped: Vec<(usize, SkipReason)>,
47 },
48 NoMatch { token: String },
50}
51
52pub fn expand<F>(config: &Config, token: &str, shell: Shell, command_exists: F) -> ExpandResult
57where
58 F: Fn(&str) -> bool,
59{
60 for abbr in &config.abbr {
61 if abbr.key != token {
62 continue;
63 }
64 let Some(expansion) = abbr.expand.for_shell(shell) else {
65 continue; };
67 if abbr.key == expansion {
68 continue; }
70 if let Some(cmds) = &abbr.when_command_exists {
71 let shell_cmds = cmds.for_shell(shell);
72 if let Some(list) = shell_cmds {
73 if !list.iter().all(|c| command_exists(c)) {
74 continue;
75 }
76 } else {
77 continue; }
79 }
80 let text = expansion.to_string();
81 let (text, cursor_offset) = extract_cursor_placeholder(&text);
82 return ExpandResult::Expanded { text, cursor_offset };
83 }
84 ExpandResult::PassThrough(token.to_string())
85}
86
87fn extract_cursor_placeholder(text: &str) -> (String, Option<usize>) {
90 if let Some(pos) = text.find(crate::model::CURSOR_PLACEHOLDER) {
91 let mut result = String::with_capacity(text.len() - 2);
92 result.push_str(&text[..pos]);
93 result.push_str(&text[pos + 2..]);
94 (result, Some(pos))
95 } else {
96 (text.to_string(), None)
97 }
98}
99
100pub fn expand_timed<F>(
105 config: &Config,
106 token: &str,
107 shell: Shell,
108 command_exists: F,
109 timings: &mut Timings,
110) -> ExpandResult
111where
112 F: Fn(&str) -> bool,
113{
114 let calls: RefCell<Vec<CommandExistsCall>> = RefCell::new(Vec::new());
115 let timer = Instant::now();
116
117 let timed_exists = |cmd: &str| -> bool {
118 let t = Instant::now();
119 let found = command_exists(cmd);
120 let elapsed = t.elapsed();
121 calls.borrow_mut().push(CommandExistsCall {
122 command: cmd.to_string(),
123 found,
124 duration: elapsed,
125 cached: elapsed.as_micros() < 100,
128 });
129 found
130 };
131
132 let result = expand(config, token, shell, timed_exists);
133 timings.record_phase("expand", timer.elapsed());
134
135 for call in calls.into_inner() {
136 timings.record_command_exists(&call.command, call.found, call.duration, call.cached);
137 }
138
139 result
140}
141
142pub fn which_abbr<F>(config: &Config, token: &str, shell: Shell, command_exists: F) -> WhichResult
149where
150 F: Fn(&str) -> bool,
151{
152 let mut skipped: Vec<(usize, SkipReason)> = Vec::new();
153 let mut any_key_matched = false;
154
155 for (i, abbr) in config.abbr.iter().enumerate() {
156 if abbr.key != token {
157 continue;
158 }
159 any_key_matched = true;
160
161 let Some(expansion) = abbr.expand.for_shell(shell) else {
162 skipped.push((i, SkipReason::NoShellEntry));
163 continue;
164 };
165
166 if abbr.key == expansion {
167 skipped.push((i, SkipReason::SelfLoop));
168 continue;
169 }
170
171 if let Some(cmds) = &abbr.when_command_exists {
172 match cmds.for_shell(shell) {
173 None => {
174 skipped.push((i, SkipReason::NoShellEntry));
175 continue;
176 }
177 Some(list) => {
178 let (found, missing): (Vec<String>, Vec<String>) =
179 list.iter().cloned().partition(|c| command_exists(c));
180 if !missing.is_empty() {
181 skipped.push((
182 i,
183 SkipReason::ConditionFailed {
184 found_commands: found,
185 missing_commands: missing,
186 },
187 ));
188 continue;
189 }
190 return WhichResult::Expanded {
191 key: abbr.key.clone(),
192 expansion: expansion.to_string(),
193 rule_index: i,
194 satisfied_conditions: list.to_vec(),
195 skipped,
196 };
197 }
198 }
199 }
200
201 return WhichResult::Expanded {
202 key: abbr.key.clone(),
203 expansion: expansion.to_string(),
204 rule_index: i,
205 satisfied_conditions: Vec::new(),
206 skipped,
207 };
208 }
209
210 if any_key_matched {
211 WhichResult::AllSkipped {
212 token: token.to_string(),
213 skipped,
214 }
215 } else {
216 WhichResult::NoMatch {
217 token: token.to_string(),
218 }
219 }
220}
221
222pub fn list<'a>(config: &'a Config, shell: Option<Shell>) -> Vec<(&'a str, String)> {
228 config
229 .abbr
230 .iter()
231 .filter_map(|a| {
232 let exp = match shell {
233 Some(sh) => a.expand.for_shell(sh)?.to_string(),
234 None => match &a.expand {
235 crate::model::PerShellString::All(s) => s.clone(),
236 crate::model::PerShellString::ByShell { default, .. } => {
237 default.as_deref()?.to_string()
238 }
239 },
240 };
241 Some((a.key.as_str(), exp))
242 })
243 .collect()
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::model::{Abbr, Config, PerShellCmds, PerShellString};
250
251 fn cfg(abbrs: Vec<Abbr>) -> Config {
252 Config {
253 version: 1,
254 keybind: crate::model::KeybindConfig::default(),
255 precache: crate::model::PrecacheConfig::default(),
256 abbr: abbrs,
257 }
258 }
259
260 fn abbr(key: &str, expand: &str) -> Abbr {
261 Abbr {
262 key: key.into(),
263 expand: PerShellString::All(expand.into()),
264 when_command_exists: None,
265 }
266 }
267
268 fn abbr_when(key: &str, exp: &str, cmds: Vec<&str>) -> Abbr {
269 Abbr {
270 key: key.into(),
271 expand: PerShellString::All(exp.into()),
272 when_command_exists: Some(PerShellCmds::All(
273 cmds.into_iter().map(String::from).collect(),
274 )),
275 }
276 }
277
278 fn abbr_pershell_expand(key: &str, expand: PerShellString) -> Abbr {
279 Abbr {
280 key: key.into(),
281 expand,
282 when_command_exists: None,
283 }
284 }
285
286 #[test]
289 fn match_expands() {
290 let c = cfg(vec![abbr("gcm", "git commit -m")]);
291 assert_eq!(
292 expand(&c, "gcm", Shell::Bash, |_| true),
293 ExpandResult::Expanded { text: "git commit -m".into(), cursor_offset: None }
294 );
295 }
296
297 #[test]
298 fn no_match_passes_through() {
299 let c = cfg(vec![abbr("gcm", "git commit -m")]);
300 assert_eq!(
301 expand(&c, "xyz", Shell::Bash, |_| true),
302 ExpandResult::PassThrough("xyz".into())
303 );
304 }
305
306 #[test]
307 fn selects_correct_abbr() {
308 let c = cfg(vec![abbr("gcm", "git commit -m"), abbr("gp", "git push")]);
309 assert_eq!(
310 expand(&c, "gp", Shell::Bash, |_| true),
311 ExpandResult::Expanded { text: "git push".into(), cursor_offset: None }
312 );
313 }
314
315 #[test]
316 fn key_eq_expand_passes_through() {
317 let c = cfg(vec![abbr("ls", "ls")]);
318 assert_eq!(
319 expand(&c, "ls", Shell::Bash, |_| true),
320 ExpandResult::PassThrough("ls".into())
321 );
322 }
323
324 #[test]
325 fn when_command_exists_present() {
326 let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
327 assert_eq!(
328 expand(&c, "ls", Shell::Bash, |_| true),
329 ExpandResult::Expanded { text: "lsd".into(), cursor_offset: None }
330 );
331 }
332
333 #[test]
334 fn when_command_exists_absent() {
335 let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
336 assert_eq!(
337 expand(&c, "ls", Shell::Bash, |_| false),
338 ExpandResult::PassThrough("ls".into())
339 );
340 }
341
342 #[test]
343 fn duplicate_key_self_loop_then_real_expands() {
344 let c = cfg(vec![abbr("ls", "ls"), abbr("ls", "lsd")]);
345 assert_eq!(
346 expand(&c, "ls", Shell::Bash, |_| true),
347 ExpandResult::Expanded { text: "lsd".into(), cursor_offset: None }
348 );
349 }
350
351 #[test]
352 fn duplicate_key_failed_condition_then_real_expands() {
353 let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"]), abbr("ls", "ls2")]);
354 assert_eq!(
355 expand(&c, "ls", Shell::Bash, |_| false),
356 ExpandResult::Expanded { text: "ls2".into(), cursor_offset: None }
357 );
358 }
359
360 #[test]
361 fn which_abbr_duplicate_self_loop_then_expanded() {
362 let c = cfg(vec![abbr("ls", "ls"), abbr("ls", "lsd")]);
363 let result = which_abbr(&c, "ls", Shell::Bash, |_| true);
364 match result {
365 WhichResult::Expanded { expansion, skipped, .. } => {
366 assert_eq!(expansion, "lsd");
367 assert_eq!(skipped.len(), 1);
368 assert_eq!(skipped[0].0, 0);
369 assert!(matches!(skipped[0].1, SkipReason::SelfLoop));
370 }
371 other => panic!("expected Expanded, got {other:?}"),
372 }
373 }
374
375 #[test]
376 fn which_abbr_all_skipped_returns_all_skipped() {
377 let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
378 let result = which_abbr(&c, "ls", Shell::Bash, |_| false);
379 match result {
380 WhichResult::AllSkipped { skipped, .. } => {
381 assert_eq!(skipped.len(), 1);
382 assert!(matches!(
383 &skipped[0].1,
384 SkipReason::ConditionFailed { missing_commands, .. }
385 if missing_commands == &["lsd"]
386 ));
387 }
388 other => panic!("expected AllSkipped, got {other:?}"),
389 }
390 }
391
392 #[test]
393 fn which_abbr_no_match() {
394 let c = cfg(vec![abbr("gcm", "git commit -m")]);
395 assert!(matches!(
396 which_abbr(&c, "xyz", Shell::Bash, |_| true),
397 WhichResult::NoMatch { .. }
398 ));
399 }
400
401 #[test]
402 fn list_returns_all_pairs() {
403 let c = cfg(vec![abbr("gcm", "git commit -m"), abbr("gp", "git push")]);
404 let pairs = list(&c, None);
405 assert_eq!(
406 pairs,
407 vec![("gcm", "git commit -m".to_string()), ("gp", "git push".to_string())]
408 );
409 }
410
411 #[test]
414 fn expand_per_shell_pwsh_uses_pwsh_expand() {
415 let c = cfg(vec![abbr_pershell_expand(
417 "7z",
418 PerShellString::ByShell {
419 default: Some("7zip".into()),
420 pwsh: Some("7z.exe".into()),
421 bash: None, zsh: None, nu: None,
422 },
423 )]);
424 assert_eq!(
425 expand(&c, "7z", Shell::Pwsh, |_| true),
426 ExpandResult::Expanded { text: "7z.exe".into(), cursor_offset: None }
427 );
428 assert_eq!(
429 expand(&c, "7z", Shell::Bash, |_| true),
430 ExpandResult::Expanded { text: "7zip".into(), cursor_offset: None }
431 );
432 }
433
434 #[test]
435 fn expand_per_shell_skips_when_no_shell_entry() {
436 let c = cfg(vec![abbr_pershell_expand(
437 "7z",
438 PerShellString::ByShell {
439 default: None,
440 pwsh: Some("7z.exe".into()),
441 bash: None, zsh: None, nu: None,
442 },
443 )]);
444 assert_eq!(
446 expand(&c, "7z", Shell::Bash, |_| true),
447 ExpandResult::PassThrough("7z".into())
448 );
449 assert_eq!(
451 expand(&c, "7z", Shell::Pwsh, |_| true),
452 ExpandResult::Expanded { text: "7z.exe".into(), cursor_offset: None }
453 );
454 }
455
456 #[test]
457 fn which_abbr_no_shell_entry_is_skipped() {
458 let c = cfg(vec![abbr_pershell_expand(
459 "7z",
460 PerShellString::ByShell {
461 default: None,
462 pwsh: Some("7z.exe".into()),
463 bash: None, zsh: None, nu: None,
464 },
465 )]);
466 let result = which_abbr(&c, "7z", Shell::Bash, |_| true);
467 match result {
468 WhichResult::AllSkipped { skipped, .. } => {
469 assert_eq!(skipped.len(), 1);
470 assert!(matches!(skipped[0].1, SkipReason::NoShellEntry));
471 }
472 other => panic!("expected AllSkipped, got {other:?}"),
473 }
474 }
475
476 #[test]
477 fn list_with_shell_filters_per_shell() {
478 let c = cfg(vec![
479 abbr_pershell_expand(
480 "7z",
481 PerShellString::ByShell {
482 default: Some("7zip".into()),
483 pwsh: Some("7z.exe".into()),
484 bash: None, zsh: None, nu: None,
485 },
486 ),
487 abbr_pershell_expand(
488 "pwsh-only",
489 PerShellString::ByShell {
490 default: None,
491 pwsh: Some("pwsh-cmd".into()),
492 bash: None, zsh: None, nu: None,
493 },
494 ),
495 ]);
496 let bash_list = list(&c, Some(Shell::Bash));
497 assert_eq!(bash_list, vec![("7z", "7zip".to_string())]);
499
500 let pwsh_list = list(&c, Some(Shell::Pwsh));
501 assert_eq!(
502 pwsh_list,
503 vec![
504 ("7z", "7z.exe".to_string()),
505 ("pwsh-only", "pwsh-cmd".to_string()),
506 ]
507 );
508 }
509
510 #[test]
513 fn expand_timed_same_result_as_expand() {
514 let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
515 let mut timings = crate::timings::Timings::new();
516 let result = expand_timed(&c, "ls", Shell::Bash, |_| true, &mut timings);
517 assert_eq!(result, ExpandResult::Expanded { text: "lsd".into(), cursor_offset: None });
518 }
519
520 #[test]
521 fn expand_timed_records_command_exists_calls() {
522 let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
523 let mut timings = crate::timings::Timings::new();
524 expand_timed(&c, "ls", Shell::Bash, |_| true, &mut timings);
525 let calls = timings.command_exists_calls();
526 assert_eq!(calls.len(), 1);
527 assert_eq!(calls[0].command, "lsd");
528 assert!(calls[0].found);
529 }
530
531 #[test]
532 fn expand_timed_records_expand_phase() {
533 let c = cfg(vec![abbr("gcm", "git commit -m")]);
534 let mut timings = crate::timings::Timings::new();
535 expand_timed(&c, "gcm", Shell::Bash, |_| true, &mut timings);
536 let phases = timings.phases();
537 assert!(
538 phases.iter().any(|p| p.name == "expand"),
539 "must record an 'expand' phase, got: {:?}",
540 phases.iter().map(|p| &p.name).collect::<Vec<_>>()
541 );
542 }
543
544 #[test]
547 fn expand_with_cursor_placeholder() {
548 let c = cfg(vec![abbr("gcam", "git commit -am '{}'")] );
549 let result = expand(&c, "gcam", Shell::Bash, |_| true);
550 assert_eq!(
551 result,
552 ExpandResult::Expanded {
553 text: "git commit -am ''".into(),
554 cursor_offset: Some(16), }
556 );
557 }
558
559 #[test]
560 fn expand_without_cursor_placeholder() {
561 let c = cfg(vec![abbr("gcm", "git commit -m")]);
562 let result = expand(&c, "gcm", Shell::Bash, |_| true);
563 assert_eq!(
564 result,
565 ExpandResult::Expanded { text: "git commit -m".into(), cursor_offset: None }
566 );
567 }
568
569 #[test]
570 fn extract_cursor_placeholder_found() {
571 let (text, offset) = extract_cursor_placeholder("git commit -am '{}'");
572 assert_eq!(text, "git commit -am ''");
573 assert_eq!(offset, Some(16));
574 }
575
576 #[test]
577 fn extract_cursor_placeholder_not_found() {
578 let (text, offset) = extract_cursor_placeholder("git commit -m");
579 assert_eq!(text, "git commit -m");
580 assert_eq!(offset, None);
581 }
582
583 #[test]
584 fn extract_cursor_placeholder_at_end() {
585 let (text, offset) = extract_cursor_placeholder("echo {}");
586 assert_eq!(text, "echo ");
587 assert_eq!(offset, Some(5));
588 }
589}