-
Notifications
You must be signed in to change notification settings - Fork 15
Add POSIX compatibility spec and acceptance tests for TruShell v3 #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤖 Prompt for AI Agents |
||
| 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}" | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
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_modeis stillSomeat the end of the loop.🤖 Prompt for AI Agents
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Empty quoted arguments are silently dropped.
split_posix_wordsonly pushes a word when!current.is_empty(), so""or''produce no word at all. For example,echo "" helloyields["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
🤖 Prompt for AI Agents