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