1use std::{
4 env,
5 fs::{self, File},
6 io::Write,
7 path::{Path, PathBuf},
8 process::{ChildStdout, Command as OsCommand, Stdio},
9};
10
11#[cfg(unix)]
12use std::os::unix::fs::PermissionsExt;
13
14use anyhow::Context;
15use pathsearch::find_executable_in_path;
16use strum::{Display, EnumIs, EnumTryAs};
17
18mod parse;
19mod targets;
20
21pub use parse::{ParsedInvocation, needs_more_input};
22pub use targets::{StderrTarget, StdoutTarget, open_stderr_writer, open_writer};
23
24#[derive(Debug, PartialEq)]
25pub enum RunResult {
26 Continue,
27 Exit(u8),
28}
29
30const BUILTIN_NAMES: &[&str] = &["cd", "echo", "exit", "type", "pwd", "history"];
31
32fn is_builtin(name: &str) -> bool {
33 BUILTIN_NAMES.contains(&name)
34}
35pub fn run_line(raw: &str, shell_name: &str, history: &[String]) -> anyhow::Result<RunResult> {
36 let raw = raw.trim();
37 if raw.is_empty() {
38 return Ok(RunResult::Continue);
39 }
40
41 let tokens = match shlex::split(raw) {
42 Some(t) if !t.is_empty() => t,
43 _ => return Ok(RunResult::Continue),
44 };
45
46 if !tokens.iter().any(|t| t == "|") {
47 let Some(cmd) = Cmd::from_input(raw)? else {
49 return Ok(RunResult::Continue);
50 };
51 return cmd.run(shell_name, history);
52 }
53
54 run_pipeline(tokens, shell_name)
55}
56
57#[derive(Debug, PartialEq, EnumIs, EnumTryAs, Display)]
58pub enum Cmd {
59 Cd {
60 cmd: Command,
61 stderr: StderrTarget,
62 },
63 Echo {
64 cmd: Command,
65 stdout: StdoutTarget,
66 stderr: StderrTarget,
67 },
68 Exit(u8),
69 Type {
70 cmd: Command,
71 stdout: StdoutTarget,
72 stderr: StderrTarget,
73 },
74 Exec {
75 cmd: Command,
76 stdout: StdoutTarget,
77 stderr: StderrTarget,
78 },
79 Pwd {
80 stdout: StdoutTarget,
81 stderr: StderrTarget,
82 },
83 History {
84 cmd: Command,
85 stdout: StdoutTarget,
86 stderr: StderrTarget,
87 },
88 Unknown {
89 cmd: Command,
90 stderr: StderrTarget,
91 },
92}
93
94impl Cmd {
95 pub fn from_input(raw: &str) -> anyhow::Result<Option<Self>> {
96 let raw = raw.trim();
97 if raw.is_empty() {
98 return Ok(None);
99 }
100 let tokens = shlex::split(raw).unwrap_or_default();
101 if tokens.is_empty() {
102 return Ok(None);
103 }
104 let Some(inv) = ParsedInvocation::from_tokens(tokens) else {
105 return Ok(None);
106 };
107 Ok(Some(Self::from_parts(
108 &inv.cmd_name,
109 inv.args,
110 inv.stdout,
111 inv.stderr,
112 )))
113 }
114
115 pub fn from_parts(
116 cmd_name: &str,
117 args: Vec<String>,
118 stdout: StdoutTarget,
119 stderr: StderrTarget,
120 ) -> Self {
121 match cmd_name {
122 "cd" => Cmd::Cd {
123 cmd: Command::new(cmd_name, None, args),
124 stderr,
125 },
126 "exit" => {
127 let code = args.first().and_then(|s| s.parse::<u8>().ok()).unwrap_or(0);
128 Cmd::Exit(code)
129 }
130 "echo" => Cmd::Echo {
131 cmd: Command::new(cmd_name, None, args),
132 stdout,
133 stderr,
134 },
135 "history" => Cmd::History {
136 cmd: Command::new(cmd_name, None, args),
137 stdout,
138 stderr,
139 },
140 "type" => Cmd::Type {
141 cmd: Command::new(cmd_name, None, args),
142 stdout,
143 stderr,
144 },
145 "pwd" => Cmd::Pwd { stdout, stderr },
146 _ => {
147 if cmd_name.contains('/') {
148 Cmd::Exec {
149 cmd: Command::new(cmd_name, Some(cmd_name.to_string()), args),
150 stdout,
151 stderr,
152 }
153 } else if let Some(path_buf) = find_executable(cmd_name) {
154 let path_str = path_buf
155 .into_os_string()
156 .into_string()
157 .unwrap_or_else(|_| String::new());
158 Cmd::Exec {
159 cmd: Command::new(cmd_name, Some(path_str), args),
160 stdout,
161 stderr,
162 }
163 } else {
164 Cmd::Unknown {
165 cmd: Command::new(cmd_name, None, args),
166 stderr,
167 }
168 }
169 }
170 }
171 }
172
173 pub fn run(&self, shell_name: &str, history: &[String]) -> anyhow::Result<RunResult> {
174 match self {
175 Cmd::Echo {
176 cmd,
177 stdout,
178 stderr: _,
179 } => {
180 let mut out = open_writer(stdout)?;
181 echo_args(&cmd.args, &mut out)?;
182 Ok(RunResult::Continue)
183 }
184 Cmd::Exit(code) => Ok(RunResult::Exit(*code)),
185 Cmd::Type {
186 cmd,
187 stdout,
188 stderr: _,
189 } => {
190 let mut out = open_writer(stdout)?;
191 writeln!(out, "{}", resolve_types(&cmd.args))?;
192 Ok(RunResult::Continue)
193 }
194 Cmd::Exec {
195 cmd,
196 stdout,
197 stderr,
198 } => {
199 if let Err(err) = cmd.run_with_stdio(stdout, stderr) {
200 let mut err_out = open_stderr_writer(stderr)?;
201 writeln!(err_out, "{shell_name}: {err}")?;
202 }
203 Ok(RunResult::Continue)
204 }
205 Cmd::Cd { cmd, stderr } => {
206 let target = cmd.args.first().map(String::as_str).unwrap_or("");
207 if let Err(err) = change_dir(target) {
208 let mut err_out = open_stderr_writer(stderr)?;
209 writeln!(err_out, "{shell_name}: {err}")?;
210 }
211 Ok(RunResult::Continue)
212 }
213 Cmd::Pwd { stdout, stderr: _ } => {
214 let mut out = open_writer(stdout)?;
215 let dir = env::current_dir().context("get current directory")?;
216 writeln!(out, "{}", dir.display())?;
217 Ok(RunResult::Continue)
218 }
219 Cmd::History {
220 cmd,
221 stdout,
222 stderr: _,
223 } => {
224 let count = cmd.args.get(0).and_then(|s| s.parse::<usize>().ok());
226 let total = history.len();
227 let start = match count {
228 Some(n) if n < total => total - n,
229 Some(_) => 0,
230 None => 0,
231 };
232
233 let mut out = open_writer(stdout)?;
234 for (idx, entry) in history.iter().enumerate().skip(start) {
235 writeln!(out, " {:>4} {entry}", idx + 1)?;
236 }
237 Ok(RunResult::Continue)
238 }
239 Cmd::Unknown { cmd, stderr } => {
240 let mut err_out = open_stderr_writer(stderr)?;
241 writeln!(err_out, "{shell_name}: command not found: {}", cmd.name)?;
242 Ok(RunResult::Continue)
243 }
244 }
245 }
246}
247
248fn split_pipeline(tokens: Vec<String>) -> Option<Vec<Vec<String>>> {
249 let mut segments = Vec::new();
250 let mut current = Vec::new();
251
252 for tok in tokens {
253 if tok == "|" {
254 if current.is_empty() {
255 return None;
256 }
257 segments.push(std::mem::take(&mut current));
258 } else {
259 current.push(tok);
260 }
261 }
262
263 if current.is_empty() {
264 return None;
265 }
266
267 segments.push(current);
268 Some(segments)
269}
270
271fn run_pipeline(tokens: Vec<String>, shell_name: &str) -> anyhow::Result<RunResult> {
272 use anyhow::anyhow;
273
274 let segments = match split_pipeline(tokens) {
275 Some(segs) => segs,
276 None => {
277 eprintln!("{shell_name}: invalid pipeline");
278 return Ok(RunResult::Continue);
279 }
280 };
281
282 let mut invocations = Vec::new();
283 for seg in segments {
284 let Some(inv) = ParsedInvocation::from_tokens(seg) else {
285 eprintln!("{shell_name}: invalid command in pipeline");
286 return Ok(RunResult::Continue);
287 };
288 invocations.push(inv);
289 }
290
291 if invocations.is_empty() {
292 return Ok(RunResult::Continue);
293 }
294
295 let mut children = Vec::new();
296 let mut prev_stdout: Option<ChildStdout> = None;
297
298 for (idx, inv) in invocations.iter().enumerate() {
299 let is_last = idx == invocations.len() - 1;
300
301 let program_path = if inv.cmd_name.contains('/') {
302 PathBuf::from(&inv.cmd_name)
303 } else if let Some(p) = find_executable(&inv.cmd_name) {
304 p
305 } else {
306 eprintln!("{shell_name}: command not found: {}", inv.cmd_name);
307 return Ok(RunResult::Continue);
308 };
309
310 let mut cmd = OsCommand::new(&program_path);
311 cmd.args(&inv.args);
312
313 if let Some(stdin) = prev_stdout.take() {
314 cmd.stdin(Stdio::from(stdin));
315 }
316
317 if is_last {
318 match &inv.stdout {
319 StdoutTarget::Stdout => { }
320 StdoutTarget::Overwrite(path) => {
321 let file = File::create(path)?;
322 cmd.stdout(Stdio::from(file));
323 }
324 StdoutTarget::Append(path) => {
325 let file = File::options().append(true).create(true).open(path)?;
326 cmd.stdout(Stdio::from(file));
327 }
328 }
329 } else {
330 cmd.stdout(Stdio::piped());
331 }
332
333 if is_last {
334 match &inv.stderr {
335 StderrTarget::Stderr => { }
336 StderrTarget::Overwrite(path) => {
337 let file = File::create(path)?;
338 cmd.stderr(Stdio::from(file));
339 }
340 StderrTarget::Append(path) => {
341 let file = File::options().append(true).create(true).open(path)?;
342 cmd.stderr(Stdio::from(file));
343 }
344 }
345 }
346
347 let mut child = cmd
348 .spawn()
349 .with_context(|| format!("failed to spawn `{}`", inv.cmd_name))?;
350
351 if !is_last {
352 let child_stdout = child
353 .stdout
354 .take()
355 .ok_or_else(|| anyhow!("failed to capture stdout for pipeline stage"))?;
356 prev_stdout = Some(child_stdout);
357 }
358
359 children.push(child);
360 }
361
362 for mut child in children {
363 child
364 .wait()
365 .with_context(|| "failed to wait for pipeline stage")?;
366 }
367
368 Ok(RunResult::Continue)
369}
370
371#[derive(Debug, PartialEq, Clone)]
372pub struct Command {
373 pub name: String,
374 pub path: Option<String>,
375 pub args: Vec<String>,
376}
377
378impl Command {
379 pub fn new(name: &str, path: Option<String>, args: Vec<String>) -> Self {
380 Self {
381 name: name.to_owned(),
382 path,
383 args,
384 }
385 }
386
387 pub fn run(&self) -> anyhow::Result<()> {
388 let program = self.path.as_deref().unwrap_or(&self.name);
389 let mut child = std::process::Command::new(program)
390 .args(&self.args)
391 .spawn()
392 .with_context(|| format!("failed to spawn `{program}`"))?;
393 child
394 .wait()
395 .with_context(|| format!("failed to wait for `{program}`"))?;
396 Ok(())
397 }
398
399 pub fn run_with_stdio(&self, stdout: &StdoutTarget, stderr: &StderrTarget) -> anyhow::Result<()> {
400 let program = self.path.as_deref().unwrap_or(&self.name);
401 let mut command = std::process::Command::new(program);
402 command.args(&self.args);
403
404 match stdout {
405 StdoutTarget::Stdout => {}
406 StdoutTarget::Overwrite(path) => {
407 let file = File::create(path)?;
408 command.stdout(Stdio::from(file));
409 }
410 StdoutTarget::Append(path) => {
411 let file = File::options().append(true).create(true).open(path)?;
412 command.stdout(Stdio::from(file));
413 }
414 }
415
416 match stderr {
417 StderrTarget::Stderr => {}
418 StderrTarget::Overwrite(path) => {
419 let file = File::create(path)?;
420 command.stderr(Stdio::from(file));
421 }
422 StderrTarget::Append(path) => {
423 let file = File::options().append(true).create(true).open(path)?;
424 command.stderr(Stdio::from(file));
425 }
426 }
427
428 let mut child = command
429 .spawn()
430 .with_context(|| format!("failed to spawn `{program}`"))?;
431 child
432 .wait()
433 .with_context(|| format!("failed to wait for `{program}`"))?;
434 Ok(())
435 }
436}
437
438pub fn resolve_types(args: &[String]) -> String {
439 args
440 .iter()
441 .map(|name| {
442 if is_builtin(name) {
443 format!("{name} is a shell builtin")
444 } else {
445 match find_executable(name) {
446 Some(path) => format!("{name} is {}", path.display()),
447 None => format!("{name}: not found"),
448 }
449 }
450 })
451 .collect::<Vec<String>>()
452 .join("\n")
453}
454
455pub fn find_executable(cmd: &str) -> Option<PathBuf> {
456 find_executable_in_path(cmd)
457}
458
459pub fn complete_command(prefix: &str) -> Vec<String> {
462 let mut names: Vec<String> = BUILTIN_NAMES
463 .iter()
464 .filter(|n| n.starts_with(prefix))
465 .map(|s| (*s).to_string())
466 .collect();
467
468 let path_var = env::var("PATH").unwrap_or_default();
469 for dir in env::split_paths(&path_var) {
470 let Ok(entries) = fs::read_dir(&dir) else {
471 continue;
472 };
473 for entry in entries.flatten() {
474 let path = entry.path();
475 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
476 continue;
477 };
478 if !name.starts_with(prefix) {
479 continue;
480 }
481 let meta = match entry.metadata() {
482 Ok(m) => m,
483 Err(_) => continue,
484 };
485 if meta.is_dir() {
486 continue;
487 }
488 #[cfg(unix)]
489 if meta.permissions().mode() & 0o111 == 0 {
490 continue;
491 }
492 names.push(name.to_string());
493 }
494 }
495
496 names.sort_unstable();
497 names.dedup();
498 names
499}
500
501pub fn change_dir(target: &str) -> anyhow::Result<()> {
502 let new_path = if target.is_empty() || target == "~" {
503 env::var("HOME").context("HOME not set")?
504 } else {
505 target.to_string()
506 };
507
508 let path = Path::new(&new_path);
509
510 if !path.exists() {
511 println!("cd: {target}: No such file or directory");
512 return Ok(());
513 }
514
515 env::set_current_dir(path).with_context(|| format!("cd: {target}"))?;
516
517 let updated_cwd = env::current_dir().context("get cwd after cd")?;
518 unsafe {
519 env::set_var("PWD", updated_cwd);
520 }
521
522 Ok(())
523}
524
525pub fn echo_args<W: Write>(args: &[String], out: &mut W) -> anyhow::Result<()> {
526 writeln!(out, "{}", args.join(" "))?;
527 Ok(())
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn from_input_empty_returns_none() {
536 assert!(matches!(Cmd::from_input("").unwrap(), None));
537 assert!(matches!(Cmd::from_input(" ").unwrap(), None));
538 }
539
540 #[test]
541 fn from_input_echo_preserves_whitespace() {
542 let cmd = Cmd::from_input(r#"echo "hello world""#).unwrap().unwrap();
543 assert!(cmd.is_echo());
544 let Cmd::Echo { cmd, .. } = cmd else {
545 unreachable!()
546 };
547 assert_eq!(cmd.args, vec!["hello world"]);
548 }
549
550 #[test]
551 fn from_input_echo_multiple_args() {
552 let cmd = Cmd::from_input("echo a b c").unwrap().unwrap();
553 let Cmd::Echo { cmd, .. } = cmd else {
554 unreachable!()
555 };
556 assert_eq!(cmd.args, vec!["a", "b", "c"]);
557 }
558
559 #[test]
560 fn needs_more_input_unclosed_quotes() {
561 assert!(needs_more_input(r#"echo "hello"#));
562 assert!(needs_more_input("echo 'hello"));
563 assert!(!needs_more_input(r#"echo "hello""#));
564 assert!(!needs_more_input(""));
565 }
566
567 #[test]
568 fn from_parts_exit_no_args_is_zero() {
569 let cmd = Cmd::from_parts("exit", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
570 assert!(matches!(cmd, Cmd::Exit(0)));
571 }
572
573 #[test]
574 fn from_parts_exit_with_code() {
575 let cmd = Cmd::from_parts(
576 "exit",
577 vec!["42".into()],
578 StdoutTarget::Stdout,
579 StderrTarget::Stderr,
580 );
581 assert!(matches!(cmd, Cmd::Exit(42)));
582 }
583
584 #[test]
585 fn from_parts_pwd() {
586 let cmd = Cmd::from_parts("pwd", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
587 assert!(matches!(cmd, Cmd::Pwd { .. }));
588 }
589
590 #[test]
591 fn from_parts_type_args() {
592 let cmd = Cmd::from_parts(
593 "type",
594 vec!["cd".into(), "ls".into()],
595 StdoutTarget::Stdout,
596 StderrTarget::Stderr,
597 );
598 let Cmd::Type { cmd, .. } = cmd else {
599 unreachable!()
600 };
601 assert_eq!(cmd.args, vec!["cd", "ls"]);
602 }
603
604 #[test]
605 fn from_parts_cd_args() {
606 let cmd = Cmd::from_parts(
607 "cd",
608 vec!["/tmp".into()],
609 StdoutTarget::Stdout,
610 StderrTarget::Stderr,
611 );
612 let Cmd::Cd { cmd, .. } = cmd else {
613 unreachable!()
614 };
615 assert_eq!(cmd.args, vec!["/tmp"]);
616 }
617
618 #[test]
619 fn is_builtin_known() {
620 for name in BUILTIN_NAMES {
621 assert!(is_builtin(name), "{name} should be builtin");
622 }
623 }
624
625 #[test]
626 fn is_builtin_unknown() {
627 assert!(!is_builtin("ls"));
628 assert!(!is_builtin(""));
629 }
630}