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