Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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(".")
});
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
Comment on lines +116 to +162

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

Unterminated quotes are silently accepted.

If a quote is opened but never closed, the tokenizer treats all remaining input as part of the current word without any warning. POSIX shells report a syntax error for unterminated quotes. Consider emitting an error or warning when quote_mode is still Some at the end of the loop.

🤖 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 `@src/main.rs` around lines 116 - 162, The split_posix_words tokenizer silently
accepts unterminated quotes. After processing the input, check whether
quote_mode is still Some and emit an appropriate error or warning instead of
treating the remaining text as valid input; update callers as needed to
propagate or handle this failure while preserving normal word parsing.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Empty quoted arguments are silently dropped.

split_posix_words only pushes a word when !current.is_empty(), so "" or '' produce no word at all. For example, echo "" hello yields ["echo", "hello"] instead of ["echo", "", "hello"]. This violates the POSIX compatibility spec's requirement to preserve quoted arguments for external commands (POSIX_COMPATIBILITY_SPEC.md, Section 2).

The root cause is that the function conflates "word has content" with "word was started." A quote pair explicitly starts a word even when it produces an empty string.

🐛 Proposed fix: track `in_word` to preserve empty quoted arguments
 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;
+    let mut in_word = false;

     while let Some(ch) = chars.next() {
         match quote_mode {
             Some('"') => match ch {
                 '\\' => {
                     if let Some(next) = chars.next() {
                         current.push(next);
+                        in_word = true;
                     }
                 }
-                '"' => quote_mode = None,
+                '"' => {
+                    quote_mode = None;
+                    in_word = true;
+                }
                 _ => current.push(ch),
             },
             Some('\'') => {
                 if ch == '\'' {
-                    quote_mode = None,
+                    quote_mode = None;
+                    in_word = true;
                 } else {
                     current.push(ch);
                 }
             }
             None => match ch {
-                '\'' => quote_mode = Some('\''),
-                '"' => quote_mode = Some('"'),
+                '\'' => {
+                    quote_mode = Some('\'');
+                    in_word = true;
+                }
+                '"' => {
+                    quote_mode = Some('"');
+                    in_word = true;
+                }
                 '\\' => {
                     if let Some(next) = chars.next() {
                         current.push(next);
+                        in_word = true;
                     }
                 }
                 ch if ch.is_whitespace() => {
-                    if !current.is_empty() {
+                    if in_word {
                         words.push(std::mem::take(&mut current));
+                        in_word = false;
                     }
                 }
-                _ => current.push(ch),
+                _ => {
+                    current.push(ch);
+                    in_word = true;
+                }
             },
         }
     }

-    if !current.is_empty() {
+    if in_word {
         words.push(current);
     }

     words
 }
📝 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 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 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;
let mut in_word = false;
while let Some(ch) = chars.next() {
match quote_mode {
Some('"') => match ch {
'\\' => {
if let Some(next) = chars.next() {
current.push(next);
in_word = true;
}
}
'"' => {
quote_mode = None;
in_word = true;
}
_ => current.push(ch),
},
Some('\'') => {
if ch == '\'' {
quote_mode = None;
in_word = true;
} else {
current.push(ch);
}
}
None => match ch {
'\'' => {
quote_mode = Some('\'');
in_word = true;
}
'"' => {
quote_mode = Some('"');
in_word = true;
}
'\\' => {
if let Some(next) = chars.next() {
current.push(next);
in_word = true;
}
}
ch if ch.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut current));
in_word = false;
}
}
_ => {
current.push(ch);
in_word = true;
}
},
}
}
if in_word {
words.push(current);
}
words
}
🤖 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 `@src/main.rs` around lines 116 - 162, Update split_posix_words to track
whether parsing is currently inside a word separately from current’s contents,
using an in_word flag set when encountering quotes, escapes, or non-whitespace
characters. Push and reset the word on whitespace and at end of input whenever
in_word is true, so empty quoted arguments such as "" and '' are preserved.

}

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");
Comment on lines +24 to +26

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

HOME fallback mismatch between test and shell.

The test falls back to "/root" when HOME is unset, but the shell (src/main.rs line 40) falls back to ".". If HOME is unset, the test expects /root in pwd output while the shell changes to the current directory, causing a spurious failure. Align the fallbacks or skip the assertion when HOME is unset.

🤖 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 - 26, Align the HOME fallback
in cd_without_arguments_uses_the_home_directory with the shell’s fallback
behavior in main, using "." when HOME is unset, or skip the path assertion when
HOME is unavailable. Ensure the test’s expected pwd output matches the directory
selected by cd without arguments.

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