Skip to main content

runex_core/
expand.rs

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/// A single skipped rule — part of the `which_abbr` trace.
11#[derive(Debug, Clone, PartialEq, Serialize)]
12#[serde(tag = "reason", rename_all = "snake_case")]
13pub enum SkipReason {
14    /// key == expand (self-loop guard).
15    SelfLoop,
16    /// One or more `when_command_exists` commands were absent.
17    ConditionFailed {
18        found_commands: Vec<String>,
19        missing_commands: Vec<String>,
20    },
21    /// No expand entry for this shell (and no default).
22    NoShellEntry,
23}
24
25/// Result of a `which` lookup — mirrors `expand()` scan order exactly.
26///
27/// `skipped` contains every rule that matched the key but was bypassed,
28/// in the same order `expand()` would skip them. This ensures `which_abbr`
29/// and `expand` agree on the final outcome even with duplicate-key rules.
30#[derive(Debug, Clone, PartialEq, Serialize)]
31#[serde(tag = "result", rename_all = "snake_case")]
32pub enum WhichResult {
33    /// Token matched a rule and all conditions passed.
34    Expanded {
35        key: String,
36        expansion: String,
37        rule_index: usize,
38        /// Commands that were checked via `when_command_exists` and passed.
39        satisfied_conditions: Vec<String>,
40        /// Earlier rules with the same key that were skipped before this one.
41        skipped: Vec<(usize, SkipReason)>,
42    },
43    /// Every matching rule was skipped; here is why each one was bypassed.
44    AllSkipped {
45        token: String,
46        skipped: Vec<(usize, SkipReason)>,
47    },
48    /// No rule had this key at all.
49    NoMatch { token: String },
50}
51
52/// Expand a token using the config.
53///
54/// `shell` selects the per-shell expand/when_command_exists entry.
55/// `command_exists` is injected for testability (DI).
56pub 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; // no entry for this shell → skip
66        };
67        if abbr.key == expansion {
68            continue; // self-loop
69        }
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; // no when_command_exists entry for this shell → skip
78            }
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
87/// Extract cursor placeholder `{}` from expansion text.
88/// Returns the text with `{}` removed and the byte offset where it was.
89fn 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
100/// Like [`expand`], but records timing data into `timings`.
101///
102/// Each `command_exists` call is individually timed, and the overall expand
103/// phase is recorded as a single phase entry.
104pub 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            // Heuristic: if the lookup completed in under 100us, it was likely a cache hit.
126            // A real which::which() call takes ~9ms on typical systems.
127            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
142/// Look up a token and return why it expands (or doesn't).
143///
144/// Scans rules in the same order as `expand()`, collecting skip reasons for
145/// every bypassed rule before returning the first one that passes. This means
146/// `which_abbr` and `expand` always agree on the final outcome, even when
147/// multiple rules share the same key.
148pub 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
222/// List abbreviations as (key, expand) pairs.
223///
224/// When `shell` is `Some`, returns only rules that have an entry for that shell,
225/// using the resolved expansion string.
226/// When `shell` is `None`, uses the `All` value or the `default` field.
227pub 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    // ── existing tests (updated signatures) ────────────────────────────────
287
288    #[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    // ── per-shell expand tests ──────────────────────────────────────────────
412
413    #[test]
414    fn expand_per_shell_pwsh_uses_pwsh_expand() {
415        // key="7z", default="7zip", pwsh="7z.exe" — no self-loop on any shell
416        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        // No entry for bash/default → pass-through
445        assert_eq!(
446            expand(&c, "7z", Shell::Bash, |_| true),
447            ExpandResult::PassThrough("7z".into())
448        );
449        // pwsh has an entry → expands
450        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        // "7z" has default so shows; "pwsh-only" has no bash/default → filtered out
498        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    // ── expand_timed tests ──────────────────────────────────────────────
511
512    #[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    // ── cursor placeholder tests ────────────────────────────────────────
545
546    #[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), // position between the quotes
555            }
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}