use std::collections::HashSet;
use super::parser::Node;
fn name_length(text: &[char]) -> usize {
let Some(&first) = text.first() else {
return 0;
};
if !(first.is_ascii_alphabetic() || first == '_') {
return 0;
}
text.iter()
.position(|character| !(character.is_ascii_alphanumeric() || *character == '_'))
.unwrap_or(text.len())
}
fn parameter_length(text: &[char]) -> usize {
let name = name_length(text);
if name > 0 {
return name;
}
let Some(&first) = text.first() else {
return 0;
};
if first.is_ascii_digit() {
return text
.iter()
.position(|character| !character.is_ascii_digit())
.unwrap_or(text.len());
}
usize::from("@*#?$".contains(first))
}
fn compact(parts: Vec<Node>) -> Vec<Node> {
let mut merged: Vec<Node> = Vec::new();
for part in parts {
if part.term == "literal" {
if let Some(previous) = merged.last_mut() {
if previous.term == "literal" {
let text = previous.text.get_or_insert_with(String::new);
text.push_str(part.text.as_deref().unwrap_or_default());
continue;
}
}
}
merged.push(part);
}
merged.retain(|part| part.term != "literal" || part.text.as_deref() != Some(""));
merged
}
fn expansion_node(name: &str, declared: &HashSet<String>) -> Node {
if !name.is_empty() && name.chars().all(|character| character.is_ascii_digit()) {
if name == "0" {
return Node::leaf("script-name", name);
}
let index = name.parse::<u32>().unwrap_or(1).saturating_sub(1);
return Node::leaf("positional", index.to_string());
}
match name {
"@" | "*" => Node::leaf("all-arguments", name),
"#" => Node::leaf("argument-count", name),
"?" => Node::leaf("exit-status", name),
"$" => Node::leaf("process-id", name),
_ => Node::leaf(
if declared.contains(name) {
"variable"
} else {
"env-variable"
},
name,
),
}
}
fn brace_expansion(body: &str, declared: &HashSet<String>) -> Node {
let characters: Vec<char> = body.chars().collect();
let name = parameter_length(&characters);
if name == characters.len() && name > 0 {
return expansion_node(body, declared);
}
if name > 0 {
let mut cursor = name;
if characters.get(cursor) == Some(&':') {
cursor += 1;
}
if matches!(characters.get(cursor), Some('-') | Some('=')) {
let variable: String = characters[..name].iter().collect();
let fallback: String = characters[cursor + 1..].iter().collect();
return Node::with_text(
"default-expansion",
vec![
expansion_node(&variable, declared),
Node::leaf("literal", fallback),
],
variable,
);
}
}
Node::leaf("unsupported-expansion", body)
}
fn matching_delimiter(text: &[char], start: usize, open: char, close: char) -> usize {
let mut depth = 0usize;
for (offset, &character) in text[start..].iter().enumerate() {
if character == open {
depth += 1;
} else if character == close {
depth -= 1;
if depth == 0 {
return start + offset;
}
}
}
text.len()
}
fn expansion_at(
text: &[char],
index: usize,
declared: &HashSet<String>,
parse_substitution: &dyn Fn(&str) -> Node,
) -> Option<(Node, usize)> {
let character = text[index];
if character == '`' {
let close = text[index + 1..]
.iter()
.position(|&c| c == '`')
.map_or(text.len(), |offset| index + 1 + offset);
let body: String = text[index + 1..close.min(text.len())].iter().collect();
return Some((
Node::new("substitution", vec![parse_substitution(&body)]),
close + 1,
));
}
if character != '$' {
return None;
}
if text.get(index + 1) == Some(&'(') {
let close = matching_delimiter(text, index + 1, '(', ')');
let body: String = text[(index + 2).min(close)..close].iter().collect();
return Some((
Node::new("substitution", vec![parse_substitution(&body)]),
close + 1,
));
}
if text.get(index + 1) == Some(&'{') {
let close = matching_delimiter(text, index + 1, '{', '}');
let body: String = text[(index + 2).min(close)..close].iter().collect();
return Some((brace_expansion(&body, declared), close + 1));
}
let rest = &text[index + 1..];
let length = name_length(rest);
if length > 0 {
let name: String = rest[..length].iter().collect();
return Some((expansion_node(&name, declared), index + 1 + length));
}
let special = *rest.first()?;
if "@*#?$".contains(special) || special.is_ascii_digit() {
return Some((expansion_node(&special.to_string(), declared), index + 2));
}
None
}
pub fn expand_word(
text: &str,
declared: &HashSet<String>,
parse_substitution: &dyn Fn(&str) -> Node,
) -> Vec<Node> {
let text: Vec<char> = text.chars().collect();
let mut parts: Vec<Node> = Vec::new();
let mut literal = String::new();
let mut single_quoted = false;
let mut index = 0usize;
while index < text.len() {
let character = text[index];
if character == '\'' {
single_quoted = !single_quoted;
literal.push(character);
index += 1;
continue;
}
if single_quoted {
literal.push(character);
index += 1;
continue;
}
if character == '\\' {
literal.extend(text[index..(index + 2).min(text.len())].iter());
index += 2;
continue;
}
if let Some((part, end)) = expansion_at(&text, index, declared, parse_substitution) {
if !literal.is_empty() {
parts.push(Node::leaf("literal", std::mem::take(&mut literal)));
}
parts.push(part);
index = end;
continue;
}
literal.push(character);
index += 1;
}
if !literal.is_empty() {
parts.push(Node::leaf("literal", literal));
}
compact(parts)
}
#[cfg(test)]
mod tests {
use super::*;
fn no_substitution(source: &str) -> Node {
Node::leaf("word", source)
}
fn expand(text: &str, declared: &[&str]) -> Vec<(String, Option<String>)> {
let declared: HashSet<String> = declared.iter().map(|name| name.to_string()).collect();
expand_word(text, &declared, &no_substitution)
.into_iter()
.map(|part| (part.term, part.text))
.collect()
}
#[test]
fn splits_a_word_into_typed_parts() {
assert_eq!(
expand("\"${DIR}/log\"", &["DIR"]),
vec![
("literal".to_string(), Some("\"".to_string())),
("variable".to_string(), Some("DIR".to_string())),
("literal".to_string(), Some("/log\"".to_string())),
]
);
}
#[test]
fn classifies_special_parameters() {
assert_eq!(
expand("$1 $# $? $HOME", &[]),
vec![
("positional".to_string(), Some("0".to_string())),
("literal".to_string(), Some(" ".to_string())),
("argument-count".to_string(), Some("#".to_string())),
("literal".to_string(), Some(" ".to_string())),
("exit-status".to_string(), Some("?".to_string())),
("literal".to_string(), Some(" ".to_string())),
("env-variable".to_string(), Some("HOME".to_string())),
]
);
}
#[test]
fn keeps_single_quoted_text_unexpanded() {
assert_eq!(
expand("'$HOME'", &[]),
vec![("literal".to_string(), Some("'$HOME'".to_string()))]
);
}
#[test]
fn reads_a_default_expansion() {
let declared = HashSet::new();
let parts = expand_word("${NAME:-anon}", &declared, &no_substitution);
assert_eq!(parts[0].term, "default-expansion");
assert_eq!(parts[0].text.as_deref(), Some("NAME"));
assert_eq!(parts[0].children[1].text.as_deref(), Some("anon"));
}
}