diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 64e05061..2884690c 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -42,8 +42,8 @@ pub type OutputCallback = Box; 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")] @@ -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, @@ -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, @@ -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 { + 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::>() + .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::() + .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, diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index 6d187156..2ef32605 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -100,6 +100,8 @@ pub enum CompoundCommand { Until(UntilCommand), /// Case statement Case(CaseCommand), + /// Select loop + Select(SelectCommand), /// Subshell (commands in parentheses) Subshell(Vec), /// Brace group @@ -148,6 +150,16 @@ pub struct ForCommand { pub span: Span, } +/// Select loop. +#[derive(Debug, Clone)] +pub struct SelectCommand { + pub variable: String, + pub words: Vec, + pub body: Vec, + /// 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 { diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 1986a514..b1276bad 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -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), _ => { @@ -683,6 +684,83 @@ impl<'a> Parser<'a> { })) } + /// Parse select loop: select var in list; do body; done + fn parse_select(&mut self) -> Result { + 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 { diff --git a/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh b/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh index e1858a9a..be1e57a4 100644 --- a/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh @@ -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 diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 5a5567b6..ad2e9926 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -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 @@ -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 |