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
173 changes: 171 additions & 2 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ pub type OutputCallback = Box<dyn FnMut(&str, &str) + Send + Sync>;
use crate::parser::{
ArithmeticForCommand, AssignmentValue, CaseCommand, Command, CommandList, CompoundCommand,
ForCommand, FunctionDef, IfCommand, ListOperator, ParameterOp, Parser, Pipeline, Redirect,
RedirectKind, Script, SimpleCommand, Span, TimeCommand, UntilCommand, WhileCommand, Word,
WordPart,
RedirectKind, Script, SelectCommand, SimpleCommand, Span, TimeCommand, UntilCommand,
WhileCommand, Word, WordPart,
};

#[cfg(feature = "failpoints")]
Expand Down Expand Up @@ -522,6 +522,7 @@ impl Interpreter {
CompoundCommand::While(cmd) => cmd.span.line(),
CompoundCommand::Until(cmd) => cmd.span.line(),
CompoundCommand::Case(cmd) => cmd.span.line(),
CompoundCommand::Select(cmd) => cmd.span.line(),
CompoundCommand::Time(cmd) => cmd.span.line(),
CompoundCommand::Subshell(_) | CompoundCommand::BraceGroup(_) => 1,
CompoundCommand::Arithmetic(_) | CompoundCommand::Conditional(_) => 1,
Expand Down Expand Up @@ -621,6 +622,7 @@ impl Interpreter {
}
CompoundCommand::BraceGroup(commands) => self.execute_command_sequence(commands).await,
CompoundCommand::Case(case_cmd) => self.execute_case(case_cmd).await,
CompoundCommand::Select(select_cmd) => self.execute_select(select_cmd).await,
CompoundCommand::Arithmetic(expr) => self.execute_arithmetic_command(expr).await,
CompoundCommand::Time(time_cmd) => self.execute_time(time_cmd).await,
CompoundCommand::Conditional(words) => self.execute_conditional(words).await,
Expand Down Expand Up @@ -775,6 +777,173 @@ impl Interpreter {
})
}

/// Execute a select loop: select var in list; do body; done
///
/// Reads lines from pipeline_stdin. Each line is treated as the user's
/// menu selection. If the line is a valid number, the variable is set to
/// the corresponding item; otherwise it is set to empty. REPLY is always
/// set to the raw input. EOF ends the loop.
async fn execute_select(&mut self, select_cmd: &SelectCommand) -> Result<ExecResult> {
let mut stdout = String::new();
let mut stderr = String::new();
let mut exit_code = 0;

// Expand word list
let mut values = Vec::new();
for w in &select_cmd.words {
let fields = self.expand_word_to_fields(w).await?;
if w.quoted {
values.extend(fields);
} else {
for expanded in fields {
let brace_expanded = self.expand_braces(&expanded);
for item in brace_expanded {
if self.contains_glob_chars(&item) {
let glob_matches = self.expand_glob(&item).await?;
if glob_matches.is_empty() {
values.push(item);
} else {
values.extend(glob_matches);
}
} else {
values.push(item);
}
}
}
}
}

if values.is_empty() {
return Ok(ExecResult {
stdout,
stderr,
exit_code,
control_flow: ControlFlow::None,
});
}

// Build menu string
let menu: String = values
.iter()
.enumerate()
.map(|(i, v)| format!("{}) {}", i + 1, v))
.collect::<Vec<_>>()
.join("\n");

let ps3 = self
.variables
.get("PS3")
.cloned()
.unwrap_or_else(|| "#? ".to_string());

// Reset loop counter
self.counters.reset_loop();

loop {
self.counters.tick_loop(&self.limits)?;

// Output menu to stderr
stderr.push_str(&menu);
stderr.push('\n');
stderr.push_str(&ps3);

// Read a line from pipeline_stdin
let line = if let Some(ref ps) = self.pipeline_stdin {
if ps.is_empty() {
// EOF: bash prints newline and exits with code 1
stdout.push('\n');
exit_code = 1;
break;
}
let data = ps.clone();
if let Some(newline_pos) = data.find('\n') {
let line = data[..newline_pos].to_string();
self.pipeline_stdin = Some(data[newline_pos + 1..].to_string());
line
} else {
self.pipeline_stdin = Some(String::new());
data
}
} else {
// No stdin: bash prints newline and exits with code 1
stdout.push('\n');
exit_code = 1;
break;
};

// Set REPLY to raw input
self.variables.insert("REPLY".to_string(), line.clone());

// Parse selection number
let selected = line
.trim()
.parse::<usize>()
.ok()
.and_then(|n| {
if n >= 1 && n <= values.len() {
Some(values[n - 1].clone())
} else {
None
}
})
.unwrap_or_default();

self.variables.insert(select_cmd.variable.clone(), selected);

// Execute body
let emit_before = self.output_emit_count;
let result = self.execute_command_sequence(&select_cmd.body).await?;
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
stdout.push_str(&result.stdout);
stderr.push_str(&result.stderr);
exit_code = result.exit_code;

// Check for break/continue
match result.control_flow {
ControlFlow::Break(n) => {
if n <= 1 {
break;
} else {
return Ok(ExecResult {
stdout,
stderr,
exit_code,
control_flow: ControlFlow::Break(n - 1),
});
}
}
ControlFlow::Continue(n) => {
if n <= 1 {
continue;
} else {
return Ok(ExecResult {
stdout,
stderr,
exit_code,
control_flow: ControlFlow::Continue(n - 1),
});
}
}
ControlFlow::Return(code) => {
return Ok(ExecResult {
stdout,
stderr,
exit_code: code,
control_flow: ControlFlow::Return(code),
});
}
ControlFlow::None => {}
}
}

Ok(ExecResult {
stdout,
stderr,
exit_code,
control_flow: ControlFlow::None,
})
}

/// Execute a C-style arithmetic for loop: for ((init; cond; step))
async fn execute_arithmetic_for(
&mut self,
Expand Down
12 changes: 12 additions & 0 deletions crates/bashkit/src/parser/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ pub enum CompoundCommand {
Until(UntilCommand),
/// Case statement
Case(CaseCommand),
/// Select loop
Select(SelectCommand),
/// Subshell (commands in parentheses)
Subshell(Vec<Command>),
/// Brace group
Expand Down Expand Up @@ -148,6 +150,16 @@ pub struct ForCommand {
pub span: Span,
}

/// Select loop.
#[derive(Debug, Clone)]
pub struct SelectCommand {
pub variable: String,
pub words: Vec<Word>,
pub body: Vec<Command>,
/// Source span of this command
pub span: Span,
}

/// C-style arithmetic for loop: for ((init; cond; step)); do body; done
#[derive(Debug, Clone)]
pub struct ArithmeticForCommand {
Expand Down
78 changes: 78 additions & 0 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ impl<'a> Parser<'a> {
"while" => return self.parse_compound_with_redirects(|s| s.parse_while()),
"until" => return self.parse_compound_with_redirects(|s| s.parse_until()),
"case" => return self.parse_compound_with_redirects(|s| s.parse_case()),
"select" => return self.parse_compound_with_redirects(|s| s.parse_select()),
"time" => return self.parse_compound_with_redirects(|s| s.parse_time()),
"function" => return self.parse_function_keyword().map(Some),
_ => {
Expand Down Expand Up @@ -683,6 +684,83 @@ impl<'a> Parser<'a> {
}))
}

/// Parse select loop: select var in list; do body; done
fn parse_select(&mut self) -> Result<CompoundCommand> {
let start_span = self.current_span;
self.push_depth()?;
self.advance(); // consume 'select'
self.skip_newlines()?;

// Expect variable name
let variable = match &self.current_token {
Some(tokens::Token::Word(w))
| Some(tokens::Token::LiteralWord(w))
| Some(tokens::Token::QuotedWord(w)) => w.clone(),
_ => {
self.pop_depth();
return Err(Error::Parse("expected variable name in select".to_string()));
}
};
self.advance();

// Expect 'in' keyword
if !self.is_keyword("in") {
self.pop_depth();
return Err(Error::Parse("expected 'in' in select".to_string()));
}
self.advance(); // consume 'in'

// Parse word list until do/newline/;
let mut words = Vec::new();
loop {
match &self.current_token {
Some(tokens::Token::Word(w)) if w == "do" => break,
Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) => {
let is_quoted =
matches!(&self.current_token, Some(tokens::Token::QuotedWord(_)));
let mut word = self.parse_word(w.clone());
if is_quoted {
word.quoted = true;
}
words.push(word);
self.advance();
}
Some(tokens::Token::LiteralWord(w)) => {
words.push(Word {
parts: vec![WordPart::Literal(w.clone())],
quoted: true,
});
self.advance();
}
Some(tokens::Token::Newline) | Some(tokens::Token::Semicolon) => {
self.advance();
break;
}
_ => break,
}
}

self.skip_newlines()?;

// Expect 'do'
self.expect_keyword("do")?;
self.skip_newlines()?;

// Parse body
let body = self.parse_compound_list("done")?;

// Expect 'done'
self.expect_keyword("done")?;

self.pop_depth();
Ok(CompoundCommand::Select(SelectCommand {
variable,
words,
body,
span: start_span.merge(self.current_span),
}))
}

/// Parse C-style arithmetic for loop inner: for ((init; cond; step)); do body; done
/// Note: depth tracking is done by parse_for which calls this
fn parse_arithmetic_for_inner(&mut self, start_span: Span) -> Result<CompoundCommand> {
Expand Down
39 changes: 39 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/control-flow.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -429,3 +429,42 @@ fi
### expect
line 42
### end

### select_basic
# select reads from stdin and sets variable
echo "2" | select item in alpha beta gamma; do echo "got: $item"; break; done
### expect
got: beta
### end

### select_reply
# select sets REPLY to raw input
echo "1" | select x in one two three; do echo "REPLY=$REPLY x=$x"; break; done
### expect
REPLY=1 x=one
### end

### select_invalid
# select with invalid number sets variable to empty
echo "9" | select x in a b c; do echo "x='$x' REPLY=$REPLY"; break; done
### expect
x='' REPLY=9
### end

### select_multiple_iterations
# select loops until break
printf "1\n2\n3\n" | select x in a b c; do echo "$x"; if [ "$REPLY" = "3" ]; then break; fi; done
### expect
a
b
c
### end

### select_eof_exits
# select exits on EOF (prints newline, exit code 1)
echo "1" | select x in a b; do echo "$x"; done
### exit_code: 1
### expect
a

### end
8 changes: 4 additions & 4 deletions specs/009-implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See

## Spec Test Coverage

**Total spec test cases:** 1277 (1272 pass, 5 skip)
**Total spec test cases:** 1282 (1277 pass, 5 skip)

| Category | Cases | In CI | Pass | Skip | Notes |
|----------|-------|-------|------|------|-------|
| Bash (core) | 859 | Yes | 854 | 5 | `bash_spec_tests` in CI |
| Bash (core) | 864 | Yes | 859 | 5 | `bash_spec_tests` in CI |
| AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g |
| Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect |
| Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E |
| JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env |
| Python | 57 | Yes | 57 | 0 | embedded Python (Monty) |
| **Total** | **1277** | **Yes** | **1272** | **5** | |
| **Total** | **1282** | **Yes** | **1277** | **5** | |

### Bash Spec Tests Breakdown

Expand All @@ -129,7 +129,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
| command-not-found.test.sh | 17 | unknown command handling |
| conditional.test.sh | 24 | `[[ ]]` conditionals, `=~` regex, BASH_REMATCH, glob `==`/`!=` |
| command-subst.test.sh | 22 | includes backtick substitution, nested quotes in `$()` (1 skipped) |
| control-flow.test.sh | 53 | if/elif/else, for, while, case `;;`/`;&`/`;;&`, trap ERR, `[[ =~ ]]` BASH_REMATCH, compound input redirects |
| control-flow.test.sh | 58 | if/elif/else, for, while, case `;;`/`;&`/`;;&`, select, trap ERR, `[[ =~ ]]` BASH_REMATCH, compound input redirects |
| cuttr.test.sh | 32 | cut and tr commands, `-z` zero-terminated |
| date.test.sh | 38 | format specifiers, `-d` relative/compound/epoch, `-R`, `-I`, `%N` (2 skipped) |
| diff.test.sh | 4 | line diffs |
Expand Down
Loading