Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions POSIX_COMPATIBILITY_SPEC.md
Original file line number Diff line number Diff line change
@@ -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.
89 changes: 76 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(".")
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if let Err(e) = std::env::set_current_dir(new_dir) {
eprintln!("trushell: cd: {}: {}", new_dir, e);
}
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -115,9 +113,63 @@ fn probable_cli_from_ast(ast: &parser::ASTNode) -> Option<(String, Vec<String>)>
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<String> {
let mut words = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
let mut quote_mode: Option<char> = 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())
Expand All @@ -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::<Vec<_>>();
execute_system_command(&cmd, &args);
}
56 changes: 56 additions & 0 deletions tests/posix_compatibility.rs
Original file line number Diff line number Diff line change
@@ -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}"
);
}
Comment on lines +24 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the test fallback with the shell implementation.

When the HOME environment variable is undefined, this test checks if the directory was changed to "/root". However, the shell implementation defaults to "." in this scenario, meaning pwd will emit the current working directory instead. This discrepancy will cause the test to fail in environments without HOME.

Consider aligning the test's fallback value with the runtime implementation's exact behavior.

💚 Proposed fix
 fn cd_without_arguments_uses_the_home_directory() {
-    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
+    let home = std::env::var("HOME").unwrap_or_else(|_| {
+        std::env::current_dir().unwrap().to_string_lossy().into_owned()
+    });
     let output = run_shell("cd\npwd\nexit\n");
     let stdout = String::from_utf8_lossy(&output.stdout);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}"
);
}
fn cd_without_arguments_uses_the_home_directory() {
let home = std::env::var("HOME").unwrap_or_else(|_| {
std::env::current_dir().unwrap().to_string_lossy().into_owned()
});
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}"
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/posix_compatibility.rs` around lines 24 - 39, Update the HOME fallback
in cd_without_arguments_uses_the_home_directory to match the shell
implementation’s exact default of "." when HOME is unavailable, leaving the
test’s assertions and execution flow unchanged.


#[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}"
);
}
Loading