ase_shell/commands/
parse.rs1use std::{env, path::PathBuf};
2
3use glob::glob;
4
5use super::targets::{StderrTarget, StdoutTarget};
6
7pub fn needs_more_input(raw: &str) -> bool {
8 let r = raw.trim();
9 !r.is_empty() && shlex::split(r).is_none()
10}
11
12pub struct ParsedInvocation {
13 pub cmd_name: String,
14 pub args: Vec<String>,
15 pub stdout: StdoutTarget,
16 pub stderr: StderrTarget,
17}
18
19impl ParsedInvocation {
20 pub fn from_tokens(tokens: Vec<String>) -> Option<Self> {
21 let mut iter = tokens.into_iter();
22 let cmd_name = iter.next()?;
23 let rest: Vec<String> = iter.collect();
24
25 let mut stdout = StdoutTarget::Stdout;
26 let mut stderr = StderrTarget::Stderr;
27 let mut args = Vec::new();
28 let mut i = 0;
29
30 while i < rest.len() {
31 match rest[i].as_str() {
32 ">>" | "1>>" => {
33 if i + 1 < rest.len() {
34 let target = expand_single_path(&rest[i + 1]);
35 stdout = StdoutTarget::Append(PathBuf::from(target));
36 i += 2;
37 continue;
38 } else {
39 args.push(rest[i].clone());
40 i += 1;
41 continue;
42 }
43 }
44 ">" | "1>" => {
45 if i + 1 < rest.len() {
46 let target = expand_single_path(&rest[i + 1]);
47 stdout = StdoutTarget::Overwrite(PathBuf::from(target));
48 i += 2;
49 continue;
50 } else {
51 args.push(rest[i].clone());
52 i += 1;
53 continue;
54 }
55 }
56 "2>>" => {
57 if i + 1 < rest.len() {
58 let target = expand_single_path(&rest[i + 1]);
59 stderr = StderrTarget::Append(PathBuf::from(target));
60 i += 2;
61 continue;
62 } else {
63 args.push(rest[i].clone());
64 i += 1;
65 continue;
66 }
67 }
68 "2>" => {
69 if i + 1 < rest.len() {
70 let target = expand_single_path(&rest[i + 1]);
71 stderr = StderrTarget::Overwrite(PathBuf::from(target));
72 i += 2;
73 continue;
74 } else {
75 args.push(rest[i].clone());
76 i += 1;
77 continue;
78 }
79 }
80 _ => {
81 let expanded = expand_arg(&rest[i]);
82 args.extend(expanded);
83 i += 1;
84 }
85 }
86 }
87
88 Some(ParsedInvocation {
89 cmd_name,
90 args,
91 stdout,
92 stderr,
93 })
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use std::fs;
101
102 #[test]
103 fn expands_env_vars_and_tilde_in_args() {
104 unsafe {
105 std::env::set_var("FOO", "bar");
106 std::env::set_var("HOME", "/home/testuser");
107 }
108
109 let tokens = vec![
110 "echo".to_string(),
111 "$FOO".to_string(),
112 "x$FOO".to_string(),
113 "~".to_string(),
114 "~/dir".to_string(),
115 ];
116
117 let inv = ParsedInvocation::from_tokens(tokens).unwrap();
118 assert_eq!(
119 inv.args,
120 vec![
121 "bar".to_string(),
122 "xbar".to_string(),
123 "/home/testuser".to_string(),
124 "/home/testuser/dir".to_string()
125 ]
126 );
127 }
128
129 #[test]
130 fn expands_globs_in_args() {
131 let dir = std::env::temp_dir().join(format!("ase_glob_test_{}", std::process::id()));
132 fs::create_dir_all(&dir).unwrap();
133
134 let a = dir.join("a.txt");
135 let b = dir.join("b.txt");
136 let c = dir.join("c.log");
137 fs::write(&a, "a").unwrap();
138 fs::write(&b, "b").unwrap();
139 fs::write(&c, "c").unwrap();
140
141 let old_cwd = std::env::current_dir().unwrap();
142 std::env::set_current_dir(&dir).unwrap();
143
144 let tokens = vec!["echo".to_string(), "*.txt".to_string()];
145 let inv = ParsedInvocation::from_tokens(tokens).unwrap();
146
147 let mut args = inv.args.clone();
148 args.sort();
149 assert_eq!(args, vec!["a.txt".to_string(), "b.txt".to_string()]);
150
151 std::env::set_current_dir(old_cwd).unwrap();
152 fs::remove_file(&a).ok();
153 fs::remove_file(&b).ok();
154 fs::remove_file(&c).ok();
155 fs::remove_dir_all(&dir).ok();
156 }
157
158 #[test]
159 fn expands_tilde_and_vars_in_redirection_paths() {
160 let home = std::env::var("HOME").unwrap_or_default();
161
162 let tokens = vec![
163 "echo".to_string(),
164 "hi".to_string(),
165 ">".to_string(),
166 "~/out.txt".to_string(),
167 "2>".to_string(),
168 "/tmp/dummy.log".to_string(),
169 ];
170
171 let inv = ParsedInvocation::from_tokens(tokens).unwrap();
172
173 match inv.stdout {
174 StdoutTarget::Overwrite(p) => {
175 let expected = PathBuf::from(&home).join("out.txt");
176 assert_eq!(p, expected);
177 }
178 _ => panic!("unexpected stdout target"),
179 }
180
181 }
184}
185
186fn expand_arg(token: &str) -> Vec<String> {
187 let token = expand_vars_and_tilde(token);
188
189 if has_glob_meta(&token) {
190 let mut results = Vec::new();
191 if let Ok(paths) = glob(&token) {
192 for entry in paths.flatten() {
193 if let Some(s) = entry.to_str() {
194 results.push(s.to_string());
195 }
196 }
197 }
198 if !results.is_empty() {
199 return results;
200 }
201 }
202
203 vec![token]
204}
205
206fn expand_single_path(token: &str) -> String {
207 expand_vars_and_tilde(token)
208}
209
210fn expand_vars_and_tilde(token: &str) -> String {
211 let mut s = token.to_string();
212
213 if let Some(home) = env::var_os("HOME") {
214 if let Some(stripped) = s.strip_prefix('~') {
215 if stripped.is_empty() || stripped.starts_with('/') {
216 if let Some(home_str) = home.to_str() {
217 s = format!("{home_str}{stripped}");
218 }
219 }
220 }
221 }
222
223 s = expand_vars(&s);
224 s
225}
226
227fn expand_vars(s: &str) -> String {
228 let mut out = String::with_capacity(s.len());
229 let bytes = s.as_bytes();
230 let mut i = 0;
231
232 while i < bytes.len() {
233 if bytes[i] == b'$' {
234 let start = i + 1;
235 let mut j = start;
236 while j < bytes.len() && (bytes[j] == b'_' || bytes[j].is_ascii_alphanumeric()) {
237 j += 1;
238 }
239 if j > start {
240 let name = &s[start..j];
241 if let Ok(val) = env::var(name) {
242 out.push_str(&val);
243 }
244 i = j;
245 continue;
246 }
247 }
248
249 out.push(bytes[i] as char);
250 i += 1;
251 }
252
253 out
254}
255
256fn has_glob_meta(s: &str) -> bool {
257 s.chars().any(|c| matches!(c, '*' | '?' | '['))
258}