use crate::domain::expand::NUMBER_PLACEHOLDER;
use crate::domain::model::{Config, Shell};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bash_double_quote_for_assoc_wraps_plain_ascii() {
assert_eq!(bash_double_quote_for_assoc("gcm"), "\"gcm\"");
}
#[test]
fn bash_double_quote_for_assoc_escapes_double_quote() {
assert_eq!(bash_double_quote_for_assoc("a\"b"), "\"a\\\"b\"");
}
#[test]
fn bash_double_quote_for_assoc_escapes_backslash() {
assert_eq!(bash_double_quote_for_assoc("a\\b"), "\"a\\\\b\"");
}
#[test]
fn bash_double_quote_for_assoc_escapes_dollar() {
assert_eq!(bash_double_quote_for_assoc("$HOME"), "\"\\$HOME\"");
}
#[test]
fn bash_double_quote_for_assoc_escapes_backtick() {
assert_eq!(bash_double_quote_for_assoc("`whoami`"), "\"\\`whoami\\`\"");
}
#[test]
fn bash_double_quote_for_assoc_drops_ascii_control_chars() {
let s = bash_double_quote_for_assoc("a\nb\tc\x01d");
assert_eq!(s, "\"abcd\"");
}
#[test]
fn bash_double_quote_for_assoc_drops_deceptive_unicode() {
let s = bash_double_quote_for_assoc("a\u{202E}b\u{FEFF}c");
assert_eq!(s, "\"abc\"");
}
#[test]
fn bash_double_quote_for_assoc_preserves_single_quote() {
assert_eq!(bash_double_quote_for_assoc("a'b"), "\"a'b\"");
}
use crate::domain::model::{Abbr, KeybindConfig, PerShellString, PrecacheConfig};
fn cfg(abbr: Vec<Abbr>) -> Config {
Config {
version: 1,
keybind: KeybindConfig::default(),
precache: PrecacheConfig::default(),
abbr,
}
}
fn plain_abbr(key: &str, expand: &str) -> Abbr {
Abbr {
key: key.into(),
expand: PerShellString::All(expand.into()),
when_command_exists: None,
number: None,
}
}
#[test]
fn exact_table_lines_emits_one_entry_per_plain_abbr() {
let c = cfg(vec![
plain_abbr("gst", "git status"),
plain_abbr("gcm", "git commit -m"),
]);
let s = exact_table_lines(&c);
assert!(s.contains("[\"gst\"]=\"git status\""), "got: {s}");
assert!(s.contains("[\"gcm\"]=\"git commit -m\""), "got: {s}");
}
#[test]
fn exact_table_lines_excludes_pattern_keys() {
let mut up = plain_abbr("up{number}", "cd {number}");
up.number = Some("../".into());
let c = cfg(vec![plain_abbr("gst", "git status"), up]);
let s = exact_table_lines(&c);
assert!(s.contains("[\"gst\"]"), "exact table should keep gst: {s}");
assert!(!s.contains("up{number}"), "exact table should drop pattern keys: {s}");
}
#[test]
fn exact_table_lines_excludes_cursor_placeholder_in_key_position_safely() {
let mut bad = plain_abbr("ok", "ok");
bad.key = "bad{}key".into();
let c = cfg(vec![plain_abbr("gst", "git status"), bad]);
let s = exact_table_lines(&c);
assert!(s.contains("[\"gst\"]"), "got: {s}");
assert!(!s.contains("bad{}key"), "got: {s}");
}
#[test]
fn exact_table_lines_uses_bash_specific_expand_value_when_bound() {
let a = Abbr {
key: "open".into(),
expand: PerShellString::ByShell {
default: Some("xdg-open".into()),
bash: Some("xdg-open --wait".into()),
zsh: None, pwsh: None, nu: None,
},
when_command_exists: None,
number: None,
};
let s = exact_table_lines(&cfg(vec![a]));
assert!(s.contains("[\"open\"]=\"xdg-open --wait\""), "got: {s}");
}
#[test]
fn exact_table_lines_skips_rules_without_bash_expand_value() {
let a = Abbr {
key: "winonly".into(),
expand: PerShellString::ByShell {
default: None,
bash: None,
zsh: None,
pwsh: Some("Get-Process".into()),
nu: None,
},
when_command_exists: None,
number: None,
};
let s = exact_table_lines(&cfg(vec![a, plain_abbr("gst", "git status")]));
assert!(!s.contains("winonly"), "got: {s}");
assert!(s.contains("[\"gst\"]"), "got: {s}");
}
#[test]
fn exact_table_lines_indents_with_four_spaces() {
let s = exact_table_lines(&cfg(vec![plain_abbr("gst", "git status")]));
assert!(s.starts_with(" "), "expected four-space indent, got: {s:?}");
}
#[test]
fn exact_table_lines_empty_for_empty_config() {
let s = exact_table_lines(&cfg(vec![]));
assert_eq!(s, "");
}
use crate::domain::model::PerShellCmds;
fn abbr_with_when_cmds(key: &str, expand: &str, cmds: Vec<&str>) -> Abbr {
Abbr {
key: key.into(),
expand: PerShellString::All(expand.into()),
when_command_exists: Some(PerShellCmds::All(
cmds.into_iter().map(String::from).collect(),
)),
number: None,
}
}
#[test]
fn cond_table_lines_emits_entry_for_single_command_guard() {
let c = cfg(vec![abbr_with_when_cmds("ls", "lsd", vec!["lsd"])]);
let s = cond_table_lines(&c);
assert!(s.contains("[\"ls\"]=\"lsd\""), "got: {s}");
}
#[test]
fn cond_table_lines_joins_multi_command_guard_with_colon() {
let c = cfg(vec![abbr_with_when_cmds(
"ks",
"kubectl get pods",
vec!["kubectl", "stern"],
)]);
let s = cond_table_lines(&c);
assert!(s.contains("[\"ks\"]=\"kubectl:stern\""), "got: {s}");
}
#[test]
fn cond_table_lines_skips_rules_without_when_command_exists() {
let c = cfg(vec![
plain_abbr("gst", "git status"),
abbr_with_when_cmds("ls", "lsd", vec!["lsd"]),
]);
let s = cond_table_lines(&c);
assert!(s.contains("[\"ls\"]"), "got: {s}");
assert!(!s.contains("[\"gst\"]"), "cond table must not list unguarded rules: {s}");
}
#[test]
fn cond_table_lines_uses_bash_specific_when_command_exists_value() {
let a = Abbr {
key: "open".into(),
expand: PerShellString::All("xdg-open".into()),
when_command_exists: Some(PerShellCmds::ByShell {
default: Some(vec!["open".into()]),
bash: Some(vec!["xdg-open".into()]),
zsh: None, pwsh: None, nu: None,
}),
number: None,
};
let s = cond_table_lines(&cfg(vec![a]));
assert!(s.contains("[\"open\"]=\"xdg-open\""), "got: {s}");
}
#[test]
fn cond_table_lines_skips_empty_command_list() {
let c = cfg(vec![abbr_with_when_cmds("nope", "noop", vec![])]);
let s = cond_table_lines(&c);
assert_eq!(s, "");
}
#[test]
fn cond_table_lines_excludes_pattern_keys() {
let mut up = abbr_with_when_cmds("up{number}", "cd {number}", vec!["pushd"]);
up.number = Some("../".into());
let s = cond_table_lines(&cfg(vec![up]));
assert_eq!(s, "");
}
fn pattern_abbr(key: &str, expand: &str, unit: &str) -> Abbr {
Abbr {
key: key.into(),
expand: PerShellString::All(expand.into()),
when_command_exists: None,
number: Some(unit.into()),
}
}
#[test]
fn pattern_table_lines_emits_entry_with_prefix_suffix_template_unit() {
let c = cfg(vec![pattern_abbr("up{number}", "cd {number}", "../")]);
let s = pattern_table_lines(&c);
assert!(
s.contains("\"up\"$'\\037'\"\"$'\\037'\"cd {number}\"$'\\037'\"../\""),
"got: {s}"
);
assert!(s.starts_with(" "), "expected four-space indent, got: {s:?}");
}
#[test]
fn pattern_table_lines_handles_prefix_and_suffix() {
let c = cfg(vec![pattern_abbr("g{number}p", "git push -n {number}", "x")]);
let s = pattern_table_lines(&c);
assert!(
s.contains("\"g\"$'\\037'\"p\"$'\\037'\"git push -n {number}\"$'\\037'\"x\""),
"got: {s}"
);
}
#[test]
fn pattern_table_lines_skips_rules_without_number_unit() {
let no_unit = Abbr {
key: "up{number}".into(),
expand: PerShellString::All("cd {number}".into()),
when_command_exists: None,
number: None,
};
let s = pattern_table_lines(&cfg(vec![no_unit]));
assert_eq!(s, "");
}
#[test]
fn pattern_table_lines_skips_rules_without_number_placeholder_in_key() {
let weird = Abbr {
key: "up".into(),
expand: PerShellString::All("cd".into()),
when_command_exists: None,
number: Some("../".into()),
};
let s = pattern_table_lines(&cfg(vec![weird]));
assert_eq!(s, "");
}
#[test]
fn pattern_table_lines_skips_rules_without_bash_expand_value() {
let a = Abbr {
key: "up{number}".into(),
expand: PerShellString::ByShell {
default: None,
bash: None,
zsh: None,
pwsh: Some("Set-Location ..".into()),
nu: None,
},
when_command_exists: None,
number: Some("../".into()),
};
let s = pattern_table_lines(&cfg(vec![a]));
assert_eq!(s, "");
}
#[test]
fn pattern_table_lines_empty_for_empty_config() {
assert_eq!(pattern_table_lines(&cfg(vec![])), "");
}
#[test]
fn bake_includes_command_position_helper() {
let c = cfg(vec![plain_abbr("gst", "git status")]);
let s = generate_cygwin_dispatcher(&c);
assert!(
s.contains("__runex_cyg_is_command_position"),
"bake dispatcher must define a pure-bash command-position helper: {s}"
);
}
#[test]
fn bake_command_position_helper_recognizes_pipeline_ops() {
let c = cfg(vec![plain_abbr("gst", "git status")]);
let s = generate_cygwin_dispatcher(&c);
for pat in [r#"*"&&""#, r#"*"||""#, r#"*"|""#, r#"*";""#] {
assert!(
s.contains(pat),
"bake helper must match pipeline operator pattern {pat}: {s}"
);
}
}
#[test]
fn bake_command_position_helper_recognizes_sudo() {
let c = cfg(vec![plain_abbr("gst", "git status")]);
let s = generate_cygwin_dispatcher(&c);
assert!(
s.contains(r#"if [ "$last_word" = "sudo" ]"#),
"bake helper must check for trailing `sudo` word: {s}"
);
}
#[test]
fn bake_expand_calls_command_position_before_lookup() {
let c = cfg(vec![plain_abbr("gst", "git status")]);
let s = generate_cygwin_dispatcher(&c);
let expand_body = &s[s.find("__runex_cyg_expand()").expect("expand fn must exist")..];
let cmd_pos_idx = expand_body
.find("__runex_cyg_is_command_position")
.expect("expand body must call the command-position helper");
let lookup_idx = expand_body
.find("__runex_cyg_lookup ")
.expect("expand body must call lookup");
assert!(
cmd_pos_idx < lookup_idx,
"command-position check must precede the abbreviation lookup; \
cmd_pos at {cmd_pos_idx}, lookup at {lookup_idx}: {expand_body}"
);
}
#[test]
fn bake_uses_substring_prefix_calc_not_glob_pattern() {
let c = cfg(vec![plain_abbr("gst", "git status")]);
let s = generate_cygwin_dispatcher(&c);
assert!(
!s.contains(r#"prefix="${left%$token}""#),
"bake expand must not use `${{left%$token}}` glob pattern (issue #9): {s}"
);
assert!(
s.contains("prefix=\"${left:0:$((${#left} - ${#token}))}\""),
"bake expand must compute prefix via substring slice (issue #9): {s}"
);
}
}
fn bash_double_quote_for_assoc(s: &str) -> String {
use crate::domain::sanitize::{is_deceptive_unicode, is_unicode_line_separator};
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'$' => out.push_str("\\$"),
'`' => out.push_str("\\`"),
c if c.is_ascii_control() => {}
c if is_unicode_line_separator(c) => {}
c if is_deceptive_unicode(c) => {}
c => out.push(c),
}
}
out.push('"');
out
}
fn exact_table_lines(config: &Config) -> String {
let mut lines = Vec::new();
for rule in &config.abbr {
if rule.key.contains('{') {
continue;
}
let Some(expand) = rule.expand.for_shell(Shell::Bash) else {
continue;
};
lines.push(format!(
" [{}]={}",
bash_double_quote_for_assoc(&rule.key),
bash_double_quote_for_assoc(expand),
));
}
lines.join("\n")
}
fn cond_table_lines(config: &Config) -> String {
let mut lines = Vec::new();
for rule in &config.abbr {
if rule.key.contains('{') {
continue;
}
let Some(cmds) = rule
.when_command_exists
.as_ref()
.and_then(|w| w.for_shell(Shell::Bash))
else {
continue;
};
if cmds.is_empty() {
continue;
}
let joined = cmds.join(":");
lines.push(format!(
" [{}]={}",
bash_double_quote_for_assoc(&rule.key),
bash_double_quote_for_assoc(&joined),
));
}
lines.join("\n")
}
fn pattern_table_lines(config: &Config) -> String {
let mut lines = Vec::new();
for rule in &config.abbr {
let Some(unit) = rule.number.as_deref() else {
continue;
};
let Some(pos) = rule.key.find(NUMBER_PLACEHOLDER) else {
continue;
};
let Some(template) = rule.expand.for_shell(Shell::Bash) else {
continue;
};
let prefix = &rule.key[..pos];
let suffix = &rule.key[pos + NUMBER_PLACEHOLDER.len()..];
let sep = "$'\\037'";
lines.push(format!(
" {prefix}{sep}{suffix}{sep}{template}{sep}{unit}",
prefix = bash_double_quote_for_assoc(prefix),
suffix = bash_double_quote_for_assoc(suffix),
template = bash_double_quote_for_assoc(template),
unit = bash_double_quote_for_assoc(unit),
sep = sep,
));
}
lines.join("\n")
}
pub(crate) fn generate_cygwin_dispatcher(config: &Config) -> String {
let exact = exact_table_lines(config);
let cond = cond_table_lines(config);
let patterns = pattern_table_lines(config);
let exact_block = if exact.is_empty() { String::new() } else { format!("\n{exact}\n") };
let cond_block = if cond.is_empty() { String::new() } else { format!("\n{cond}\n") };
let pattern_block = if patterns.is_empty() { String::new() } else { format!("\n{patterns}\n") };
format!(
r#"declare -gA __runex_abbr_expand=({exact_block})
declare -gA __runex_abbr_cond=({cond_block})
__runex_abbr_patterns=({pattern_block})
__runex_cyg_render() {{
local text="$1" pos
pos="${{text%%\{{\}}*}}"
if [ "$pos" = "$text" ]; then
__runex_out="$text"
__runex_cursor_off=""
else
__runex_cursor_off="${{#pos}}"
__runex_out="${{pos}}${{text#*\{{\}}}}"
fi
}}
__runex_cyg_lookup() {{
local key="$1" raw conds c
__runex_out=""
__runex_cursor_off=""
raw="${{__runex_abbr_expand[$key]-}}"
[ -z "$raw" ] && return
conds="${{__runex_abbr_cond[$key]-}}"
if [ -n "$conds" ]; then
local IFS=':'
for c in $conds; do command -v "$c" >/dev/null 2>&1 || return; done
fi
[ "$raw" = "$key" ] && return
__runex_cyg_render "$raw"
}}
__runex_cyg_pattern_lookup() {{
local token="$1" entry prefix suffix template unit rest n i repeated rendered
__runex_out=""
__runex_cursor_off=""
for entry in "${{__runex_abbr_patterns[@]}}"; do
IFS=$'\037' read -r prefix suffix template unit <<<"$entry"
[ "${{token#"$prefix"}}" = "$token" ] && continue
rest="${{token#"$prefix"}}"
if [ -n "$suffix" ]; then
[ "${{rest%"$suffix"}}" = "$rest" ] && continue
rest="${{rest%"$suffix"}}"
fi
[ -z "$rest" ] && continue
case "$rest" in (*[!0-9]*) continue ;; esac
n="$rest"
[ "$n" -le 0 ] 2>/dev/null && continue
[ "$n" -gt 128 ] 2>/dev/null && continue
repeated=""
for ((i=0; i<n; i++)); do repeated="${{repeated}}${{unit}}"; done
[ "${{#repeated}}" -gt 4096 ] && continue
rendered="${{template//\{{number\}}/$repeated}}"
[ "${{#rendered}}" -gt 4096 ] && continue
__runex_cyg_render "$rendered"
return
done
}}
__runex_cyg_is_command_position() {{
# Sets __runex_cmd_pos = 1 if $1 is a command-position prefix,
# 0 otherwise. Mirrors `domain::hook::is_command_position`:
# - empty / whitespace-only prefix → command position
# - ends with `&&` / `||` / `|` / `;` → command position
# - ends with the word `sudo` whose prefix is itself command
# position → command position
local prefix="$1"
while [ "${{prefix: -1}}" = " " ]; do
prefix="${{prefix:0:${{#prefix}}-1}}"
done
if [ -z "$prefix" ]; then __runex_cmd_pos=1; return; fi
case "$prefix" in
*"&&"|*"||"|*"|"|*";") __runex_cmd_pos=1; return ;;
esac
local last_word="${{prefix##* }}"
local before_last
if [ "$last_word" = "$prefix" ]; then
before_last=""
else
before_last="${{prefix:0:$((${{#prefix}} - ${{#last_word}}))}}"
fi
if [ "$last_word" = "sudo" ]; then
while [ "${{before_last: -1}}" = " " ]; do
before_last="${{before_last:0:${{#before_last}}-1}}"
done
if [ -z "$before_last" ]; then __runex_cmd_pos=1; return; fi
case "$before_last" in
*"&&"|*"||"|*"|"|*";") __runex_cmd_pos=1; return ;;
esac
fi
__runex_cmd_pos=0
}}
__runex_cyg_expand() {{
local left right token prefix
left="${{READLINE_LINE:0:READLINE_POINT}}"
right="${{READLINE_LINE:READLINE_POINT}}"
if [ -n "$right" ] && [ "${{right:0:1}}" != " " ]; then
READLINE_LINE="${{left}} ${{right}}"
READLINE_POINT=$((READLINE_POINT + 1))
return
fi
token="${{left##* }}"
if [ -z "$token" ]; then
READLINE_LINE="${{left}} ${{right}}"
READLINE_POINT=$((READLINE_POINT + 1))
return
fi
# Substring slice for prefix (= avoid `${{left%$token}}` glob
# interpretation when the token contains `?`, `*`, or `[`).
prefix="${{left:0:$((${{#left}} - ${{#token}}))}}"
# Command-position check (issue #9): if the prefix is not a
# command position, fall back to a literal space insertion
# without consulting the abbreviation tables.
__runex_cyg_is_command_position "$prefix"
if [ "$__runex_cmd_pos" -eq 0 ]; then
READLINE_LINE="${{left}} ${{right}}"
READLINE_POINT=$((READLINE_POINT + 1))
return
fi
__runex_cyg_lookup "$token"
if [ -z "$__runex_out" ]; then __runex_cyg_pattern_lookup "$token"; fi
if [ -z "$__runex_out" ]; then
READLINE_LINE="${{left}} ${{right}}"
READLINE_POINT=$((READLINE_POINT + 1))
return
fi
if [ -n "$__runex_cursor_off" ]; then
READLINE_LINE="${{prefix}}${{__runex_out}}${{right}}"
READLINE_POINT=$(( ${{#prefix}} + __runex_cursor_off ))
else
READLINE_LINE="${{prefix}}${{__runex_out}} ${{right}}"
READLINE_POINT=$(( ${{#prefix}} + ${{#__runex_out}} + 1 ))
fi
}}
"#,
exact_block = exact_block,
cond_block = cond_block,
pattern_block = pattern_block,
)
}