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
294pub 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}