use std::collections::BTreeMap;
use std::ops::Range;
const ESCAPE: u8 = b'\\';
const OPEN: u8 = b'<';
const CLOSE: u8 = b'>';
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Placeholder {
pub name: String,
pub default: Option<String>,
pub span: Range<usize>,
}
pub fn parse(cmd: &str) -> Vec<Placeholder> {
let bytes = cmd.as_bytes();
let mut found = Vec::new();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
ESCAPE if bytes.get(i + 1) == Some(&OPEN) => i += 2,
OPEN => match scan(cmd, i) {
Some(placeholder) => {
i = placeholder.span.end;
found.push(placeholder);
}
None => i += 1,
},
_ => i += 1,
}
}
found
}
pub fn names(cmd: &str) -> Vec<String> {
let mut seen = Vec::new();
for placeholder in parse(cmd) {
if !seen.contains(&placeholder.name) {
seen.push(placeholder.name);
}
}
seen
}
pub fn render(cmd: &str, values: &BTreeMap<String, String>) -> String {
let bytes = cmd.as_bytes();
let mut out = String::with_capacity(cmd.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
ESCAPE if bytes.get(i + 1) == Some(&OPEN) => {
out.push(OPEN as char);
i += 2;
}
OPEN => match scan(cmd, i) {
Some(placeholder) => {
let value = values
.get(&placeholder.name)
.or(placeholder.default.as_ref())
.map(String::as_str)
.unwrap_or_default();
out.push_str(value);
i = placeholder.span.end;
}
None => {
out.push(OPEN as char);
i += 1;
}
},
ESCAPE => {
out.push(ESCAPE as char);
i += 1;
}
_ => {
let start = i;
i += 1;
while i < bytes.len() && !matches!(bytes[i], OPEN | ESCAPE) {
i += 1;
}
out.push_str(&cmd[start..i]);
}
}
}
out
}
fn scan(cmd: &str, start: usize) -> Option<Placeholder> {
let bytes = cmd.as_bytes();
let name_start = start + 1;
let mut i = name_start;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'_' | b'-')) {
i += 1;
}
if i == name_start {
return None;
}
let name = cmd[name_start..i].to_string();
let default = if bytes.get(i) == Some(&b':') {
let value_start = i + 1;
while i < bytes.len() && bytes[i] != CLOSE {
i += 1;
}
Some(cmd[value_start..i].to_string())
} else {
None
};
if bytes.get(i) != Some(&CLOSE) {
return None;
}
Some(Placeholder {
name,
default,
span: start..i + 1,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parsed(cmd: &str) -> Vec<(String, Option<String>)> {
parse(cmd)
.into_iter()
.map(|p| (p.name, p.default))
.collect()
}
#[test]
fn reads_a_bare_placeholder() {
assert_eq!(
parsed("docker logs <container>"),
[("container".to_string(), None)]
);
}
#[test]
fn reads_an_inline_default() {
assert_eq!(
parsed("kubectl get pods -n <namespace:default>"),
[("namespace".to_string(), Some("default".to_string()))]
);
}
#[test]
fn reads_hyphenated_names_in_order() {
assert_eq!(
parsed("kubectl port-forward svc/<service> <local-port>:<remote-port>"),
[
("service".to_string(), None),
("local-port".to_string(), None),
("remote-port".to_string(), None),
]
);
}
#[test]
fn ignores_go_template_syntax() {
let cmd = r#"docker ps --format "table {{.Names}}\t{{.Status}}""#;
assert!(parse(cmd).is_empty());
}
#[test]
fn ignores_shell_redirection() {
assert!(parse("mysql -u root < dump.sql").is_empty());
assert!(parse("make 2>&1 | tee log").is_empty());
assert!(parse("cat <file.txt").is_empty());
}
#[test]
fn escaped_bracket_is_not_a_placeholder() {
let cmd = r"echo \<literal>";
assert!(parse(cmd).is_empty());
assert_eq!(render(cmd, &BTreeMap::new()), "echo <literal>");
}
#[test]
fn render_substitutes_values_over_defaults() {
let values = BTreeMap::from([("namespace".to_string(), "prod".to_string())]);
assert_eq!(
render("kubectl get pods -n <namespace:default>", &values),
"kubectl get pods -n prod"
);
}
#[test]
fn render_falls_back_to_the_default() {
assert_eq!(
render("kubectl get pods -n <namespace:default>", &BTreeMap::new()),
"kubectl get pods -n default"
);
}
#[test]
fn render_leaves_go_templates_untouched() {
let cmd = r"docker inspect -f '{{.Id}}' <container>";
let values = BTreeMap::from([("container".to_string(), "web".to_string())]);
assert_eq!(render(cmd, &values), r"docker inspect -f '{{.Id}}' web");
}
#[test]
fn render_preserves_multibyte_text() {
let values = BTreeMap::from([("dir".to_string(), "günlük".to_string())]);
assert_eq!(
render("echo 'ölçüm →' && ls <dir>", &values),
"echo 'ölçüm →' && ls günlük"
);
}
#[test]
fn parse_handles_multibyte_text_before_a_placeholder() {
assert_eq!(parsed("echo 'ölçüm' <path>"), [("path".to_string(), None)]);
}
#[test]
fn lone_backslash_is_preserved() {
assert_eq!(
render(r"copy C:\src\file <dst>", &BTreeMap::new()),
r"copy C:\src\file "
);
}
#[test]
fn names_are_deduplicated_in_first_appearance_order() {
assert_eq!(
names("cp <src> <dst> && diff <src> <dst>"),
["src".to_string(), "dst".to_string()]
);
}
}