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