From b9af25426f911d2ce492ffe41fc89c6593b290cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 17:02:24 +0000 Subject: [PATCH] feat(bash): implement select construct Parse and execute `select var in list; do body; done`. Reads from pipeline_stdin, prints numbered menu to stderr, sets variable to selected item and REPLY to raw input. Matches bash EOF behavior (prints newline, exits with code 1). Adds SelectCommand AST node, parser, and interpreter execution. 5 spec tests covering basic selection, REPLY, invalid input, multiple iterations, and EOF exit. --- crates/bashkit/src/interpreter/mod.rs | 173 +++++++++++++++++- crates/bashkit/src/parser/ast.rs | 12 ++ crates/bashkit/src/parser/mod.rs | 78 ++++++++ .../spec_cases/bash/control-flow.test.sh | 39 ++++ specs/009-implementation-status.md | 8 +- 5 files changed, 304 insertions(+), 6 deletions(-) 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 |