1use std::fmt;
2use std::str::FromStr;
3
4use crate::model::{Config, TriggerKey};
5use crate::sanitize::{double_quote_escape, is_nu_drop_char, is_unicode_line_separator, is_unsafe_for_display};
6
7pub use crate::model::Shell;
10
11impl FromStr for Shell {
12 type Err = ShellParseError;
13
14 fn from_str(s: &str) -> Result<Self, Self::Err> {
15 match s.to_ascii_lowercase().as_str() {
16 "bash" => Ok(Shell::Bash),
17 "zsh" => Ok(Shell::Zsh),
18 "pwsh" => Ok(Shell::Pwsh),
19 "clink" => Ok(Shell::Clink),
20 "nu" => Ok(Shell::Nu),
21 _ => Err(ShellParseError(s.to_string())),
22 }
23 }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ShellParseError(pub String);
33
34impl fmt::Display for ShellParseError {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 let safe: String = self
37 .0
38 .chars()
39 .filter(|&c| !is_unsafe_for_display(c))
40 .collect();
41 write!(
42 f,
43 "unknown shell '{}' (expected: bash, zsh, pwsh, clink, nu)",
44 safe
45 )
46 }
47}
48
49impl std::error::Error for ShellParseError {}
50
51fn trigger_for(shell: Shell, config: Option<&Config>) -> Option<TriggerKey> {
52 let keybind = match config {
53 Some(config) => &config.keybind,
54 None => return None,
55 };
56
57 match shell {
58 Shell::Bash => keybind.trigger.bash.or(keybind.trigger.default),
59 Shell::Zsh => keybind.trigger.zsh.or(keybind.trigger.default),
60 Shell::Pwsh => keybind.trigger.pwsh.or(keybind.trigger.default),
61 Shell::Nu => keybind.trigger.nu.or(keybind.trigger.default),
62 Shell::Clink => keybind.trigger.default,
63 }
64}
65
66fn self_insert_for(shell: Shell, config: Option<&Config>) -> Option<TriggerKey> {
67 let keybind = match config {
68 Some(config) => &config.keybind,
69 None => return None,
70 };
71
72 match shell {
73 Shell::Bash => keybind.self_insert.bash.or(keybind.self_insert.default),
74 Shell::Zsh => keybind.self_insert.zsh.or(keybind.self_insert.default),
75 Shell::Pwsh => keybind.self_insert.pwsh.or(keybind.self_insert.default),
76 Shell::Nu => keybind.self_insert.nu.or(keybind.self_insert.default),
77 Shell::Clink => None,
78 }
79}
80
81fn bash_chord(trigger: TriggerKey) -> &'static str {
82 match trigger {
83 TriggerKey::Space => "\\x20",
84 TriggerKey::Tab => "\\C-i",
85 TriggerKey::AltSpace => "\\e ",
86 TriggerKey::ShiftSpace => unreachable!("ShiftSpace cannot be used as a trigger in bash"),
87 }
88}
89
90fn zsh_chord(trigger: TriggerKey) -> &'static str {
91 match trigger {
92 TriggerKey::Space => " ",
93 TriggerKey::Tab => "^I",
94 TriggerKey::AltSpace => "^[ ",
95 TriggerKey::ShiftSpace => unreachable!("ShiftSpace cannot be used as a trigger in zsh"),
96 }
97}
98
99pub(crate) fn bash_quote_string(value: &str) -> String {
111 let mut out = String::from("'");
112 for ch in value.chars() {
113 match ch {
114 '\'' => out.push_str(r"'\''"),
115 c if c.is_ascii_control() || is_unicode_line_separator(c) => {}
116 _ => out.push(ch),
117 }
118 }
119 out.push('\'');
120 out
121}
122
123fn pwsh_chord(trigger: TriggerKey) -> &'static str {
130 match trigger {
131 TriggerKey::Space => " ",
132 TriggerKey::Tab => "Tab",
133 TriggerKey::AltSpace => "Alt+Spacebar",
134 TriggerKey::ShiftSpace => unreachable!("ShiftSpace cannot be used as a trigger in pwsh"),
135 }
136}
137
138pub(crate) fn pwsh_quote_string(token: &str) -> String {
144 let mut out = String::from("'");
145 for ch in token.chars() {
146 match ch {
147 '\'' => out.push_str("''"),
148 c if c.is_ascii_control() || is_unicode_line_separator(c) => {}
149 _ => out.push(ch),
150 }
151 }
152 out.push('\'');
153 out
154}
155
156pub(crate) fn nu_quote_string(value: &str) -> String {
165 let mut out = String::from("^\"");
166 for ch in value.chars() {
167 if let Some(esc) = double_quote_escape(ch) {
168 out.push_str(esc);
169 } else if ch == '$' {
170 out.push_str("\\$");
171 } else if is_nu_drop_char(ch) {
172 } else {
173 out.push(ch);
174 }
175 }
176 out.push('"');
177 out
178}
179
180fn nu_quote_string_embedded(value: &str) -> String {
191 let standalone = nu_quote_string(value);
192 let mut out = String::with_capacity(standalone.len() + 8);
193 let mut chars = standalone.chars().peekable();
194 while let Some(ch) = chars.next() {
195 match ch {
196 '\\' => {
197 if chars.peek() == Some(&'$') {
198 out.push('\\');
199 out.push('$');
200 chars.next();
201 } else {
202 out.push_str("\\\\");
203 }
204 }
205 '"' => out.push_str("\\\""),
206 c => out.push(c),
207 }
208 }
209 out
210}
211
212pub(crate) fn lua_quote_string(value: &str) -> String {
222 let mut out = String::from("\"");
223 for ch in value.chars() {
224 if let Some(esc) = double_quote_escape(ch) {
225 out.push_str(esc);
226 } else if ch == '\0' || is_unicode_line_separator(ch) {
227 } else if ch.is_ascii_control() {
228 out.push_str(&format!("\\{:03}", ch as u8));
229 } else {
230 out.push(ch);
231 }
232 }
233 out.push('"');
234 out
235}
236
237fn nu_modifier(trigger: TriggerKey) -> &'static str {
238 match trigger {
239 TriggerKey::AltSpace => "alt",
240 TriggerKey::ShiftSpace => "shift",
241 TriggerKey::Space | TriggerKey::Tab => "none",
242 }
243}
244
245fn nu_keycode(trigger: TriggerKey) -> &'static str {
246 match trigger {
247 TriggerKey::Space | TriggerKey::AltSpace | TriggerKey::ShiftSpace => "space",
248 TriggerKey::Tab => "tab",
249 }
250}
251
252fn clink_key_sequence(trigger: TriggerKey) -> &'static str {
253 match trigger {
254 TriggerKey::Space => r#"" ""#,
255 TriggerKey::Tab => r#""\t""#,
256 TriggerKey::AltSpace => r#""\e ""#,
257 TriggerKey::ShiftSpace => unreachable!("ShiftSpace cannot be used as a trigger in clink"),
258 }
259}
260
261fn bash_bind_lines(trigger: Option<TriggerKey>) -> String {
264 let mut lines = Vec::new();
265 if let Some(trigger) = trigger {
266 lines.push(format!(
267 r#"bind -r "{}" 2>/dev/null || true"#,
268 bash_chord(trigger)
269 ));
270 lines.push(format!("bind -x '\"{}\": __runex_expand'", bash_chord(trigger)));
271 }
272 lines.join("\n")
273}
274
275fn zsh_bind_lines(trigger: Option<TriggerKey>) -> String {
278 let mut lines = Vec::new();
279 if let Some(trigger) = trigger {
280 lines.push(format!(
281 r#"bindkey -r "{}" 2>/dev/null"#,
282 zsh_chord(trigger)
283 ));
284 lines.push(format!(r#"bindkey "{}" __runex_expand"#, zsh_chord(trigger)));
285 }
286 lines.join("\n")
287}
288
289fn bash_self_insert_lines(self_insert: Option<TriggerKey>) -> String {
290 match self_insert {
291 Some(TriggerKey::AltSpace) => [
292 r#"bind -r "\e " 2>/dev/null || true"#,
293 r#"bind '"\e ": self-insert'"#,
294 ]
295 .join("\n"),
296 _ => String::new(),
297 }
298}
299
300fn zsh_self_insert_lines(self_insert: Option<TriggerKey>) -> String {
301 match self_insert {
302 Some(TriggerKey::AltSpace) => [
303 r#"bindkey -r "^[ " 2>/dev/null"#,
304 r#"bindkey "^[ " self-insert"#,
305 ]
306 .join("\n"),
307 _ => String::new(),
308 }
309}
310
311fn pwsh_register_lines(trigger: Option<TriggerKey>) -> String {
312 let mut lines = Vec::new();
313 if let Some(trigger) = trigger {
314 lines.push(format!(
315 " __runex_register_expand_handler '{}'",
316 pwsh_chord(trigger)
317 ));
318 }
319 let mut vi_lines = Vec::new();
320 if let Some(trigger) = trigger {
321 vi_lines.push(format!(
322 " __runex_register_expand_handler '{}' Insert",
323 pwsh_chord(trigger)
324 ));
325 }
326 if !vi_lines.is_empty() {
327 lines.push(" if ((Get-PSReadLineOption).EditMode -eq 'Vi') {".to_string());
328 lines.extend(vi_lines);
329 lines.push(" }".to_string());
330 }
331 lines.join("\n")
332}
333
334fn pwsh_self_insert_lines(self_insert: Option<TriggerKey>) -> String {
335 match self_insert {
336 Some(TriggerKey::ShiftSpace) => {
337 " Set-PSReadLineKeyHandler -Chord 'Shift+Spacebar' -Function SelfInsert"
338 .to_string()
339 }
340 Some(TriggerKey::AltSpace) => {
341 " Set-PSReadLineKeyHandler -Chord 'Alt+Spacebar' -Function SelfInsert".to_string()
342 }
343 _ => String::new(),
344 }
345}
346
347fn nu_bindings(trigger: Option<TriggerKey>, bin: &str) -> String {
348 let mut blocks = Vec::new();
349 if let Some(trigger) = trigger {
350 blocks.push(
351 include_str!("templates/nu_expand_binding.nu")
352 .replace("{NU_BIN}", &nu_quote_string_embedded(bin))
353 .replace("{NU_MODIFIER}", nu_modifier(trigger))
354 .replace("{NU_KEYCODE}", nu_keycode(trigger)),
355 );
356 }
357 blocks.join(" | append ")
358}
359
360fn nu_self_insert_lines(self_insert: Option<TriggerKey>) -> String {
361 let key = match self_insert {
362 Some(TriggerKey::ShiftSpace) => Some(("shift", "space")),
363 Some(TriggerKey::AltSpace) => Some(("alt", "space")),
364 _ => None,
365 };
366 let Some((modifier, keycode)) = key else {
367 return String::new();
368 };
369 include_str!("templates/nu_self_insert_binding.nu")
370 .replace("{NU_SI_MODIFIER}", modifier)
371 .replace("{NU_SI_KEYCODE}", keycode)
372}
373
374fn clink_binding(trigger: Option<TriggerKey>) -> String {
375 let Some(trigger) = trigger else {
376 return String::new();
377 };
378
379 let key = clink_key_sequence(trigger);
380 [
381 format!(
382 r#"pcall(rl.setbinding, [[{key}]], [["luafunc:runex_expand"]], "emacs")"#,
383 key = key
384 ),
385 format!(
386 r#"pcall(rl.setbinding, [[{key}]], [["luafunc:runex_expand"]], "vi-insert")"#,
387 key = key
388 ),
389 ]
390 .join("\n")
391}
392
393pub fn export_script(shell: Shell, bin: &str, config: Option<&Config>) -> String {
397 let template = match shell {
398 Shell::Bash => include_str!("templates/bash.sh"),
399 Shell::Zsh => include_str!("templates/zsh.zsh"),
400 Shell::Pwsh => include_str!("templates/pwsh.ps1"),
401 Shell::Clink => include_str!("templates/clink.lua"),
402 Shell::Nu => include_str!("templates/nu.nu"),
403 };
404 let trigger = trigger_for(shell, config);
405 let self_insert = self_insert_for(shell, config);
406 template
407 .replace("\r\n", "\n")
408 .replace("{BASH_BIN}", &bash_quote_string(bin))
409 .replace("{BASH_BIND_LINES}", &bash_bind_lines(trigger))
410 .replace("{BASH_SELF_INSERT_LINES}", &bash_self_insert_lines(self_insert))
411 .replace("{ZSH_BIN}", &bash_quote_string(bin))
412 .replace("{ZSH_BIND_LINES}", &zsh_bind_lines(trigger))
413 .replace("{ZSH_SELF_INSERT_LINES}", &zsh_self_insert_lines(self_insert))
414 .replace("{CLINK_BIN}", &lua_quote_string(bin))
415 .replace("{CLINK_BINDING}", &clink_binding(trigger))
416 .replace("{PWSH_BIN}", &pwsh_quote_string(bin))
417 .replace("{PWSH_REGISTER_LINES}", &pwsh_register_lines(trigger))
418 .replace("{PWSH_SELF_INSERT_LINES}", &pwsh_self_insert_lines(self_insert))
419 .replace("{NU_BIN}", &nu_quote_string(bin))
420 .replace("{NU_BINDINGS}", &nu_bindings(trigger, bin))
421 .replace("{NU_SELF_INSERT_BINDINGS}", &nu_self_insert_lines(self_insert))
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 mod shell_parse {
429 use super::*;
430
431 #[test]
432 fn parse_bash() {
433 assert_eq!(Shell::from_str("bash").unwrap(), Shell::Bash);
434 }
435
436 #[test]
437 fn parse_case_insensitive() {
438 assert_eq!(Shell::from_str("PWSH").unwrap(), Shell::Pwsh);
439 assert_eq!(Shell::from_str("Clink").unwrap(), Shell::Clink);
440 assert_eq!(Shell::from_str("Nu").unwrap(), Shell::Nu);
441 assert_eq!(Shell::from_str("Zsh").unwrap(), Shell::Zsh);
442 }
443
444 #[test]
448 fn shell_parse_error_display_strips_esc_sequences() {
449 let err = Shell::from_str("bash\x1b[2Jevil").unwrap_err();
450 let msg = err.to_string();
451 assert!(
452 !msg.contains('\x1b'),
453 "ShellParseError Display must not contain raw ESC: {msg:?}"
454 );
455 }
456
457 #[test]
458 fn shell_parse_error_display_strips_bel() {
459 let err = Shell::from_str("bash\x07evil").unwrap_err();
460 let msg = err.to_string();
461 assert!(
462 !msg.contains('\x07'),
463 "ShellParseError Display must not contain raw BEL: {msg:?}"
464 );
465 }
466
467 #[test]
468 fn shell_parse_error_display_strips_del() {
469 let err = Shell::from_str("bash\x7fevil").unwrap_err();
470 let msg = err.to_string();
471 assert!(
472 !msg.contains('\x7f'),
473 "ShellParseError Display must not contain DEL: {msg:?}"
474 );
475 }
476
477 #[test]
478 fn shell_parse_error_display_strips_rlo() {
479 let err = Shell::from_str("bash\u{202E}lve").unwrap_err();
480 let msg = err.to_string();
481 assert!(
482 !msg.contains('\u{202E}'),
483 "ShellParseError Display must not contain RLO U+202E: {msg:?}"
484 );
485 }
486
487 #[test]
488 fn shell_parse_error_display_strips_bom() {
489 let err = Shell::from_str("bash\u{FEFF}evil").unwrap_err();
490 let msg = err.to_string();
491 assert!(
492 !msg.contains('\u{FEFF}'),
493 "ShellParseError Display must not contain BOM U+FEFF: {msg:?}"
494 );
495 }
496
497 #[test]
498 fn shell_parse_error_display_strips_zwsp() {
499 let err = Shell::from_str("ba\u{200B}sh").unwrap_err();
500 let msg = err.to_string();
501 assert!(
502 !msg.contains('\u{200B}'),
503 "ShellParseError Display must not contain ZWSP U+200B: {msg:?}"
504 );
505 }
506
507 #[test]
508 fn parse_unknown_errors() {
509 let err = Shell::from_str("fish").unwrap_err();
510 assert_eq!(err.0, "fish");
511 }
512
513 } mod script_generation {
516 use super::*;
517
518 #[test]
519 fn export_script_contains_bin() {
520 let config = Config {
521 version: 1,
522 keybind: crate::model::KeybindConfig {
523 trigger: crate::model::PerShellKey {
524 default: Some(TriggerKey::Space),
525 ..Default::default()
526 },
527 ..crate::model::KeybindConfig::default()
528 },
529 precache: crate::model::PrecacheConfig::default(),
530 abbr: vec![],
531 };
532 for shell in [Shell::Bash, Shell::Zsh, Shell::Pwsh, Shell::Clink, Shell::Nu] {
533 let script = export_script(shell, "my-runex", Some(&config));
534 assert!(
535 script.contains("my-runex"),
536 "{shell:?} script must contain the bin name"
537 );
538 }
539 }
540
541 #[test]
542 fn bash_script_has_bind() {
543 let s = export_script(
544 Shell::Bash,
545 "runex",
546 Some(&Config {
547 version: 1,
548 keybind: crate::model::KeybindConfig {
549 trigger: crate::model::PerShellKey {
550 default: Some(TriggerKey::Space),
551 ..Default::default()
552 },
553 ..crate::model::KeybindConfig::default()
554 },
555 precache: crate::model::PrecacheConfig::default(),
556 abbr: vec![],
557 }),
558 );
559 assert!(s.contains("bind -x"), "bash bootstrap must use bind -x");
565 assert!(
566 s.contains("hook --shell bash"),
567 "bash bootstrap must invoke `runex hook --shell bash`"
568 );
569 assert!(
570 s.contains("'runex' hook --shell bash"),
571 "bash bootstrap must quote the executable name"
572 );
573 assert!(!s.contains("{BASH_BIND_LINES}"), "bash script must resolve bind lines");
574 }
575
576 #[test]
577 fn pwsh_script_has_psreadline() {
578 let s = export_script(
579 Shell::Pwsh,
580 "runex",
581 Some(&Config {
582 version: 1,
583 keybind: crate::model::KeybindConfig {
584 trigger: crate::model::PerShellKey {
585 default: Some(TriggerKey::Space),
586 ..Default::default()
587 },
588 ..crate::model::KeybindConfig::default()
589 },
590 precache: crate::model::PrecacheConfig::default(),
591 abbr: vec![],
592 }),
593 );
594 assert!(s.contains("Set-PSReadLineKeyHandler"), "pwsh script must use PSReadLine");
595 assert!(
596 !s.contains("Set-PSReadLineKeyHandler -Chord 'Tab' -Function Complete"),
597 "pwsh script must not clobber the user's Tab binding"
598 );
599 assert!(
600 s.contains("'runex' @hookArgs") || s.contains("'runex' hook"),
601 "pwsh bootstrap must invoke runex with hook args"
602 );
603 assert!(
604 s.contains("hook"),
605 "pwsh bootstrap must invoke `runex hook`"
606 );
607 assert!(!s.contains("{PWSH_REGISTER_LINES}"), "pwsh script must resolve register lines");
608 }
609
610 #[test]
611 fn pwsh_script_has_paste_guard() {
612 let s = export_script(
617 Shell::Pwsh,
618 "runex",
619 Some(&Config {
620 version: 1,
621 keybind: crate::model::KeybindConfig {
622 trigger: crate::model::PerShellKey {
623 default: Some(TriggerKey::Space),
624 ..Default::default()
625 },
626 ..crate::model::KeybindConfig::default()
627 },
628 precache: crate::model::PrecacheConfig::default(),
629 abbr: vec![],
630 }),
631 );
632 assert!(s.contains("__runex_queued_key_count"), "pwsh must retain paste guard helper");
633 assert!(s.contains("_queuedKeys"), "pwsh must probe PSReadLine's _queuedKeys field");
634 assert!(s.contains("--paste-pending"), "pwsh must forward paste state to `runex hook`");
635 }
636
637 #[test]
638 fn zsh_script_has_zle_widget() {
639 let s = export_script(
640 Shell::Zsh,
641 "runex",
642 Some(&Config {
643 version: 1,
644 keybind: crate::model::KeybindConfig {
645 trigger: crate::model::PerShellKey {
646 default: Some(TriggerKey::Space),
647 ..Default::default()
648 },
649 ..crate::model::KeybindConfig::default()
650 },
651 precache: crate::model::PrecacheConfig::default(),
652 abbr: vec![],
653 }),
654 );
655 assert!(s.contains("zle -N __runex_expand"), "zsh script must register a zle widget");
656 assert!(s.contains(r#"bindkey " " __runex_expand"#), "zsh script must bind the trigger key");
657 assert!(s.contains("LBUFFER"), "zsh script must inspect the text before the cursor");
658 assert!(s.contains("RBUFFER"), "zsh script must inspect the text after the cursor");
659 assert!(
660 s.contains("'runex' hook --shell zsh"),
661 "zsh bootstrap must invoke `runex hook --shell zsh`"
662 );
663 }
664
665 #[test]
666 fn clink_script_has_clink() {
667 let s = export_script(
668 Shell::Clink,
669 "runex",
670 Some(&Config {
671 version: 1,
672 keybind: crate::model::KeybindConfig {
673 trigger: crate::model::PerShellKey {
674 default: Some(TriggerKey::Space),
675 ..Default::default()
676 },
677 ..crate::model::KeybindConfig::default()
678 },
679 precache: crate::model::PrecacheConfig::default(),
680 abbr: vec![],
681 }),
682 );
683 assert!(s.contains("clink"), "clink script must reference clink");
684 assert!(s.contains("local RUNEX_BIN = \"runex\""), "clink script must quote the executable");
685 assert!(
686 s.contains("hook --shell clink"),
687 "clink bootstrap must invoke `runex hook --shell clink`"
688 );
689 assert!(
690 !s.contains("local RUNEX_KNOWN"),
691 "clink bootstrap must not embed token lookup table (moved to `runex hook`)"
692 );
693 assert!(s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]], "emacs")"#), "clink script must bind the trigger key in emacs mode");
694 assert!(s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]], "vi-insert")"#), "clink script must bind the trigger key in vi insert mode");
695 assert!(s.contains("rl_buffer:getcursor()"), "clink script must inspect the cursor");
696 assert!(!s.contains("clink.onfilterinput"), "clink script must not use onfilterinput for realtime expansion");
697 }
698
699 #[test]
700 fn clink_script_uses_alt_space_sequence() {
701 let config = Config {
702 version: 1,
703 keybind: crate::model::KeybindConfig {
704 trigger: crate::model::PerShellKey {
705 default: Some(TriggerKey::AltSpace),
706 ..Default::default()
707 },
708 ..crate::model::KeybindConfig::default()
709 },
710 precache: crate::model::PrecacheConfig::default(),
711 abbr: vec![],
712 };
713 let s = export_script(Shell::Clink, "runex", Some(&config));
714 assert!(
715 s.contains(r#"pcall(rl.setbinding, [["\e "]], [["luafunc:runex_expand"]], "emacs")"#),
716 "clink script must use the alt-space sequence"
717 );
718 }
719
720 #[test]
721 fn nu_script_has_keybindings() {
722 let s = export_script(
723 Shell::Nu,
724 "runex",
725 Some(&Config {
726 version: 1,
727 keybind: crate::model::KeybindConfig {
728 trigger: crate::model::PerShellKey {
729 default: Some(TriggerKey::Space),
730 ..Default::default()
731 },
732 ..crate::model::KeybindConfig::default()
733 },
734 precache: crate::model::PrecacheConfig::default(),
735 abbr: vec![],
736 }),
737 );
738 assert!(s.contains("keybindings"), "nu script must reference keybindings");
739 assert!(s.contains("commandline get-cursor"), "nu script must inspect the cursor");
740 }
741
742 #[test]
743 fn bash_script_uses_keybind_override() {
744 let config = Config {
745 version: 1,
746 keybind: crate::model::KeybindConfig {
747 trigger: crate::model::PerShellKey {
748 bash: Some(TriggerKey::AltSpace),
749 ..Default::default()
750 },
751 ..Default::default()
752 },
753 precache: crate::model::PrecacheConfig::default(),
754 abbr: vec![],
755 };
756 let s = export_script(Shell::Bash, "runex", Some(&config));
757 assert!(s.contains("\\e "), "bash script must use the configured key chord");
758 }
759
760 #[test]
764 fn export_script_placeholder_bin_does_not_cause_second_order_substitution() {
765 use crate::model::{Config, KeybindConfig, TriggerKey};
766 let config = Config {
767 version: 1,
768 keybind: KeybindConfig {
769 trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
770 ..Default::default()
771 },
772 precache: crate::model::PrecacheConfig::default(),
773 abbr: vec![],
774 };
775
776 let cases: &[(&str, Shell, &str)] = &[
777 ("{BASH_BIN}", Shell::Bash, "'{BASH_BIN}'"),
778 ("{ZSH_BIN}", Shell::Zsh, "'{ZSH_BIN}'"),
779 ("{PWSH_BIN}", Shell::Pwsh, "'{PWSH_BIN}'"),
780 ];
781 for (placeholder, shell, expected_quoted) in cases {
782 let s = export_script(*shell, placeholder, Some(&config));
783 assert!(
784 s.contains(expected_quoted),
785 "bin={placeholder:?} for {shell:?} must appear as quoted literal {expected_quoted:?} in script"
786 );
787 }
788 }
789
790 #[test]
794 fn bash_script_does_not_eval_debug_trap() {
795 let config = Config {
796 version: 1,
797 keybind: crate::model::KeybindConfig {
798 trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
799 ..Default::default()
800 },
801 precache: crate::model::PrecacheConfig::default(),
802 abbr: vec![],
803 };
804 let s = export_script(Shell::Bash, "runex", Some(&config));
805 assert!(
806 !s.contains("eval \"$runex_debug_trap\"") && !s.contains("eval '$runex_debug_trap'"),
807 "bash script must not eval the captured debug trap: {s}"
808 );
809 }
810
811 #[test]
812 fn bash_script_does_not_embed_known_tokens() {
813 let config = Config {
819 version: 1,
820 keybind: crate::model::KeybindConfig::default(),
821 precache: crate::model::PrecacheConfig::default(),
822 abbr: vec![crate::model::Abbr {
823 key: "gcm".into(),
824 expand: crate::model::PerShellString::All("git commit -m".into()),
825 when_command_exists: None,
826 }],
827 };
828 let s = export_script(Shell::Bash, "runex", Some(&config));
829 assert!(!s.contains("'gcm'"), "bash bootstrap must not embed tokens anymore");
830 assert!(!s.contains("__runex_is_known_token"), "legacy helper removed");
831 }
832
833 #[test]
834 fn pwsh_script_uses_global_keybind() {
835 let config = Config {
836 version: 1,
837 keybind: crate::model::KeybindConfig {
838 trigger: crate::model::PerShellKey {
839 default: Some(TriggerKey::Tab),
840 ..Default::default()
841 },
842 ..Default::default()
843 },
844 precache: crate::model::PrecacheConfig::default(),
845 abbr: vec![],
846 };
847 let s = export_script(Shell::Pwsh, "runex", Some(&config));
848 assert!(
849 s.contains("__runex_register_expand_handler 'Tab'"),
850 "pwsh script must use the configured chord"
851 );
852 }
853
854 #[test]
855 fn pwsh_script_uses_spacebar_name_for_alt_space() {
856 let config = Config {
857 version: 1,
858 keybind: crate::model::KeybindConfig {
859 trigger: crate::model::PerShellKey {
860 pwsh: Some(TriggerKey::AltSpace),
861 ..Default::default()
862 },
863 ..Default::default()
864 },
865 precache: crate::model::PrecacheConfig::default(),
866 abbr: vec![],
867 };
868 let s = export_script(Shell::Pwsh, "runex", Some(&config));
869 assert!(
870 s.contains("__runex_register_expand_handler 'Alt+Spacebar'"),
871 "pwsh script must register Alt+Space using Spacebar"
872 );
873 }
874
875 #[test]
876 fn pwsh_script_does_not_embed_known_tokens() {
877 let config = Config {
880 version: 1,
881 keybind: crate::model::KeybindConfig::default(),
882 precache: crate::model::PrecacheConfig::default(),
883 abbr: vec![crate::model::Abbr {
884 key: "gcm".into(),
885 expand: crate::model::PerShellString::All("git commit -m".into()),
886 when_command_exists: None,
887 }],
888 };
889 let s = export_script(Shell::Pwsh, "runex", Some(&config));
890 assert!(!s.contains("'gcm' { return $true }"), "pwsh must not embed tokens");
891 assert!(!s.contains("__runex_is_known_token"), "legacy helper removed");
892 }
893
894 #[test]
895 fn no_keybinds_means_no_handlers() {
896 let s = export_script(Shell::Bash, "runex", Some(&Config {
897 version: 1,
898 keybind: crate::model::KeybindConfig::default(),
899 precache: crate::model::PrecacheConfig::default(),
900 abbr: vec![],
901 }));
902 assert!(!s.contains("bind -x"), "bash script should not bind keys by default");
903 assert!(!s.contains(r#"bind -r"#), "bash script should not remove keybinds when no trigger is configured");
904
905 let s = export_script(Shell::Pwsh, "runex", Some(&Config {
906 version: 1,
907 keybind: crate::model::KeybindConfig::default(),
908 precache: crate::model::PrecacheConfig::default(),
909 abbr: vec![],
910 }));
911 assert!(
912 !s.contains("__runex_register_expand_handler '"),
913 "pwsh script should not register expand handlers by default"
914 );
915 assert!(
916 !s.contains("Set-PSReadLineKeyHandler -Chord ' ' -Function SelfInsert"),
917 "pwsh script should not clobber default key handlers when no trigger is configured"
918 );
919
920 let s = export_script(Shell::Clink, "runex", Some(&Config {
921 version: 1,
922 keybind: crate::model::KeybindConfig::default(),
923 precache: crate::model::PrecacheConfig::default(),
924 abbr: vec![],
925 }));
926 assert!(
927 !s.contains("rl.setbinding("),
928 "clink script should not register handlers by default"
929 );
930 }
931
932 #[test]
937 fn bin_single_quote_is_escaped_in_bash() {
938 let s = export_script(Shell::Bash, "run'ex", None);
939 assert!(s.contains(r"'run'\''ex'"), "bash: single quote must be escaped as '\\''");
940 }
941
942 #[test]
943 fn bin_single_quote_is_escaped_in_zsh() {
944 let s = export_script(Shell::Zsh, "run'ex", None);
945 assert!(s.contains(r"'run'\''ex'"), "zsh: single quote must be escaped as '\\''");
946 }
947
948 #[test]
949 fn bin_single_quote_is_escaped_in_pwsh() {
950 let s = export_script(Shell::Pwsh, "run'ex", None);
951 assert!(s.contains("'run''ex'"), "pwsh: single quote must be doubled");
952 }
953
954 #[test]
955 fn bin_double_quote_is_escaped_in_clink() {
956 let s = export_script(Shell::Clink, r#"run"ex"#, None);
957 assert!(s.contains(r#""run\"ex""#), "clink: double quote must be escaped");
958 }
959
960 #[test]
961 fn bin_with_special_chars_is_safe_in_nu() {
962 let s = export_script(Shell::Nu, "runex; echo INJECTED", None);
963 assert!(
966 !s.contains("; echo INJECTED") || s.contains(r#"^"runex; echo INJECTED""#),
967 "nu: bin value must be quoted; got:\n{s}"
968 );
969 for line in s.lines() {
971 let trimmed = line.trim_start();
972 assert!(
973 !trimmed.starts_with("echo INJECTED"),
974 "nu: unquoted injection detected: {line}"
975 );
976 }
977 }
978
979 #[test]
984 fn nu_bin_uses_caret_external_command_syntax() {
985 use crate::model::{Config, KeybindConfig, TriggerKey};
986 let config = Config {
987 version: 1,
988 keybind: KeybindConfig {
989 trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
990 ..Default::default()
991 },
992 precache: crate::model::PrecacheConfig::default(),
993 abbr: vec![],
994 };
995 let s = export_script(Shell::Nu, "runex", Some(&config));
996 assert!(
997 s.contains("^\\\"runex\\\""),
998 "nu: bin inside cmd string must use ^\\\"...\\\" syntax, got snippet: {:?}",
999 s.lines().find(|l| l.contains("runex")).unwrap_or("<not found>")
1000 );
1001 }
1002
1003 #[test]
1004 fn nu_bin_with_special_chars_uses_caret_syntax() {
1005 use crate::model::{Config, KeybindConfig, TriggerKey};
1006 let config = Config {
1007 version: 1,
1008 keybind: KeybindConfig {
1009 trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
1010 ..Default::default()
1011 },
1012 precache: crate::model::PrecacheConfig::default(),
1013 abbr: vec![],
1014 };
1015 let s = export_script(Shell::Nu, "my\"app", Some(&config));
1016 assert!(s.contains("^\\\"my\\\\\\\"app\\\""), "nu: special chars must be escaped in embedded context: {s}");
1017 }
1018
1019 #[test]
1023 fn nu_bin_in_cmd_string_does_not_break_outer_quotes() {
1024 use crate::model::{Config, KeybindConfig, TriggerKey};
1025 let config = Config {
1026 version: 1,
1027 keybind: KeybindConfig {
1028 trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
1029 ..Default::default()
1030 },
1031 precache: crate::model::PrecacheConfig::default(),
1032 abbr: vec![],
1033 };
1034 let s = export_script(Shell::Nu, "runex", Some(&config));
1035 let cmd_start = s.find("cmd: \"").expect("cmd: block not found");
1036 let cmd_block = &s[cmd_start..];
1037 assert!(
1038 cmd_block.contains("^\\\"runex\\\""),
1039 "nu: bin inside cmd string must use ^\\\"...\\\" syntax (escaped quotes), got:\n{}",
1040 cmd_block.lines().find(|l| l.contains("runex")).unwrap_or("<not found>")
1041 );
1042 }
1043
1044 } mod quote_functions {
1047 use super::*;
1048
1049 #[test]
1050 fn nu_quote_string_escapes_newline() {
1051 let s = nu_quote_string("run\nex");
1052 assert!(!s.contains('\n'), "nu_quote_string must escape newline: {s}");
1053 assert!(s.contains("\\n"), "expected \\n escape: {s}");
1054 }
1055
1056 #[test]
1057 fn nu_quote_string_escapes_carriage_return() {
1058 let s = nu_quote_string("run\rex");
1059 assert!(!s.contains('\r'), "nu_quote_string must escape CR: {s}");
1060 assert!(s.contains("\\r"), "expected \\r escape: {s}");
1061 }
1062
1063 #[test]
1074 fn nu_hook_invocation_uses_separate_line_and_cursor_args() {
1075 use crate::model::{Config, KeybindConfig, TriggerKey};
1076 let config = Config {
1077 version: 1,
1078 keybind: KeybindConfig {
1079 trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
1080 ..Default::default()
1081 },
1082 precache: crate::model::PrecacheConfig::default(),
1083 abbr: vec![],
1084 };
1085 let s = export_script(Shell::Nu, "runex", Some(&config));
1086 assert!(
1087 s.contains("hook --shell nu --line $line --cursor $cursor"),
1088 "Nu bootstrap must pass buffer state as separate --line/--cursor args: {s}"
1089 );
1090 assert!(s.contains("from json"), "Nu bootstrap must parse hook output via `from json`: {s}");
1094 }
1095
1096 #[test]
1097 fn nu_bin_newline_does_not_inject_into_cmd_block() {
1098 use crate::model::{Config, KeybindConfig, TriggerKey};
1099 let config = Config {
1100 version: 1,
1101 keybind: KeybindConfig {
1102 trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
1103 ..Default::default()
1104 },
1105 precache: crate::model::PrecacheConfig::default(),
1106 abbr: vec![],
1107 };
1108 let s = export_script(Shell::Nu, "runex\nsource /tmp/evil.nu\n", Some(&config));
1109 let lines: Vec<&str> = s.lines().collect();
1110 assert!(
1111 !lines.iter().any(|l| l.trim() == "source /tmp/evil.nu"),
1112 "newline must not create an injected source line: {s}"
1113 );
1114 }
1115
1116 #[test]
1117 fn bash_quote_string_drops_newline() {
1118 let s = bash_quote_string("run\nex");
1119 assert!(!s.contains('\n'), "bash_quote_string must drop newline: {s:?}");
1120 assert!(!s.contains("$'"), "dollar-quote ANSI-C form must not be used: {s:?}");
1121 assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1122 }
1123
1124 #[test]
1125 fn bash_quote_string_drops_carriage_return() {
1126 let s = bash_quote_string("run\rex");
1127 assert!(!s.contains('\r'), "bash_quote_string must drop CR: {s:?}");
1128 assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1129 }
1130
1131 #[test]
1132 fn bash_quote_string_escapes_nul() {
1133 let s = bash_quote_string("run\x00ex");
1134 assert!(!s.contains('\0'), "bash_quote_string must drop NUL: {s:?}");
1135 }
1136
1137 #[test]
1138 fn pwsh_quote_string_drops_newline() {
1139 let s = pwsh_quote_string("run\nex");
1140 assert!(!s.contains('\n'), "pwsh_quote_string must drop newline: {s:?}");
1141 assert!(!s.contains("'`"), "backtick-concat form must not be used: {s:?}");
1142 assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1143 }
1144
1145 #[test]
1146 fn pwsh_quote_string_drops_carriage_return() {
1147 let s = pwsh_quote_string("run\rex");
1148 assert!(!s.contains('\r'), "pwsh_quote_string must drop CR: {s:?}");
1149 assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1150 }
1151
1152 #[test]
1153 fn pwsh_quote_string_escapes_nul() {
1154 let s = pwsh_quote_string("run\x00ex");
1155 assert!(!s.contains('\0'), "pwsh_quote_string must drop NUL: {s:?}");
1156 }
1157
1158 #[test]
1159 fn nu_quote_string_escapes_nul() {
1160 let s = nu_quote_string("run\x00ex");
1161 assert!(!s.contains('\0'), "nu_quote_string must drop NUL: {s:?}");
1162 }
1163
1164
1165 #[test]
1166 fn bash_quote_string_newline_safe_in_eval_context() {
1167 let line = bash_quote_string("runex\necho INJECTED");
1168 assert!(!line.contains('\n'), "literal newline must not appear: {line:?}");
1169 assert!(!line.contains("$'"), "dollar-quote ANSI-C form must not be used (eval injection risk): {line:?}");
1170 }
1171
1172 #[test]
1173 fn bash_quote_string_cr_safe_in_eval_context() {
1174 let line = bash_quote_string("runex\recho INJECTED");
1175 assert!(!line.contains('\r'), "literal CR must not appear: {line:?}");
1176 assert!(!line.contains("$'"), "dollar-quote ANSI-C form must not be used: {line:?}");
1177 }
1178
1179 #[test]
1184 fn lua_quote_string_escapes_nul() {
1185 let s = lua_quote_string("run\x00ex");
1186 assert!(!s.contains('\0'), "lua_quote_string must not produce literal NUL: {s:?}");
1187 }
1188
1189 #[test]
1190 fn lua_quote_string_escapes_tab() {
1191 let s = lua_quote_string("run\tex");
1192 assert!(!s.contains('\t'), "lua_quote_string must escape tab: {s:?}");
1193 }
1194
1195 #[test]
1196 fn nu_quote_string_nul_is_dropped_not_embedded() {
1197 let s = nu_quote_string("run\x00ex");
1198 assert!(!s.contains("\\u{0000}"), "NUL must be dropped, not embedded as \\u{{0000}}: {s:?}");
1199 assert!(!s.contains('\0'), "literal NUL must not appear: {s:?}");
1200 assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1201 }
1202
1203 #[test]
1206 fn nu_quote_string_embedded_preserves_non_ascii_unicode() {
1207 let input = "caf\u{00E9}";
1208 let embedded = nu_quote_string_embedded(input);
1209 assert!(
1210 std::str::from_utf8(embedded.as_bytes()).is_ok(),
1211 "nu_quote_string_embedded must produce valid UTF-8: {embedded:?}"
1212 );
1213 assert!(
1214 embedded.contains('\u{00E9}'),
1215 "nu_quote_string_embedded must preserve non-ASCII char U+00E9: {embedded:?}"
1216 );
1217 }
1218
1219 #[test]
1220 fn pwsh_quote_string_newline_not_using_backtick_concat() {
1221 let s = pwsh_quote_string("run\nex");
1222 assert!(!s.contains('\n'), "literal newline must not appear: {s:?}");
1223 assert!(!s.contains("'`"), "backtick-concat form must not be used (token split risk): {s:?}");
1224 }
1225
1226 } mod regression_issues {
1229 use super::*;
1230
1231 #[test]
1234 fn clink_script_double_quote_in_bin_does_not_inject_into_popen() {
1235 let s = export_script(Shell::Clink, "run\"ex", Some(&Config {
1236 version: 1,
1237 keybind: crate::model::KeybindConfig::default(),
1238 precache: crate::model::PrecacheConfig::default(),
1239 abbr: vec![],
1240 }));
1241 assert!(
1242 !s.contains(r#"'"' .. RUNEX_BIN .. '"'"#),
1243 "io.popen must not wrap RUNEX_BIN in shell double-quotes: {s}"
1244 );
1245 }
1246
1247 #[test]
1248 fn clink_script_bin_with_double_quote_uses_single_quote_shell_wrapping() {
1249 let s = export_script(Shell::Clink, "run\"ex", Some(&Config {
1250 version: 1,
1251 keybind: crate::model::KeybindConfig::default(),
1252 precache: crate::model::PrecacheConfig::default(),
1253 abbr: vec![],
1254 }));
1255 assert!(
1256 s.contains("runex_shell_quote"),
1257 "clink script must use a shell-quoting helper for RUNEX_BIN in io.popen: {s}"
1258 );
1259 }
1260
1261
1262 #[test]
1271 fn clink_io_popen_command_is_wrapped_in_extra_pair_of_quotes() {
1272 let s = export_script(Shell::Clink, "runex", None);
1273 assert!(
1274 s.contains("local cmd = '\"' .. runex_shell_quote(RUNEX_BIN)"),
1275 "clink script must prepend a literal '\"' before runex_shell_quote(RUNEX_BIN): {s}"
1276 );
1277 assert!(
1278 s.contains("' 2>&1\"'"),
1279 "clink script must append a literal '\"' after `2>&1`: {s}"
1280 );
1281 }
1282
1283 #[test]
1284 fn nu_quote_string_escapes_tab() {
1285 let s = nu_quote_string("run\tex");
1286 assert!(!s.contains('\t'), "nu_quote_string must escape tab: {s:?}");
1287 assert!(s.contains("\\t"), "expected \\t escape: {s:?}");
1288 }
1289
1290
1291 #[test]
1292 fn bash_quote_string_drops_unicode_line_separator() {
1293 let s = bash_quote_string("run\u{2028}ex");
1294 assert!(!s.contains('\u{2028}'), "bash_quote_string must drop U+2028: {s:?}");
1295 }
1296
1297 #[test]
1298 fn pwsh_quote_string_drops_unicode_line_separator() {
1299 let s = pwsh_quote_string("run\u{2028}ex");
1300 assert!(!s.contains('\u{2028}'), "pwsh_quote_string must drop U+2028: {s:?}");
1301 }
1302
1303 #[test]
1304 fn nu_quote_string_drops_unicode_line_separator() {
1305 let s = nu_quote_string("run\u{2028}ex");
1306 assert!(!s.contains('\u{2028}'), "nu_quote_string must drop U+2028: {s:?}");
1307 }
1308
1309
1310 #[test]
1311 fn nu_quote_string_drops_del() {
1312 let s = nu_quote_string("run\x7fex");
1313 assert!(!s.contains('\x7f'), "nu_quote_string must drop DEL (\\x7f): {s:?}");
1314 }
1315
1316 #[test]
1317 fn nu_quote_string_escapes_dollar_sign() {
1318 let s = nu_quote_string("run$exenv");
1319 let raw_dollar = s
1320 .char_indices()
1321 .filter(|(_, c)| *c == '$')
1322 .any(|(i, _)| i == 0 || s.as_bytes()[i - 1] != b'\\');
1323 assert!(
1324 !raw_dollar,
1325 "nu_quote_string must escape '$' to prevent Nu variable interpolation: {s:?}"
1326 );
1327 assert!(s.contains("\\$"), "expected \\$ escape sequence in: {s:?}");
1328 }
1329
1330 #[test]
1335 fn nu_quote_string_embedded_escapes_dollar_sign() {
1336 let s = nu_quote_string_embedded("run$exenv");
1337 let bytes = s.as_bytes();
1338 for i in 0..bytes.len() {
1339 if bytes[i] == b'$' {
1340 let mut preceding_backslashes = 0usize;
1341 let mut j = i;
1342 while j > 0 && bytes[j - 1] == b'\\' {
1343 preceding_backslashes += 1;
1344 j -= 1;
1345 }
1346 assert!(
1347 preceding_backslashes % 2 == 1,
1348 "nu_quote_string_embedded: '$' at byte {i} has {preceding_backslashes} preceding backslashes \
1349 (even = Nu interpolation NOT suppressed). Full output: {s:?}"
1350 );
1351 }
1352 }
1353 }
1354
1355 #[test]
1358 fn nu_quote_string_drops_remaining_c0_control_chars() {
1359 let dangerous_c0: &[char] = &[
1360 '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07',
1361 '\x08', '\x0b', '\x0c', '\x0e', '\x0f',
1362 '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17',
1363 '\x18', '\x19', '\x1a', '\x1b',
1364 '\x1c', '\x1d', '\x1e', '\x1f',
1365 ];
1366 for &ch in dangerous_c0 {
1367 let input = format!("run{}ex", ch);
1368 let s = nu_quote_string(&input);
1369 assert!(
1370 !s.contains(ch),
1371 "nu_quote_string must drop C0 control U+{:04X}: {s:?}",
1372 ch as u32
1373 );
1374 }
1375 }
1376
1377 #[test]
1378 fn pwsh_self_insert_shift_space_when_configured() {
1379 let config = Config {
1380 version: 1,
1381 keybind: crate::model::KeybindConfig {
1382 self_insert: crate::model::PerShellKey {
1383 pwsh: Some(TriggerKey::ShiftSpace),
1384 ..Default::default()
1385 },
1386 ..crate::model::KeybindConfig::default()
1387 },
1388 precache: crate::model::PrecacheConfig::default(),
1389 abbr: vec![],
1390 };
1391 let s = export_script(Shell::Pwsh, "runex", Some(&config));
1392 assert!(
1393 s.contains("Set-PSReadLineKeyHandler -Chord 'Shift+Spacebar' -Function SelfInsert"),
1394 "pwsh script must bind Shift+Spacebar to SelfInsert when self_insert = shift-space: {s}"
1395 );
1396 }
1397
1398 #[test]
1399 fn pwsh_self_insert_alt_space_when_configured() {
1400 let config = Config {
1401 version: 1,
1402 keybind: crate::model::KeybindConfig {
1403 self_insert: crate::model::PerShellKey {
1404 pwsh: Some(TriggerKey::AltSpace),
1405 ..Default::default()
1406 },
1407 ..crate::model::KeybindConfig::default()
1408 },
1409 precache: crate::model::PrecacheConfig::default(),
1410 abbr: vec![],
1411 };
1412 let s = export_script(Shell::Pwsh, "runex", Some(&config));
1413 assert!(
1414 s.contains("Set-PSReadLineKeyHandler -Chord 'Alt+Spacebar' -Function SelfInsert"),
1415 "pwsh script must bind Alt+Spacebar to SelfInsert when self_insert = alt-space: {s}"
1416 );
1417 }
1418
1419 #[test]
1420 fn pwsh_no_self_insert_when_not_configured() {
1421 let config = Config {
1422 version: 1,
1423 keybind: crate::model::KeybindConfig {
1424 trigger: crate::model::PerShellKey {
1425 default: Some(TriggerKey::Space),
1426 ..Default::default()
1427 },
1428 ..crate::model::KeybindConfig::default()
1429 },
1430 precache: crate::model::PrecacheConfig::default(),
1431 abbr: vec![],
1432 };
1433 let s = export_script(Shell::Pwsh, "runex", Some(&config));
1434 assert!(
1435 !s.contains("SelfInsert"),
1436 "pwsh script must not bind SelfInsert when self_insert is not configured (even if trigger is Space): {s}"
1437 );
1438 }
1439
1440 #[test]
1441 fn nu_self_insert_shift_space_when_configured() {
1442 let config = Config {
1443 version: 1,
1444 keybind: crate::model::KeybindConfig {
1445 self_insert: crate::model::PerShellKey {
1446 nu: Some(TriggerKey::ShiftSpace),
1447 ..Default::default()
1448 },
1449 ..crate::model::KeybindConfig::default()
1450 },
1451 precache: crate::model::PrecacheConfig::default(),
1452 abbr: vec![],
1453 };
1454 let s = export_script(Shell::Nu, "runex", Some(&config));
1455 assert!(
1456 s.contains("runex_self_insert") && s.contains("modifier: shift") && s.contains("keycode: space"),
1457 "nu script must include shift+space self-insert binding when self_insert = shift-space: {s}"
1458 );
1459 }
1460
1461 #[test]
1462 fn nu_self_insert_alt_space_when_configured() {
1463 let config = Config {
1464 version: 1,
1465 keybind: crate::model::KeybindConfig {
1466 self_insert: crate::model::PerShellKey {
1467 nu: Some(TriggerKey::AltSpace),
1468 ..Default::default()
1469 },
1470 ..crate::model::KeybindConfig::default()
1471 },
1472 precache: crate::model::PrecacheConfig::default(),
1473 abbr: vec![],
1474 };
1475 let s = export_script(Shell::Nu, "runex", Some(&config));
1476 assert!(
1477 s.contains("runex_self_insert") && s.contains("modifier: alt") && s.contains("keycode: space"),
1478 "nu script must include alt+space self-insert binding when self_insert = alt-space: {s}"
1479 );
1480 }
1481
1482 #[test]
1483 fn nu_no_self_insert_when_not_configured() {
1484 let config = Config {
1485 version: 1,
1486 keybind: crate::model::KeybindConfig {
1487 trigger: crate::model::PerShellKey {
1488 default: Some(TriggerKey::Space),
1489 ..Default::default()
1490 },
1491 ..crate::model::KeybindConfig::default()
1492 },
1493 precache: crate::model::PrecacheConfig::default(),
1494 abbr: vec![],
1495 };
1496 let s = export_script(Shell::Nu, "runex", Some(&config));
1497 assert!(
1498 !s.contains("insertchar"),
1499 "nu script must not contain insertchar append block when self_insert is not configured: {s}"
1500 );
1501 }
1502
1503 #[test]
1504 fn bash_self_insert_alt_space_when_configured() {
1505 let config = Config {
1506 version: 1,
1507 keybind: crate::model::KeybindConfig {
1508 self_insert: crate::model::PerShellKey {
1509 bash: Some(TriggerKey::AltSpace),
1510 ..Default::default()
1511 },
1512 ..crate::model::KeybindConfig::default()
1513 },
1514 precache: crate::model::PrecacheConfig::default(),
1515 abbr: vec![],
1516 };
1517 let s = export_script(Shell::Bash, "runex", Some(&config));
1518 assert!(
1519 s.contains(r#"bind '"\e ": self-insert'"#),
1520 "bash script must bind Alt+Space to self-insert when self_insert = alt-space: {s}"
1521 );
1522 }
1523
1524 #[test]
1525 fn bash_no_self_insert_when_not_configured() {
1526 let config = Config {
1527 version: 1,
1528 keybind: crate::model::KeybindConfig {
1529 trigger: crate::model::PerShellKey {
1530 default: Some(TriggerKey::Space),
1531 ..Default::default()
1532 },
1533 ..crate::model::KeybindConfig::default()
1534 },
1535 precache: crate::model::PrecacheConfig::default(),
1536 abbr: vec![],
1537 };
1538 let s = export_script(Shell::Bash, "runex", Some(&config));
1539 assert!(
1540 !s.contains("self-insert"),
1541 "bash script must not contain self-insert when self_insert is not configured: {s}"
1542 );
1543 }
1544
1545 #[test]
1546 fn zsh_self_insert_alt_space_when_configured() {
1547 let config = Config {
1548 version: 1,
1549 keybind: crate::model::KeybindConfig {
1550 self_insert: crate::model::PerShellKey {
1551 zsh: Some(TriggerKey::AltSpace),
1552 ..Default::default()
1553 },
1554 ..crate::model::KeybindConfig::default()
1555 },
1556 precache: crate::model::PrecacheConfig::default(),
1557 abbr: vec![],
1558 };
1559 let s = export_script(Shell::Zsh, "runex", Some(&config));
1560 assert!(
1561 s.contains(r#"bindkey "^[ " self-insert"#),
1562 "zsh script must bind Alt+Space to self-insert when self_insert = alt-space: {s}"
1563 );
1564 }
1565
1566 #[test]
1567 fn zsh_no_self_insert_when_not_configured() {
1568 let config = Config {
1569 version: 1,
1570 keybind: crate::model::KeybindConfig {
1571 trigger: crate::model::PerShellKey {
1572 default: Some(TriggerKey::Space),
1573 ..Default::default()
1574 },
1575 ..crate::model::KeybindConfig::default()
1576 },
1577 precache: crate::model::PrecacheConfig::default(),
1578 abbr: vec![],
1579 };
1580 let s = export_script(Shell::Zsh, "runex", Some(&config));
1581 assert!(
1582 !s.contains("self-insert"),
1583 "zsh script must not contain self-insert when self_insert is not configured: {s}"
1584 );
1585 }
1586
1587 #[test]
1588 fn trigger_for_shell_override_takes_precedence_over_default() {
1589 let config = Config {
1590 version: 1,
1591 keybind: crate::model::KeybindConfig {
1592 trigger: crate::model::PerShellKey {
1593 default: Some(TriggerKey::Space),
1594 bash: Some(TriggerKey::AltSpace),
1595 ..Default::default()
1596 },
1597 ..Default::default()
1598 },
1599 precache: crate::model::PrecacheConfig::default(),
1600 abbr: vec![],
1601 };
1602 let bash_s = export_script(Shell::Bash, "runex", Some(&config));
1604 assert!(bash_s.contains("\\e "), "bash must use AltSpace override, not default Space");
1605 let zsh_s = export_script(Shell::Zsh, "runex", Some(&config));
1607 assert!(zsh_s.contains(r#"bindkey " " __runex_expand"#), "zsh must fall back to default Space");
1608 }
1609
1610 #[test]
1611 fn trigger_for_falls_back_to_default() {
1612 let config = Config {
1613 version: 1,
1614 keybind: crate::model::KeybindConfig {
1615 trigger: crate::model::PerShellKey {
1616 default: Some(TriggerKey::Tab),
1617 ..Default::default()
1618 },
1619 ..Default::default()
1620 },
1621 precache: crate::model::PrecacheConfig::default(),
1622 abbr: vec![],
1623 };
1624 let nu_s = export_script(Shell::Nu, "runex", Some(&config));
1626 assert!(nu_s.contains("tab"), "nu must fall back to default Tab trigger");
1627 }
1628
1629 #[test]
1630 fn clink_ignores_shell_specific_trigger_fields() {
1631 let config = Config {
1632 version: 1,
1633 keybind: crate::model::KeybindConfig {
1634 trigger: crate::model::PerShellKey {
1635 default: Some(TriggerKey::Space),
1636 bash: Some(TriggerKey::AltSpace),
1637 ..Default::default()
1638 },
1639 ..Default::default()
1640 },
1641 precache: crate::model::PrecacheConfig::default(),
1642 abbr: vec![],
1643 };
1644 let s = export_script(Shell::Clink, "runex", Some(&config));
1646 assert!(
1647 s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]]"#),
1648 "clink must use trigger.default (Space), not the bash-specific AltSpace: {s}"
1649 );
1650 }
1651
1652 } mod unicode_edge_cases {
1655 use super::*;
1656
1657 #[test]
1658 fn lua_quote_string_drops_del() {
1659 let s = lua_quote_string("run\x7fex");
1660 assert!(!s.contains('\x7f'), "lua_quote_string must drop DEL: {s:?}");
1661 }
1662
1663 #[test]
1664 fn lua_quote_string_drops_unicode_line_separators() {
1665 for ch in ['\u{0085}', '\u{2028}', '\u{2029}'] {
1666 let input = format!("run{ch}ex");
1667 let s = lua_quote_string(&input);
1668 assert!(!s.contains(ch), "lua_quote_string must drop U+{:04X}: {s:?}", ch as u32);
1669 }
1670 }
1671
1672 #[test]
1675 fn lua_quote_string_decimal_escape_not_ambiguous_with_following_digit() {
1676 let s = lua_quote_string("\x010");
1677 assert!(
1678 !s.contains("\\10"),
1679 "lua_quote_string: \\x01 + '0' must not produce ambiguous \\10: {s:?}"
1680 );
1681 assert!(
1682 s.contains("\\001"),
1683 "lua_quote_string: \\x01 must be escaped as \\001: {s:?}"
1684 );
1685 }
1686
1687 } mod case_pattern_globs {
1694 use super::*;
1695
1696 } }