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