1use std::collections::HashSet;
8
9use super::parser::Node;
10
11fn 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
24fn 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
43fn 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
62fn expansion_node(name: &str, declared: &HashSet<String>) -> Node {
66 if !name.is_empty() && name.chars().all(|character| character.is_ascii_digit()) {
67 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 _ => Node::leaf(
84 if declared.contains(name) {
85 "variable"
86 } else {
87 "env-variable"
88 },
89 name,
90 ),
91 }
92}
93
94fn 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 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
140fn 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
196pub 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 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}