Skip to main content

ase_shell/commands/
parse.rs

1use 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    // stderr target is covered by other tests; here we just care that we don't
182    // panic and that tilde expansion happened correctly for stdout.
183  }
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}