use std::borrow::Cow;
use std::path::{Path, PathBuf};
#[derive(Default, Clone, Debug)]
pub struct ExpandContext<'a> {
pub current_path: Option<&'a Path>,
pub alt_path: Option<&'a Path>,
pub cword: Option<Cow<'a, str>>,
pub cwword: Option<Cow<'a, str>>,
pub cwd: Option<&'a Path>,
}
fn apply_modifier(s: &str, modifier: &str) -> Option<String> {
match modifier {
"p" => {
let p = Path::new(s);
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
};
let result = abs.canonicalize().unwrap_or(abs);
Some(result.display().to_string())
}
"h" => {
let p = Path::new(s);
match p.parent() {
None => Some(String::new()),
Some(parent) if parent == Path::new("") => Some(".".to_string()),
Some(parent) => Some(parent.display().to_string()),
}
}
"t" => {
let p = Path::new(s);
match p.file_name() {
Some(name) => Some(name.to_string_lossy().into_owned()),
None => Some(s.to_string()),
}
}
_ => None,
}
}
fn apply_modifiers(mut value: String, modifiers: &str) -> Option<String> {
if modifiers.is_empty() {
return Some(value);
}
for modifier in modifiers.split(':') {
if modifier.is_empty() {
continue;
}
value = apply_modifier(&value, modifier)?;
}
Some(value)
}
pub fn expand_filename(ctx: &ExpandContext<'_>, token: &str) -> Option<String> {
if token == "%" || token.starts_with("%:") {
let base = ctx.current_path?.display().to_string();
let mods = token
.strip_prefix('%')
.unwrap()
.strip_prefix(':')
.unwrap_or("");
return apply_modifiers(base, mods);
}
if token == "#" || token.starts_with("#:") {
let base = ctx.alt_path?.display().to_string();
let mods = token
.strip_prefix('#')
.unwrap()
.strip_prefix(':')
.unwrap_or("");
return apply_modifiers(base, mods);
}
if token == "<cword>" || token.starts_with("<cword>:") {
let base = ctx.cword.as_deref()?.to_string();
let mods = token
.strip_prefix("<cword>")
.unwrap()
.strip_prefix(':')
.unwrap_or("");
return apply_modifiers(base, mods);
}
if token == "<cWORD>" || token.starts_with("<cWORD>:") {
let base = ctx.cwword.as_deref()?.to_string();
let mods = token
.strip_prefix("<cWORD>")
.unwrap()
.strip_prefix(':')
.unwrap_or("");
return apply_modifiers(base, mods);
}
None
}
fn extract_token(s: &str) -> (&str, &str) {
let base_len = if s.starts_with("<cword>") || s.starts_with("<cWORD>") {
7
} else if s.starts_with('%') || s.starts_with('#') {
1
} else {
return (s, "");
};
let mut end = base_len;
let bytes = s.as_bytes();
while end < bytes.len() && bytes[end] == b':' {
let colon_pos = end;
end += 1; let seg_start = end;
while end < bytes.len() && bytes[end].is_ascii_alphabetic() {
end += 1;
}
if end == seg_start {
end = colon_pos;
break;
}
}
(&s[..end], &s[end..])
}
pub fn expand_args(ctx: &ExpandContext<'_>, args: &str) -> String {
let mut result = String::with_capacity(args.len());
let mut i = 0;
let bytes = args.as_bytes();
let len = bytes.len();
while i < len {
if bytes[i] == b'\\' && i + 1 < len && (bytes[i + 1] == b'%' || bytes[i + 1] == b'#') {
result.push(bytes[i + 1] as char);
i += 2;
continue;
}
let rest = &args[i..];
if bytes[i] == b'%'
|| bytes[i] == b'#'
|| rest.starts_with("<cword>")
|| rest.starts_with("<cWORD>")
{
let (token, after) = extract_token(rest);
match expand_filename(ctx, token) {
Some(expanded) => {
result.push_str(&expanded);
}
None => {
result.push_str(token);
}
}
i += token.len();
let _ = after; continue;
}
let ch = args[i..].chars().next().unwrap();
result.push(ch);
i += ch.len_utf8();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn percent_with_current_path() {
let ctx = ExpandContext {
current_path: Some(Path::new("src/main.rs")),
..Default::default()
};
assert_eq!(expand_filename(&ctx, "%"), Some("src/main.rs".to_string()));
}
#[test]
fn percent_no_current_path_returns_none() {
let ctx = ExpandContext::default();
assert_eq!(expand_filename(&ctx, "%"), None);
}
#[test]
fn hash_with_alt_path() {
let ctx = ExpandContext {
alt_path: Some(Path::new("src/lib.rs")),
..Default::default()
};
assert_eq!(expand_filename(&ctx, "#"), Some("src/lib.rs".to_string()));
}
#[test]
fn cword_with_value() {
let ctx = ExpandContext {
cword: Some(Cow::Borrowed("foo")),
..Default::default()
};
assert_eq!(expand_filename(&ctx, "<cword>"), Some("foo".to_string()));
}
#[test]
fn cword_none_returns_none() {
let ctx = ExpandContext::default();
assert_eq!(expand_filename(&ctx, "<cword>"), None);
}
#[test]
fn percent_colon_p_absolute() {
let ctx = ExpandContext {
current_path: Some(Path::new("Cargo.toml")),
..Default::default()
};
let expanded = expand_filename(&ctx, "%:p").unwrap();
assert!(
Path::new(&expanded).is_absolute(),
":p must produce absolute path, got {expanded:?}"
);
assert!(expanded.ends_with("Cargo.toml"), "must end with Cargo.toml");
}
#[test]
fn percent_colon_h_parent_dir() {
let ctx = ExpandContext {
current_path: Some(Path::new("src/main.rs")),
..Default::default()
};
assert_eq!(expand_filename(&ctx, "%:h"), Some("src".to_string()));
}
#[test]
fn percent_colon_t_basename() {
let ctx = ExpandContext {
current_path: Some(Path::new("src/main.rs")),
..Default::default()
};
assert_eq!(expand_filename(&ctx, "%:t"), Some("main.rs".to_string()));
}
#[test]
fn percent_colon_p_colon_h_parent_of_absolute() {
let ctx = ExpandContext {
current_path: Some(Path::new("src/main.rs")),
..Default::default()
};
let expanded = expand_filename(&ctx, "%:p:h").unwrap();
let p = Path::new(&expanded);
assert!(p.is_absolute(), ":p:h must be absolute, got {expanded:?}");
assert_eq!(
p.file_name().map(|n| n.to_string_lossy().to_string()),
Some("src".to_string())
);
}
#[test]
fn percent_colon_t_colon_p_silly_but_valid() {
let ctx = ExpandContext {
current_path: Some(Path::new("src/main.rs")),
..Default::default()
};
let expanded = expand_filename(&ctx, "%:t:p").unwrap();
let p = Path::new(&expanded);
assert!(p.is_absolute(), "%:t:p must be absolute, got {expanded:?}");
assert!(
expanded.ends_with("main.rs"),
"%:t:p must end with main.rs, got {expanded:?}"
);
}
#[test]
fn percent_bogus_modifier_returns_none() {
let ctx = ExpandContext {
current_path: Some(Path::new("src/main.rs")),
..Default::default()
};
assert_eq!(expand_filename(&ctx, "%:bogus"), None);
}
#[test]
fn expand_args_percent_dot_txt_suffix() {
let ctx = ExpandContext {
current_path: Some(Path::new("src/main.rs")),
..Default::default()
};
assert_eq!(expand_args(&ctx, "%.txt"), "src/main.rs.txt");
}
#[test]
fn expand_args_backslash_percent_literal() {
let ctx = ExpandContext {
current_path: Some(Path::new("src/main.rs")),
..Default::default()
};
assert_eq!(expand_args(&ctx, r"\%"), "%");
}
#[test]
fn expand_args_cword_standalone() {
let ctx = ExpandContext {
cword: Some(Cow::Borrowed("foo")),
..Default::default()
};
assert_eq!(expand_args(&ctx, "<cword>"), "foo");
}
#[test]
fn expand_args_cword_surrounded() {
let ctx = ExpandContext {
cword: Some(Cow::Borrowed("foo")),
..Default::default()
};
assert_eq!(expand_args(&ctx, "a <cword> b"), "a foo b");
}
#[test]
fn expand_args_percent_in_middle() {
let ctx = ExpandContext {
current_path: Some(Path::new("src/main.rs")),
..Default::default()
};
assert_eq!(expand_args(&ctx, "foo % bar"), "foo src/main.rs bar");
}
#[test]
fn expand_args_unexpandable_left_in_place() {
let ctx = ExpandContext::default();
assert_eq!(expand_args(&ctx, "e %"), "e %");
}
#[test]
fn expand_args_hash_backslash() {
let ctx = ExpandContext {
alt_path: Some(Path::new("src/lib.rs")),
..Default::default()
};
assert_eq!(expand_args(&ctx, r"\#"), "#");
}
#[test]
fn percent_colon_h_root_returns_empty() {
let ctx = ExpandContext {
current_path: Some(Path::new("/")),
..Default::default()
};
let result = expand_filename(&ctx, "%:h");
assert!(result.is_some());
}
#[test]
fn percent_colon_h_no_separator() {
let ctx = ExpandContext {
current_path: Some(Path::new("main.rs")),
..Default::default()
};
assert_eq!(expand_filename(&ctx, "%:h"), Some(".".to_string()));
}
}