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