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