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 "        default { return $true }".to_string();
154    };
155
156    if config.abbr.is_empty() {
157        return "        default { return $true }".to_string();
158    }
159
160    let mut lines = Vec::with_capacity(config.abbr.len());
161    for abbr in &config.abbr {
162        lines.push(format!(
163            "        {} {{ return $true }}",
164            pwsh_quote_string(&abbr.key)
165        ));
166    }
167    lines.join("\n")
168}
169
170fn nu_modifier(trigger: TriggerKey) -> &'static str {
171    match trigger {
172        TriggerKey::AltSpace => "alt",
173        TriggerKey::Space | TriggerKey::Tab => "none",
174    }
175}
176
177fn clink_known_cases(config: Option<&Config>) -> String {
178    let Some(config) = config else {
179        return String::new();
180    };
181
182    config
183        .abbr
184        .iter()
185        .map(|abbr| format!("    [{}] = true,", lua_quote_string(&abbr.key)))
186        .collect::<Vec<_>>()
187        .join("\n")
188}
189
190fn nu_keycode(trigger: TriggerKey) -> &'static str {
191    match trigger {
192        TriggerKey::Space | TriggerKey::AltSpace => "space",
193        TriggerKey::Tab => "tab",
194    }
195}
196
197fn clink_key_sequence(trigger: TriggerKey) -> &'static str {
198    match trigger {
199        TriggerKey::Space => r#"" ""#,
200        TriggerKey::Tab => r#""\t""#,
201        TriggerKey::AltSpace => r#""\e ""#,
202    }
203}
204
205fn bash_bind_lines(trigger: Option<TriggerKey>) -> String {
206    let mut lines = Vec::new();
207    // Only unbind and rebind the key that runex is configured to use.
208    if let Some(trigger) = trigger {
209        lines.push(format!(
210            r#"bind -r "{}" 2>/dev/null || true"#,
211            bash_chord(trigger)
212        ));
213        lines.push(format!("bind -x '\"{}\": __runex_expand'", bash_chord(trigger)));
214    }
215    lines.join("\n")
216}
217
218fn zsh_bind_lines(trigger: Option<TriggerKey>) -> String {
219    let mut lines = Vec::new();
220    // Only unbind and rebind the key that runex is configured to use.
221    if let Some(trigger) = trigger {
222        lines.push(format!(
223            r#"bindkey -r "{}" 2>/dev/null"#,
224            zsh_chord(trigger)
225        ));
226        lines.push(format!(r#"bindkey "{}" __runex_expand"#, zsh_chord(trigger)));
227    }
228    lines.join("\n")
229}
230
231fn pwsh_register_lines(trigger: Option<TriggerKey>) -> String {
232    let mut lines = Vec::new();
233    if let Some(trigger) = trigger {
234        lines.push(format!(
235            "    __runex_register_expand_handler '{}'",
236            pwsh_chord(trigger)
237        ));
238    }
239    let mut vi_lines = Vec::new();
240    if let Some(trigger) = trigger {
241        vi_lines.push(format!(
242            "        __runex_register_expand_handler '{}' Insert",
243            pwsh_chord(trigger)
244        ));
245    }
246    if !vi_lines.is_empty() {
247        lines.push("    if ((Get-PSReadLineOption).EditMode -eq 'Vi') {".to_string());
248        lines.extend(vi_lines);
249        lines.push("    }".to_string());
250    }
251    lines.join("\n")
252}
253
254fn nu_bindings(trigger: Option<TriggerKey>, bin: &str) -> String {
255    let mut blocks = Vec::new();
256    if let Some(trigger) = trigger {
257        blocks.push(
258            include_str!("templates/nu_expand_binding.nu")
259                .replace("{BIN}", bin)
260                .replace("{NU_MODIFIER}", nu_modifier(trigger))
261                .replace("{NU_KEYCODE}", nu_keycode(trigger)),
262        );
263    }
264    blocks.join(" | append ")
265}
266
267fn clink_binding(trigger: Option<TriggerKey>) -> String {
268    let Some(trigger) = trigger else {
269        return String::new();
270    };
271
272    let key = clink_key_sequence(trigger);
273    [
274        format!(
275            r#"pcall(rl.setbinding, [[{key}]], [["luafunc:runex_expand"]], "emacs")"#,
276            key = key
277        ),
278        format!(
279            r#"pcall(rl.setbinding, [[{key}]], [["luafunc:runex_expand"]], "vi-insert")"#,
280            key = key
281        ),
282    ]
283    .join("\n")
284}
285
286/// Generate a shell integration script.
287///
288/// `{BIN}` placeholders in the template are replaced with `bin`.
289pub fn export_script(shell: Shell, bin: &str, config: Option<&Config>) -> String {
290    let template = match shell {
291        Shell::Bash => include_str!("templates/bash.sh"),
292        Shell::Zsh => include_str!("templates/zsh.zsh"),
293        Shell::Pwsh => include_str!("templates/pwsh.ps1"),
294        Shell::Clink => include_str!("templates/clink.lua"),
295        Shell::Nu => include_str!("templates/nu.nu"),
296    };
297    let trigger = trigger_for(shell, config);
298    template
299        .replace("\r\n", "\n")
300        .replace("{BIN}", bin)
301        .replace("{BASH_BIN}", &bash_quote_string(bin))
302        .replace("{BASH_BIND_LINES}", &bash_bind_lines(trigger))
303        .replace("{BASH_KNOWN_CASES}", &bash_known_cases(config))
304        .replace("{ZSH_BIN}", &bash_quote_string(bin))
305        .replace("{ZSH_BIND_LINES}", &zsh_bind_lines(trigger))
306        .replace("{ZSH_KNOWN_CASES}", &zsh_known_cases(config))
307        .replace("{CLINK_BIN}", &lua_quote_string(bin))
308        .replace("{CLINK_BINDING}", &clink_binding(trigger))
309        .replace("{CLINK_KNOWN_CASES}", &clink_known_cases(config))
310        .replace("{PWSH_BIN}", &pwsh_quote_string(bin))
311        .replace("{PWSH_REGISTER_LINES}", &pwsh_register_lines(trigger))
312        .replace("{PWSH_KNOWN_CASES}", &pwsh_known_cases(config))
313        .replace("{NU_BINDINGS}", &nu_bindings(trigger, bin))
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn parse_bash() {
322        assert_eq!(Shell::from_str("bash").unwrap(), Shell::Bash);
323    }
324
325    #[test]
326    fn parse_case_insensitive() {
327        assert_eq!(Shell::from_str("PWSH").unwrap(), Shell::Pwsh);
328        assert_eq!(Shell::from_str("Clink").unwrap(), Shell::Clink);
329        assert_eq!(Shell::from_str("Nu").unwrap(), Shell::Nu);
330        assert_eq!(Shell::from_str("Zsh").unwrap(), Shell::Zsh);
331    }
332
333    #[test]
334    fn parse_unknown_errors() {
335        let err = Shell::from_str("fish").unwrap_err();
336        assert_eq!(err.0, "fish");
337    }
338
339    #[test]
340    fn export_script_contains_bin() {
341        let config = Config {
342            version: 1,
343            keybind: crate::model::KeybindConfig {
344                trigger: Some(TriggerKey::Space),
345                ..crate::model::KeybindConfig::default()
346            },
347            abbr: vec![],
348        };
349        for shell in [Shell::Bash, Shell::Zsh, Shell::Pwsh, Shell::Clink, Shell::Nu] {
350            let script = export_script(shell, "my-runex", Some(&config));
351            assert!(
352                script.contains("my-runex"),
353                "{shell:?} script must contain the bin name"
354            );
355        }
356    }
357
358    #[test]
359    fn bash_script_has_bind() {
360        let s = export_script(
361            Shell::Bash,
362            "runex",
363            Some(&Config {
364                version: 1,
365                keybind: crate::model::KeybindConfig {
366                    trigger: Some(TriggerKey::Space),
367                    ..crate::model::KeybindConfig::default()
368                },
369                abbr: vec![],
370            }),
371        );
372        assert!(s.contains("bind -x"), "bash script must use bind");
373        // Space trigger → only the space keybind should be removed before rebinding.
374        assert!(s.contains(r#"bind -r "\x20""#), "bash script must remove the space binding before rebinding");
375        assert!(s.contains("expanded=$('runex' expand"), "bash script must quote the executable");
376        assert!(s.contains("READLINE_LINE"), "bash script must use READLINE_LINE");
377        assert!(s.contains("READLINE_POINT"), "bash script must inspect the cursor");
378        assert!(!s.contains("{BASH_BIND_LINES}"), "bash script must resolve bind lines");
379    }
380
381    #[test]
382    fn pwsh_script_has_psreadline() {
383        let s = export_script(
384            Shell::Pwsh,
385            "runex",
386            Some(&Config {
387                version: 1,
388                keybind: crate::model::KeybindConfig {
389                    trigger: Some(TriggerKey::Space),
390                    ..crate::model::KeybindConfig::default()
391                },
392                abbr: vec![],
393            }),
394        );
395        assert!(s.contains("Set-PSReadLineKeyHandler"), "pwsh script must use PSReadLine");
396        assert!(
397            !s.contains("Set-PSReadLineKeyHandler -Chord 'Tab' -Function Complete"),
398            "pwsh script must not clobber the user's Tab binding"
399        );
400        assert!(s.contains("$expanded = & 'runex' expand"), "pwsh script must quote the executable");
401        assert!(s.contains("$cursor -lt $line.Length"), "pwsh script must guard mid-line insertion");
402        assert!(s.contains("EditMode"), "pwsh script must handle PSReadLine edit mode");
403        assert!(s.contains("__runex_is_command_position"), "pwsh script must detect command position");
404        assert!(!s.contains("{PWSH_REGISTER_LINES}"), "pwsh script must resolve register lines");
405    }
406
407    #[test]
408    fn pwsh_script_short_circuits_non_candidates() {
409        let s = export_script(
410            Shell::Pwsh,
411            "runex",
412            Some(&Config {
413                version: 1,
414                keybind: crate::model::KeybindConfig {
415                    trigger: Some(TriggerKey::Space),
416                    ..crate::model::KeybindConfig::default()
417                },
418                abbr: vec![],
419            }),
420        );
421        assert!(
422            s.contains("function __runex_get_expand_candidate"),
423            "pwsh script must define a fast precheck helper"
424        );
425        assert!(
426            s.contains("$candidate = __runex_get_expand_candidate $line $cursor"),
427            "pwsh handler must skip full expansion logic for non-candidates"
428        );
429        assert!(
430            s.contains("[Microsoft.PowerShell.PSConsoleReadLine]::Insert(' ')"),
431            "pwsh handler must insert a plain space on the fast path"
432        );
433    }
434
435    #[test]
436    fn zsh_script_has_zle_widget() {
437        let s = export_script(
438            Shell::Zsh,
439            "runex",
440            Some(&Config {
441                version: 1,
442                keybind: crate::model::KeybindConfig {
443                    trigger: Some(TriggerKey::Space),
444                    ..crate::model::KeybindConfig::default()
445                },
446                abbr: vec![],
447            }),
448        );
449        assert!(s.contains("zle -N __runex_expand"), "zsh script must register a zle widget");
450        assert!(s.contains(r#"bindkey " " __runex_expand"#), "zsh script must bind the trigger key");
451        assert!(s.contains("__runex_expand_buffer"), "zsh script must expose a testable helper");
452        assert!(s.contains("LBUFFER"), "zsh script must inspect the text before the cursor");
453        assert!(s.contains("RBUFFER"), "zsh script must inspect the text after the cursor");
454        assert!(s.contains("expanded=$('runex' expand"), "zsh script must quote the executable");
455    }
456
457    #[test]
458    fn clink_script_has_clink() {
459        let s = export_script(
460            Shell::Clink,
461            "runex",
462            Some(&Config {
463                version: 1,
464                keybind: crate::model::KeybindConfig {
465                    trigger: Some(TriggerKey::Space),
466                    ..crate::model::KeybindConfig::default()
467                },
468                abbr: vec![],
469            }),
470        );
471        assert!(s.contains("clink"), "clink script must reference clink");
472        assert!(s.contains("local RUNEX_BIN = \"runex\""), "clink script must quote the executable");
473        assert!(s.contains("local RUNEX_KNOWN = {"), "clink script must embed known keys");
474        assert!(s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]], "emacs")"#), "clink script must bind the trigger key in emacs mode");
475        assert!(s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]], "vi-insert")"#), "clink script must bind the trigger key in vi insert mode");
476        assert!(s.contains("rl_buffer:getcursor()"), "clink script must inspect the cursor");
477        assert!(!s.contains("clink.onfilterinput"), "clink script must not use onfilterinput for realtime expansion");
478    }
479
480    #[test]
481    fn clink_script_uses_alt_space_sequence() {
482        let config = Config {
483            version: 1,
484            keybind: crate::model::KeybindConfig {
485                trigger: Some(TriggerKey::AltSpace),
486                ..crate::model::KeybindConfig::default()
487            },
488            abbr: vec![],
489        };
490        let s = export_script(Shell::Clink, "runex", Some(&config));
491        assert!(
492            s.contains(r#"pcall(rl.setbinding, [["\e "]], [["luafunc:runex_expand"]], "emacs")"#),
493            "clink script must use the alt-space sequence"
494        );
495    }
496
497    #[test]
498    fn nu_script_has_keybindings() {
499        let s = export_script(
500            Shell::Nu,
501            "runex",
502            Some(&Config {
503                version: 1,
504                keybind: crate::model::KeybindConfig {
505                    trigger: Some(TriggerKey::Space),
506                    ..crate::model::KeybindConfig::default()
507                },
508                abbr: vec![],
509            }),
510        );
511        assert!(s.contains("keybindings"), "nu script must reference keybindings");
512        assert!(s.contains("commandline get-cursor"), "nu script must inspect the cursor");
513    }
514
515    #[test]
516    fn bash_script_uses_keybind_override() {
517        let config = Config {
518            version: 1,
519            keybind: crate::model::KeybindConfig {
520                trigger: None,
521                bash: Some(TriggerKey::AltSpace),
522                zsh: None,
523                pwsh: None,
524                nu: None,
525            },
526            abbr: vec![],
527        };
528        let s = export_script(Shell::Bash, "runex", Some(&config));
529        assert!(s.contains("\\e "), "bash script must use the configured key chord");
530    }
531
532    #[test]
533    fn bash_script_embeds_known_tokens() {
534        let config = Config {
535            version: 1,
536            keybind: crate::model::KeybindConfig::default(),
537            abbr: vec![crate::model::Abbr {
538                key: "gcm".into(),
539                expand: "git commit -m".into(),
540                when_command_exists: None,
541            }],
542        };
543        let s = export_script(Shell::Bash, "runex", Some(&config));
544        assert!(s.contains("'gcm') return 0 ;;"), "bash script must embed known tokens");
545    }
546
547    #[test]
548    fn pwsh_script_uses_global_keybind() {
549        let config = Config {
550            version: 1,
551            keybind: crate::model::KeybindConfig {
552                trigger: Some(TriggerKey::Tab),
553                bash: None,
554                zsh: None,
555                pwsh: None,
556                nu: None,
557            },
558            abbr: vec![],
559        };
560        let s = export_script(Shell::Pwsh, "runex", Some(&config));
561        assert!(
562            s.contains("__runex_register_expand_handler 'Tab'"),
563            "pwsh script must use the configured chord"
564        );
565    }
566
567    #[test]
568    fn pwsh_script_uses_spacebar_name_for_alt_space() {
569        let config = Config {
570            version: 1,
571            keybind: crate::model::KeybindConfig {
572                trigger: None,
573                bash: None,
574                zsh: None,
575                pwsh: Some(TriggerKey::AltSpace),
576                nu: None,
577            },
578            abbr: vec![],
579        };
580        let s = export_script(Shell::Pwsh, "runex", Some(&config));
581        assert!(
582            s.contains("__runex_register_expand_handler 'Alt+Spacebar'"),
583            "pwsh script must register Alt+Space using Spacebar"
584        );
585    }
586
587    #[test]
588    fn pwsh_script_embeds_known_tokens() {
589        let config = Config {
590            version: 1,
591            keybind: crate::model::KeybindConfig::default(),
592            abbr: vec![crate::model::Abbr {
593                key: "gcm".into(),
594                expand: "git commit -m".into(),
595                when_command_exists: None,
596            }],
597        };
598        let s = export_script(Shell::Pwsh, "runex", Some(&config));
599        assert!(s.contains("'gcm' { return $true }"), "pwsh script must embed known tokens");
600    }
601
602    #[test]
603    fn no_keybinds_means_no_handlers() {
604        let s = export_script(Shell::Bash, "runex", Some(&Config {
605            version: 1,
606            keybind: crate::model::KeybindConfig::default(),
607            abbr: vec![],
608        }));
609        assert!(!s.contains("bind -x"), "bash script should not bind keys by default");
610        assert!(!s.contains(r#"bind -r"#), "bash script should not remove keybinds when no trigger is configured");
611
612        let s = export_script(Shell::Pwsh, "runex", Some(&Config {
613            version: 1,
614            keybind: crate::model::KeybindConfig::default(),
615            abbr: vec![],
616        }));
617        assert!(
618            !s.contains("__runex_register_expand_handler '"),
619            "pwsh script should not register expand handlers by default"
620        );
621        assert!(
622            !s.contains("Set-PSReadLineKeyHandler -Chord ' ' -Function SelfInsert"),
623            "pwsh script should not clobber default key handlers when no trigger is configured"
624        );
625
626        let s = export_script(Shell::Clink, "runex", Some(&Config {
627            version: 1,
628            keybind: crate::model::KeybindConfig::default(),
629            abbr: vec![],
630        }));
631        assert!(
632            !s.contains("rl.setbinding("),
633            "clink script should not register handlers by default"
634        );
635    }
636}