Skip to main content

command_stream/fy/
word_expander.rs

1//! Splits a shell word into typed parts so that every leaf of the network is
2//! translated by a rule rather than by ad-hoc string surgery.
3//!
4//! A word such as `"${DIR}/$(basename "$1").log"` becomes:
5//!   literal(") variable(DIR) literal(/) substitution(...) literal(.log")
6
7use std::collections::HashSet;
8
9use super::parser::Node;
10
11/// Length of the leading `[A-Za-z_][A-Za-z0-9_]*` run, or 0 when there is none.
12fn name_length(text: &[char]) -> usize {
13    let Some(&first) = text.first() else {
14        return 0;
15    };
16    if !(first.is_ascii_alphabetic() || first == '_') {
17        return 0;
18    }
19    text.iter()
20        .position(|character| !(character.is_ascii_alphanumeric() || *character == '_'))
21        .unwrap_or(text.len())
22}
23
24/// Length of the parameter a `${...}` body starts with: a name, a positional
25/// number, or one of the special parameters.
26fn parameter_length(text: &[char]) -> usize {
27    let name = name_length(text);
28    if name > 0 {
29        return name;
30    }
31    let Some(&first) = text.first() else {
32        return 0;
33    };
34    if first.is_ascii_digit() {
35        return text
36            .iter()
37            .position(|character| !character.is_ascii_digit())
38            .unwrap_or(text.len());
39    }
40    usize::from("@*#?$".contains(first))
41}
42
43/// Merges adjacent literal parts so the output stays compact.
44fn compact(parts: Vec<Node>) -> Vec<Node> {
45    let mut merged: Vec<Node> = Vec::new();
46    for part in parts {
47        if part.term == "literal" {
48            if let Some(previous) = merged.last_mut() {
49                if previous.term == "literal" {
50                    let text = previous.text.get_or_insert_with(String::new);
51                    text.push_str(part.text.as_deref().unwrap_or_default());
52                    continue;
53                }
54            }
55        }
56        merged.push(part);
57    }
58    merged.retain(|part| part.term != "literal" || part.text.as_deref() != Some(""));
59    merged
60}
61
62/// Classifies a `$...` expansion.
63///
64/// `declared` holds the names the script assigns itself.
65fn expansion_node(name: &str, declared: &HashSet<String>) -> Node {
66    if !name.is_empty() && name.chars().all(|character| character.is_ascii_digit()) {
67        // `$0` is the script itself; `$1` is the first user argument, which is
68        // index 0 of `std::env::args().skip(1)`. Doing the renumbering here
69        // keeps the translation rules free of arithmetic.
70        if name == "0" {
71            return Node::leaf("script-name", name);
72        }
73        let index = name.parse::<u32>().unwrap_or(1).saturating_sub(1);
74        return Node::leaf("positional", index.to_string());
75    }
76    match name {
77        "@" | "*" => Node::leaf("all-arguments", name),
78        "#" => Node::leaf("argument-count", name),
79        "?" => Node::leaf("exit-status", name),
80        "$" => Node::leaf("process-id", name),
81        // A name the script assigns becomes a binding; anything else can only
82        // come from the environment.
83        _ => Node::leaf(
84            if declared.contains(name) {
85                "variable"
86            } else {
87                "env-variable"
88            },
89            name,
90        ),
91    }
92}
93
94/// `${NAME}`, `${NAME:-default}` and `${NAME:=default}` are supported directly;
95/// anything more exotic is preserved verbatim as an environment lookup so no
96/// information is silently dropped.
97fn brace_expansion(body: &str, declared: &HashSet<String>) -> Node {
98    let characters: Vec<char> = body.chars().collect();
99    let name = parameter_length(&characters);
100    if name == characters.len() && name > 0 {
101        return expansion_node(body, declared);
102    }
103    // `NAME:-default` / `NAME:=default` / `NAME-default` / `NAME=default`
104    if name > 0 {
105        let mut cursor = name;
106        if characters.get(cursor) == Some(&':') {
107            cursor += 1;
108        }
109        if matches!(characters.get(cursor), Some('-') | Some('=')) {
110            let variable: String = characters[..name].iter().collect();
111            let fallback: String = characters[cursor + 1..].iter().collect();
112            return Node::with_text(
113                "default-expansion",
114                vec![
115                    expansion_node(&variable, declared),
116                    Node::leaf("literal", fallback),
117                ],
118                variable,
119            );
120        }
121    }
122    Node::leaf("unsupported-expansion", body)
123}
124
125fn matching_delimiter(text: &[char], start: usize, open: char, close: char) -> usize {
126    let mut depth = 0usize;
127    for (offset, &character) in text[start..].iter().enumerate() {
128        if character == open {
129            depth += 1;
130        } else if character == close {
131            depth -= 1;
132            if depth == 0 {
133                return start + offset;
134            }
135        }
136    }
137    text.len()
138}
139
140/// Reads the expansion starting at `index`, if there is one.
141///
142/// Returns the expansion node and the index just past it, or `None` when the
143/// character is ordinary text.
144fn expansion_at(
145    text: &[char],
146    index: usize,
147    declared: &HashSet<String>,
148    parse_substitution: &dyn Fn(&str) -> Node,
149) -> Option<(Node, usize)> {
150    let character = text[index];
151
152    if character == '`' {
153        let close = text[index + 1..]
154            .iter()
155            .position(|&c| c == '`')
156            .map_or(text.len(), |offset| index + 1 + offset);
157        let body: String = text[index + 1..close.min(text.len())].iter().collect();
158        return Some((
159            Node::new("substitution", vec![parse_substitution(&body)]),
160            close + 1,
161        ));
162    }
163
164    if character != '$' {
165        return None;
166    }
167
168    if text.get(index + 1) == Some(&'(') {
169        let close = matching_delimiter(text, index + 1, '(', ')');
170        let body: String = text[(index + 2).min(close)..close].iter().collect();
171        return Some((
172            Node::new("substitution", vec![parse_substitution(&body)]),
173            close + 1,
174        ));
175    }
176
177    if text.get(index + 1) == Some(&'{') {
178        let close = matching_delimiter(text, index + 1, '{', '}');
179        let body: String = text[(index + 2).min(close)..close].iter().collect();
180        return Some((brace_expansion(&body, declared), close + 1));
181    }
182
183    let rest = &text[index + 1..];
184    let length = name_length(rest);
185    if length > 0 {
186        let name: String = rest[..length].iter().collect();
187        return Some((expansion_node(&name, declared), index + 1 + length));
188    }
189    let special = *rest.first()?;
190    if "@*#?$".contains(special) || special.is_ascii_digit() {
191        return Some((expansion_node(&special.to_string(), declared), index + 2));
192    }
193    None
194}
195
196/// Expands a shell word (quotes included) into part nodes.
197///
198/// `declared` holds the variable names assigned by the script and
199/// `parse_substitution` parses the body of a command substitution into a
200/// statement node.
201pub fn expand_word(
202    text: &str,
203    declared: &HashSet<String>,
204    parse_substitution: &dyn Fn(&str) -> Node,
205) -> Vec<Node> {
206    let text: Vec<char> = text.chars().collect();
207    let mut parts: Vec<Node> = Vec::new();
208    let mut literal = String::new();
209    let mut single_quoted = false;
210    let mut index = 0usize;
211
212    while index < text.len() {
213        let character = text[index];
214
215        if character == '\'' {
216            single_quoted = !single_quoted;
217            literal.push(character);
218            index += 1;
219            continue;
220        }
221        // Inside single quotes the shell performs no expansion at all.
222        if single_quoted {
223            literal.push(character);
224            index += 1;
225            continue;
226        }
227        if character == '\\' {
228            literal.extend(text[index..(index + 2).min(text.len())].iter());
229            index += 2;
230            continue;
231        }
232
233        if let Some((part, end)) = expansion_at(&text, index, declared, parse_substitution) {
234            if !literal.is_empty() {
235                parts.push(Node::leaf("literal", std::mem::take(&mut literal)));
236            }
237            parts.push(part);
238            index = end;
239            continue;
240        }
241
242        literal.push(character);
243        index += 1;
244    }
245
246    if !literal.is_empty() {
247        parts.push(Node::leaf("literal", literal));
248    }
249    compact(parts)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn no_substitution(source: &str) -> Node {
257        Node::leaf("word", source)
258    }
259
260    fn expand(text: &str, declared: &[&str]) -> Vec<(String, Option<String>)> {
261        let declared: HashSet<String> = declared.iter().map(|name| name.to_string()).collect();
262        expand_word(text, &declared, &no_substitution)
263            .into_iter()
264            .map(|part| (part.term, part.text))
265            .collect()
266    }
267
268    #[test]
269    fn splits_a_word_into_typed_parts() {
270        assert_eq!(
271            expand("\"${DIR}/log\"", &["DIR"]),
272            vec![
273                ("literal".to_string(), Some("\"".to_string())),
274                ("variable".to_string(), Some("DIR".to_string())),
275                ("literal".to_string(), Some("/log\"".to_string())),
276            ]
277        );
278    }
279
280    #[test]
281    fn classifies_special_parameters() {
282        assert_eq!(
283            expand("$1 $# $? $HOME", &[]),
284            vec![
285                ("positional".to_string(), Some("0".to_string())),
286                ("literal".to_string(), Some(" ".to_string())),
287                ("argument-count".to_string(), Some("#".to_string())),
288                ("literal".to_string(), Some(" ".to_string())),
289                ("exit-status".to_string(), Some("?".to_string())),
290                ("literal".to_string(), Some(" ".to_string())),
291                ("env-variable".to_string(), Some("HOME".to_string())),
292            ]
293        );
294    }
295
296    #[test]
297    fn keeps_single_quoted_text_unexpanded() {
298        assert_eq!(
299            expand("'$HOME'", &[]),
300            vec![("literal".to_string(), Some("'$HOME'".to_string()))]
301        );
302    }
303
304    #[test]
305    fn reads_a_default_expansion() {
306        let declared = HashSet::new();
307        let parts = expand_word("${NAME:-anon}", &declared, &no_substitution);
308        assert_eq!(parts[0].term, "default-expansion");
309        assert_eq!(parts[0].text.as_deref(), Some("NAME"));
310        assert_eq!(parts[0].children[1].text.as_deref(), Some("anon"));
311    }
312}