From b520e3dadd518d4a4cd4777b2b2467fc130c9cfb Mon Sep 17 00:00:00 2001 From: exlier Date: Sat, 11 Jul 2026 03:16:50 +0000 Subject: [PATCH] Add POSIX compatibility spec and acceptance tests for TruShell v3 --- POSIX_COMPATIBILITY_SPEC.md | 35 ++++++++++++++ src/main.rs | 89 ++++++++++++++++++++++++++++++------ tests/posix_compatibility.rs | 56 +++++++++++++++++++++++ 3 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 POSIX_COMPATIBILITY_SPEC.md create mode 100644 tests/posix_compatibility.rs diff --git a/POSIX_COMPATIBILITY_SPEC.md b/POSIX_COMPATIBILITY_SPEC.md new file mode 100644 index 0000000..9c89514 --- /dev/null +++ b/POSIX_COMPATIBILITY_SPEC.md @@ -0,0 +1,35 @@ +# POSIX compatibility spec for TruShell v3 + +This document captures the minimum POSIX-oriented compatibility expectations for the v3 shell experience in TruShell. The goal is to preserve familiar shell behavior for common builtins and external commands while keeping the language parser available for TruShell-specific expressions. + +## Scope + +The v3 shell should support: + +- interactive prompt behavior with standard input and output +- basic builtins such as `cd` and `exit` +- execution of external commands through the host system PATH +- preservation of quoted arguments for external commands +- simple fallback behavior when a line is not a TruShell expression + +## Acceptance criteria + +### 1. `cd` without arguments + +- Running `cd` changes the shell working directory to the user's home directory. +- A following `pwd` command prints that directory. + +### 2. Quoted arguments for external commands + +- A command such as `printf '%s %s\n' hello world` receives the arguments `hello` and `world` exactly as written. +- The shell should preserve the quoting semantics required for the external process to see the intended arguments. + +### 3. Builtin exit behavior + +- `exit` terminates the interactive shell cleanly. +- The shell should not hang waiting for more input after receiving `exit`. + +### 4. Fallback to external commands + +- If a line cannot be parsed as a TruShell expression, the shell should attempt to execute it as an external command. +- This allows ordinary POSIX-style commands to continue working in the REPL. diff --git a/src/main.rs b/src/main.rs index bae66d7..6063a3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,9 +34,11 @@ fn main() { break; } - if trimmed_input.starts_with("cd") { - let parts: Vec<&str> = trimmed_input.split_whitespace().collect(); - let new_dir = parts.get(1).copied().unwrap_or("."); + let parts = split_posix_words(trimmed_input); + if parts.first().map(String::as_str) == Some("cd") { + let new_dir = parts.get(1).map(String::as_str).unwrap_or_else(|| { + std::env::var("HOME").as_deref().unwrap_or(".") + }); if let Err(e) = std::env::set_current_dir(new_dir) { eprintln!("trushell: cd: {}: {}", new_dir, e); } @@ -49,18 +51,14 @@ fn main() { // accidentally parsed as subtraction (e.g. `ls -la` -> `ls - la`), // fall back to executing the system command. if let Some((cmd, args)) = probable_cli_from_ast(&ast) { - let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - execute_system_command(&cmd, &arg_refs); + execute_system_command(&cmd, &args); } else { println!("Parsed AST: {:#?}", ast); } } Err(err) => { eprintln!("Parse error: {}", err); - let parts: Vec<&str> = trimmed_input.split_whitespace().collect(); - let command = parts[0]; - let args = &parts[1..]; - execute_system_command(command, args); + execute_system_command_from_input(trimmed_input); } } } @@ -115,9 +113,63 @@ fn probable_cli_from_ast(ast: &parser::ASTNode) -> Option<(String, Vec)> Some((cmd, args)) } -fn execute_system_command(cmd: &str, args: &[&str]) { - // Removed 'mut' here to fix the compilation warning perfectly - let child = Command::new(cmd) +fn split_posix_words(input: &str) -> Vec { + let mut words = Vec::new(); + let mut current = String::new(); + let mut chars = input.chars().peekable(); + let mut quote_mode: Option = None; + + while let Some(ch) = chars.next() { + match quote_mode { + Some('"') => match ch { + '\\' => { + if let Some(next) = chars.next() { + current.push(next); + } + } + '"' => quote_mode = None, + _ => current.push(ch), + }, + Some('\'') => { + if ch == '\'' { + quote_mode = None; + } else { + current.push(ch); + } + } + None => match ch { + '\'' => quote_mode = Some('\''), + '"' => quote_mode = Some('"'), + '\\' => { + if let Some(next) = chars.next() { + current.push(next); + } + } + ch if ch.is_whitespace() => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + }, + } + } + + if !current.is_empty() { + words.push(current); + } + + words +} + +fn execute_system_command(cmd: &str, args: &[String]) { + let command_name = if cmd.is_empty() { + return; + } else { + cmd + }; + + let child = Command::new(command_name) .args(args) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) @@ -131,7 +183,18 @@ fn execute_system_command(cmd: &str, args: &[&str]) { } } Err(e) => { - eprintln!("trushell: command not found '{}': {}", cmd, e); + eprintln!("trushell: command not found '{}': {}", command_name, e); } } } + +fn execute_system_command_from_input(input: &str) { + let parts = split_posix_words(input); + if parts.is_empty() { + return; + } + + let cmd = parts[0].clone(); + let args = parts.into_iter().skip(1).collect::>(); + execute_system_command(&cmd, &args); +} diff --git a/tests/posix_compatibility.rs b/tests/posix_compatibility.rs new file mode 100644 index 0000000..9431fd3 --- /dev/null +++ b/tests/posix_compatibility.rs @@ -0,0 +1,56 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run_shell(input: &str) -> std::process::Output { + let mut child = Command::new(env!("CARGO")) + .arg("run") + .arg("--quiet") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn trushell"); + + if let Some(stdin) = child.stdin.as_mut() { + stdin + .write_all(input.as_bytes()) + .expect("failed to write shell input"); + } + + child.wait_with_output().expect("failed to read shell output") +} + +#[test] +fn cd_without_arguments_uses_the_home_directory() { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let output = run_shell("cd\npwd\nexit\n"); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + output.status.success(), + "shell exited with {:?}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains(&home), + "expected pwd output to contain home directory {home}, got: {stdout}" + ); +} + +#[test] +fn quoted_arguments_are_preserved_for_external_commands() { + let output = run_shell("printf '%s %s\\n' hello world\nexit\n"); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + output.status.success(), + "shell exited with {:?}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains("hello world"), + "expected printf output to include the quoted arguments, got: {stdout}" + ); +}