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