Skip to main content

runex_core/
shell.rs

1use std::fmt;
2use std::str::FromStr;
3
4use crate::model::{Config, TriggerKey};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Shell {
8    Bash,
9    Zsh,
10    Pwsh,
11    Clink,
12    Nu,
13}
14
15impl FromStr for Shell {
16    type Err = ShellParseError;
17
18    fn from_str(s: &str) -> Result<Self, Self::Err> {
19        match s.to_ascii_lowercase().as_str() {
20            "bash" => Ok(Shell::Bash),
21            "zsh" => Ok(Shell::Zsh),
22            "pwsh" => Ok(Shell::Pwsh),
23            "clink" => Ok(Shell::Clink),
24            "nu" => Ok(Shell::Nu),
25            _ => Err(ShellParseError(s.to_string())),
26        }
27    }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ShellParseError(pub String);
32
33impl fmt::Display for ShellParseError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        write!(
36            f,
37            "unknown shell '{}' (expected: bash, zsh, pwsh, clink, nu)",
38            self.0
39        )
40    }
41}
42
43impl std::error::Error for ShellParseError {}
44
45fn trigger_for(shell: Shell, config: Option<&Config>) -> Option<TriggerKey> {
46    let keybind = match config {
47        Some(config) => &config.keybind,
48        None => return None,
49    };
50
51    match shell {
52        Shell::Bash => keybind.bash.or(keybind.trigger),
53        Shell::Zsh => keybind.zsh.or(keybind.trigger),
54        Shell::Pwsh => keybind.pwsh.or(keybind.trigger),
55        Shell::Nu => keybind.nu.or(keybind.trigger),
56        Shell::Clink => keybind.trigger,
57    }
58}
59
60fn bash_chord(trigger: TriggerKey) -> &'static str {
61    match trigger {
62        TriggerKey::Space => "\\x20",
63        TriggerKey::Tab => "\\C-i",
64        TriggerKey::AltSpace => "\\e ",
65    }
66}
67
68fn zsh_chord(trigger: TriggerKey) -> &'static str {
69    match trigger {
70        TriggerKey::Space => " ",
71        TriggerKey::Tab => "^I",
72        TriggerKey::AltSpace => "^[ ",
73    }
74}
75
76fn bash_quote_pattern(token: &str) -> String {
77    format!("'{}'", token.replace('\'', r#"'\''"#))
78}
79
80fn bash_quote_string(value: &str) -> String {
81    format!("'{}'", value.replace('\'', r#"'\''"#))
82}
83
84fn bash_known_cases(config: Option<&Config>) -> String {
85    let Some(config) = config else {
86        return "        *) return 0 ;;".to_string();
87    };
88
89    if config.abbr.is_empty() {
90        return "        *) return 0 ;;".to_string();
91    }
92
93    let mut lines = Vec::with_capacity(config.abbr.len() + 1);
94    for abbr in &config.abbr {
95        lines.push(format!(
96            "        {}) return 0 ;;",
97            bash_quote_pattern(&abbr.key)
98        ));
99    }
100    lines.push("        *) return 1 ;;".to_string());
101    lines.join("\n")
102}
103
104fn zsh_known_cases(config: Option<&Config>) -> String {
105    let Some(config) = config else {
106        return "        *) return 0 ;;".to_string();
107    };
108
109    if config.abbr.is_empty() {
110        return "        *) return 0 ;;".to_string();
111    }
112
113    let mut lines = Vec::with_capacity(config.abbr.len() + 1);
114    for abbr in &config.abbr {
115        lines.push(format!(
116            "        {}) return 0 ;;",
117            bash_quote_pattern(&abbr.key)
118        ));
119    }
120    lines.push("        *) return 1 ;;".to_string());
121    lines.join("\n")
122}
123
124fn pwsh_chord(trigger: TriggerKey) -> &'static str {
125    match trigger {
126        TriggerKey::Space => " ",
127        TriggerKey::Tab => "Tab",
128        TriggerKey::AltSpace => "Alt+Spacebar",
129    }
130}
131
132fn pwsh_quote_string(token: &str) -> String {
133    format!("'{}'", token.replace('\'', "''"))
134}
135
136fn lua_quote_string(value: &str) -> String {
137    let mut out = String::from("\"");
138    for ch in value.chars() {
139        match ch {
140            '\\' => out.push_str("\\\\"),
141            '"' => out.push_str("\\\""),
142            '\n' => out.push_str("\\n"),
143            '\r' => out.push_str("\\r"),
144            _ => out.push(ch),
145        }
146    }
147    out.push('"');
148    out
149}
150
151fn pwsh_known_cases(config: Option<&Config>) -> String {
152    let Some(config) = config else {
153        return String::new();
154    };
155
156    let mut lines = Vec::with_capacity(config.abbr.len());
157    for abbr in &config.abbr {
158        lines.push(format!(
159            "        {} {{ return $true }}",
160            pwsh_quote_string(&abbr.key)
161        ));
162    }
163    lines.join("\n")
164}
165
166fn nu_modifier(trigger: TriggerKey) -> &'static str {
167    match trigger {
168        TriggerKey::AltSpace => "alt",
169        TriggerKey::Space | TriggerKey::Tab => "none",
170    }
171}
172
173fn clink_known_cases(config: Option<&Config>) -> String {
174    let Some(config) = config else {
175        return String::new();
176    };
177
178    config
179        .abbr
180        .iter()
181        .map(|abbr| format!("    [{}] = true,", lua_quote_string(&abbr.key)))
182        .collect::<Vec<_>>()
183        .join("\n")
184}
185
186fn nu_keycode(trigger: TriggerKey) -> &'static str {
187    match trigger {
188        TriggerKey::Space | TriggerKey::AltSpace => "space",
189        TriggerKey::Tab => "tab",
190    }
191}
192
193fn clink_key_sequence(trigger: TriggerKey) -> &'static str {
194    match trigger {
195        TriggerKey::Space => r#"" ""#,
196        TriggerKey::Tab => r#""\t""#,
197        TriggerKey::AltSpace => r#""\e ""#,
198    }
199}
200
201fn bash_bind_lines(trigger: Option<TriggerKey>) -> String {
202    let mut lines = Vec::new();
203    // Only unbind and rebind the key that runex is configured to use.
204    if let Some(trigger) = trigger {
205        lines.push(format!(
206            r#"bind -r "{}" 2>/dev/null || true"#,
207            bash_chord(trigger)
208        ));
209        lines.push(format!("bind -x '\"{}\": __runex_expand'", bash_chord(trigger)));
210    }
211    lines.join("\n")
212}
213
214fn zsh_bind_lines(trigger: Option<TriggerKey>) -> String {
215    let mut lines = Vec::new();
216    // Only unbind and rebind the key that runex is configured to use.
217    if let Some(trigger) = trigger {
218        lines.push(format!(
219            r#"bindkey -r "{}" 2>/dev/null"#,
220            zsh_chord(trigger)
221        ));
222        lines.push(format!(r#"bindkey "{}" __runex_expand"#, zsh_chord(trigger)));
223    }
224    lines.join("\n")
225}
226
227fn pwsh_register_lines(trigger: Option<TriggerKey>) -> String {
228    let mut lines = Vec::new();
229    if let Some(trigger) = trigger {
230        lines.push(format!(
231            "    __runex_register_expand_handler '{}'",
232            pwsh_chord(trigger)
233        ));
234    }
235    let mut vi_lines = Vec::new();
236    if let Some(trigger) = trigger {
237        vi_lines.push(format!(
238            "        __runex_register_expand_handler '{}' Insert",
239            pwsh_chord(trigger)
240        ));
241    }
242    if !vi_lines.is_empty() {
243        lines.push("    if ((Get-PSReadLineOption).EditMode -eq 'Vi') {".to_string());
244        lines.extend(vi_lines);
245        lines.push("    }".to_string());
246    }
247    lines.join("\n")
248}
249
250fn nu_bindings(trigger: Option<TriggerKey>, bin: &str) -> String {
251    let mut blocks = Vec::new();
252    if let Some(trigger) = trigger {
253        blocks.push(
254            include_str!("templates/nu_expand_binding.nu")
255                .replace("{BIN}", bin)
256                .replace("{NU_MODIFIER}", nu_modifier(trigger))
257                .replace("{NU_KEYCODE}", nu_keycode(trigger)),
258        );
259    }
260    blocks.join(" | append ")
261}
262
263fn clink_binding(trigger: Option<TriggerKey>) -> String {
264    let Some(trigger) = trigger else {
265        return String::new();
266    };
267
268    let key = clink_key_sequence(trigger);
269    [
270        format!(
271            r#"pcall(rl.setbinding, [[{key}]], [["luafunc:runex_expand"]], "emacs")"#,
272            key = key
273        ),
274        format!(
275            r#"pcall(rl.setbinding, [[{key}]], [["luafunc:runex_expand"]], "vi-insert")"#,
276            key = key
277        ),
278    ]
279    .join("\n")
280}
281
282/// Generate a shell integration script.
283///
284/// `{BIN}` placeholders in the template are replaced with `bin`.
285pub fn export_script(shell: Shell, bin: &str, config: Option<&Config>) -> String {
286    let template = match shell {
287        Shell::Bash => include_str!("templates/bash.sh"),
288        Shell::Zsh => include_str!("templates/zsh.zsh"),
289        Shell::Pwsh => include_str!("templates/pwsh.ps1"),
290        Shell::Clink => include_str!("templates/clink.lua"),
291        Shell::Nu => include_str!("templates/nu.nu"),
292    };
293    let trigger = trigger_for(shell, config);
294    template
295        .replace("\r\n", "\n")
296        .replace("{BIN}", bin)
297        .replace("{BASH_BIN}", &bash_quote_string(bin))
298        .replace("{BASH_BIND_LINES}", &bash_bind_lines(trigger))
299        .replace("{BASH_KNOWN_CASES}", &bash_known_cases(config))
300        .replace("{ZSH_BIN}", &bash_quote_string(bin))
301        .replace("{ZSH_BIND_LINES}", &zsh_bind_lines(trigger))
302        .replace("{ZSH_KNOWN_CASES}", &zsh_known_cases(config))
303        .replace("{CLINK_BIN}", &lua_quote_string(bin))
304        .replace("{CLINK_BINDING}", &clink_binding(trigger))
305        .replace("{CLINK_KNOWN_CASES}", &clink_known_cases(config))
306        .replace("{PWSH_BIN}", &pwsh_quote_string(bin))
307        .replace("{PWSH_REGISTER_LINES}", &pwsh_register_lines(trigger))
308        .replace("{PWSH_KNOWN_CASES}", &pwsh_known_cases(config))
309        .replace("{NU_BINDINGS}", &nu_bindings(trigger, bin))
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn parse_bash() {
318        assert_eq!(Shell::from_str("bash").unwrap(), Shell::Bash);
319    }
320
321    #[test]
322    fn parse_case_insensitive() {
323        assert_eq!(Shell::from_str("PWSH").unwrap(), Shell::Pwsh);
324        assert_eq!(Shell::from_str("Clink").unwrap(), Shell::Clink);
325        assert_eq!(Shell::from_str("Nu").unwrap(), Shell::Nu);
326        assert_eq!(Shell::from_str("Zsh").unwrap(), Shell::Zsh);
327    }
328
329    #[test]
330    fn parse_unknown_errors() {
331        let err = Shell::from_str("fish").unwrap_err();
332        assert_eq!(err.0, "fish");
333    }
334
335    #[test]
336    fn export_script_contains_bin() {
337        let config = Config {
338            version: 1,
339            keybind: crate::model::KeybindConfig {
340                trigger: Some(TriggerKey::Space),
341                ..crate::model::KeybindConfig::default()
342            },
343            abbr: vec![],
344        };
345        for shell in [Shell::Bash, Shell::Zsh, Shell::Pwsh, Shell::Clink, Shell::Nu] {
346            let script = export_script(shell, "my-runex", Some(&config));
347            assert!(
348                script.contains("my-runex"),
349                "{shell:?} script must contain the bin name"
350            );
351        }
352    }
353
354    #[test]
355    fn bash_script_has_bind() {
356        let s = export_script(
357            Shell::Bash,
358            "runex",
359            Some(&Config {
360                version: 1,
361                keybind: crate::model::KeybindConfig {
362                    trigger: Some(TriggerKey::Space),
363                    ..crate::model::KeybindConfig::default()
364                },
365                abbr: vec![],
366            }),
367        );
368        assert!(s.contains("bind -x"), "bash script must use bind");
369        // Space trigger → only the space keybind should be removed before rebinding.
370        assert!(s.contains(r#"bind -r "\x20""#), "bash script must remove the space binding before rebinding");
371        assert!(s.contains("expanded=$('runex' expand"), "bash script must quote the executable");
372        assert!(s.contains("READLINE_LINE"), "bash script must use READLINE_LINE");
373        assert!(s.contains("READLINE_POINT"), "bash script must inspect the cursor");
374        assert!(!s.contains("{BASH_BIND_LINES}"), "bash script must resolve bind lines");
375    }
376
377    #[test]
378    fn pwsh_script_has_psreadline() {
379        let s = export_script(
380            Shell::Pwsh,
381            "runex",
382            Some(&Config {
383                version: 1,
384                keybind: crate::model::KeybindConfig {
385                    trigger: Some(TriggerKey::Space),
386                    ..crate::model::KeybindConfig::default()
387                },
388                abbr: vec![],
389            }),
390        );
391        assert!(s.contains("Set-PSReadLineKeyHandler"), "pwsh script must use PSReadLine");
392        assert!(
393            !s.contains("Set-PSReadLineKeyHandler -Chord 'Tab' -Function Complete"),
394            "pwsh script must not clobber the user's Tab binding"
395        );
396        assert!(s.contains("$expanded = & 'runex' expand"), "pwsh script must quote the executable");
397        assert!(s.contains("$cursor -lt $line.Length"), "pwsh script must guard mid-line insertion");
398        assert!(s.contains("EditMode"), "pwsh script must handle PSReadLine edit mode");
399        assert!(s.contains("__runex_is_command_position"), "pwsh script must detect command position");
400        assert!(!s.contains("{PWSH_REGISTER_LINES}"), "pwsh script must resolve register lines");
401    }
402
403    #[test]
404    fn pwsh_script_short_circuits_non_candidates() {
405        let s = export_script(
406            Shell::Pwsh,
407            "runex",
408            Some(&Config {
409                version: 1,
410                keybind: crate::model::KeybindConfig {
411                    trigger: Some(TriggerKey::Space),
412                    ..crate::model::KeybindConfig::default()
413                },
414                abbr: vec![],
415            }),
416        );
417        assert!(
418            s.contains("function __runex_get_expand_candidate"),
419            "pwsh script must define a fast precheck helper"
420        );
421        assert!(
422            s.contains("$candidate = __runex_get_expand_candidate $line $cursor"),
423            "pwsh handler must skip full expansion logic for non-candidates"
424        );
425        assert!(
426            s.contains("[Microsoft.PowerShell.PSConsoleReadLine]::Insert(' ')"),
427            "pwsh handler must insert a plain space on the fast path"
428        );
429    }
430
431    #[test]
432    fn zsh_script_has_zle_widget() {
433        let s = export_script(
434            Shell::Zsh,
435            "runex",
436            Some(&Config {
437                version: 1,
438                keybind: crate::model::KeybindConfig {
439                    trigger: Some(TriggerKey::Space),
440                    ..crate::model::KeybindConfig::default()
441                },
442                abbr: vec![],
443            }),
444        );
445        assert!(s.contains("zle -N __runex_expand"), "zsh script must register a zle widget");
446        assert!(s.contains(r#"bindkey " " __runex_expand"#), "zsh script must bind the trigger key");
447        assert!(s.contains("__runex_expand_buffer"), "zsh script must expose a testable helper");
448        assert!(s.contains("LBUFFER"), "zsh script must inspect the text before the cursor");
449        assert!(s.contains("RBUFFER"), "zsh script must inspect the text after the cursor");
450        assert!(s.contains("expanded=$('runex' expand"), "zsh script must quote the executable");
451    }
452
453    #[test]
454    fn clink_script_has_clink() {
455        let s = export_script(
456            Shell::Clink,
457            "runex",
458            Some(&Config {
459                version: 1,
460                keybind: crate::model::KeybindConfig {
461                    trigger: Some(TriggerKey::Space),
462                    ..crate::model::KeybindConfig::default()
463                },
464                abbr: vec![],
465            }),
466        );
467        assert!(s.contains("clink"), "clink script must reference clink");
468        assert!(s.contains("local RUNEX_BIN = \"runex\""), "clink script must quote the executable");
469        assert!(s.contains("local RUNEX_KNOWN = {"), "clink script must embed known keys");
470        assert!(s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]], "emacs")"#), "clink script must bind the trigger key in emacs mode");
471        assert!(s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]], "vi-insert")"#), "clink script must bind the trigger key in vi insert mode");
472        assert!(s.contains("rl_buffer:getcursor()"), "clink script must inspect the cursor");
473        assert!(!s.contains("clink.onfilterinput"), "clink script must not use onfilterinput for realtime expansion");
474    }
475
476    #[test]
477    fn clink_script_uses_alt_space_sequence() {
478        let config = Config {
479            version: 1,
480            keybind: crate::model::KeybindConfig {
481                trigger: Some(TriggerKey::AltSpace),
482                ..crate::model::KeybindConfig::default()
483            },
484            abbr: vec![],
485        };
486        let s = export_script(Shell::Clink, "runex", Some(&config));
487        assert!(
488            s.contains(r#"pcall(rl.setbinding, [["\e "]], [["luafunc:runex_expand"]], "emacs")"#),
489            "clink script must use the alt-space sequence"
490        );
491    }
492
493    #[test]
494    fn nu_script_has_keybindings() {
495        let s = export_script(
496            Shell::Nu,
497            "runex",
498            Some(&Config {
499                version: 1,
500                keybind: crate::model::KeybindConfig {
501                    trigger: Some(TriggerKey::Space),
502                    ..crate::model::KeybindConfig::default()
503                },
504                abbr: vec![],
505            }),
506        );
507        assert!(s.contains("keybindings"), "nu script must reference keybindings");
508        assert!(s.contains("commandline get-cursor"), "nu script must inspect the cursor");
509    }
510
511    #[test]
512    fn bash_script_uses_keybind_override() {
513        let config = Config {
514            version: 1,
515            keybind: crate::model::KeybindConfig {
516                trigger: None,
517                bash: Some(TriggerKey::AltSpace),
518                zsh: None,
519                pwsh: None,
520                nu: None,
521            },
522            abbr: vec![],
523        };
524        let s = export_script(Shell::Bash, "runex", Some(&config));
525        assert!(s.contains("\\e "), "bash script must use the configured key chord");
526    }
527
528    #[test]
529    fn bash_script_embeds_known_tokens() {
530        let config = Config {
531            version: 1,
532            keybind: crate::model::KeybindConfig::default(),
533            abbr: vec![crate::model::Abbr {
534                key: "gcm".into(),
535                expand: "git commit -m".into(),
536                when_command_exists: None,
537            }],
538        };
539        let s = export_script(Shell::Bash, "runex", Some(&config));
540        assert!(s.contains("'gcm') return 0 ;;"), "bash script must embed known tokens");
541    }
542
543    #[test]
544    fn pwsh_script_uses_global_keybind() {
545        let config = Config {
546            version: 1,
547            keybind: crate::model::KeybindConfig {
548                trigger: Some(TriggerKey::Tab),
549                bash: None,
550                zsh: None,
551                pwsh: None,
552                nu: None,
553            },
554            abbr: vec![],
555        };
556        let s = export_script(Shell::Pwsh, "runex", Some(&config));
557        assert!(
558            s.contains("__runex_register_expand_handler 'Tab'"),
559            "pwsh script must use the configured chord"
560        );
561    }
562
563    #[test]
564    fn pwsh_script_uses_spacebar_name_for_alt_space() {
565        let config = Config {
566            version: 1,
567            keybind: crate::model::KeybindConfig {
568                trigger: None,
569                bash: None,
570                zsh: None,
571                pwsh: Some(TriggerKey::AltSpace),
572                nu: None,
573            },
574            abbr: vec![],
575        };
576        let s = export_script(Shell::Pwsh, "runex", Some(&config));
577        assert!(
578            s.contains("__runex_register_expand_handler 'Alt+Spacebar'"),
579            "pwsh script must register Alt+Space using Spacebar"
580        );
581    }
582
583    #[test]
584    fn pwsh_script_embeds_known_tokens() {
585        let config = Config {
586            version: 1,
587            keybind: crate::model::KeybindConfig::default(),
588            abbr: vec![crate::model::Abbr {
589                key: "gcm".into(),
590                expand: "git commit -m".into(),
591                when_command_exists: None,
592            }],
593        };
594        let s = export_script(Shell::Pwsh, "runex", Some(&config));
595        assert!(s.contains("'gcm' { return $true }"), "pwsh script must embed known tokens");
596    }
597
598    #[test]
599    fn no_keybinds_means_no_handlers() {
600        let s = export_script(Shell::Bash, "runex", Some(&Config {
601            version: 1,
602            keybind: crate::model::KeybindConfig::default(),
603            abbr: vec![],
604        }));
605        assert!(!s.contains("bind -x"), "bash script should not bind keys by default");
606        assert!(!s.contains(r#"bind -r"#), "bash script should not remove keybinds when no trigger is configured");
607
608        let s = export_script(Shell::Pwsh, "runex", Some(&Config {
609            version: 1,
610            keybind: crate::model::KeybindConfig::default(),
611            abbr: vec![],
612        }));
613        assert!(
614            !s.contains("__runex_register_expand_handler '"),
615            "pwsh script should not register expand handlers by default"
616        );
617        assert!(
618            !s.contains("Set-PSReadLineKeyHandler -Chord ' ' -Function SelfInsert"),
619            "pwsh script should not clobber default key handlers when no trigger is configured"
620        );
621
622        let s = export_script(Shell::Clink, "runex", Some(&Config {
623            version: 1,
624            keybind: crate::model::KeybindConfig::default(),
625            abbr: vec![],
626        }));
627        assert!(
628            !s.contains("rl.setbinding("),
629            "clink script should not register handlers by default"
630        );
631    }
632
633    #[test]
634    fn pwsh_script_has_single_default_clause() {
635        // Regression: empty abbr list used to emit a duplicate `default` clause
636        // inside the switch statement, causing a PowerShell parse error.
637        for abbr in [vec![], vec![crate::model::Abbr {
638            key: "gcm".into(),
639            expand: "git commit -m".into(),
640            when_command_exists: None,
641        }]] {
642            let s = export_script(Shell::Pwsh, "runex", Some(&Config {
643                version: 1,
644                keybind: crate::model::KeybindConfig::default(),
645                abbr,
646            }));
647            let default_count = s.matches("default {").count();
648            assert_eq!(default_count, 1, "pwsh script must have exactly one default clause, got {default_count}");
649        }
650    }
651}