Skip to content

Commit a71e89a

Browse files
chaliyclaude
andauthored
feat(bash): implement select construct (#251)
## Summary - Implement `select var in list; do body; done` construct - Parse into `SelectCommand` AST node (similar to `ForCommand`) - Execute by reading from `pipeline_stdin`, printing numbered menu to stderr - Sets variable to selected item, `REPLY` to raw input - Matches bash EOF behavior: prints newline to stdout and exits with code 1 - 5 spec tests: basic selection, REPLY variable, invalid input, multiple iterations, EOF exit ## Test plan - [x] `cargo test --all-features` passes - [x] `cargo clippy` clean - [x] `cargo fmt --check` clean - [x] `bash_comparison_tests` 789/789 match real bash - [x] All 864 bash spec tests pass (859 pass + 5 skip) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d7818c commit a71e89a

File tree

5 files changed

+304
-6
lines changed

5 files changed

+304
-6
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ pub type OutputCallback = Box<dyn FnMut(&str, &str) + Send + Sync>;
4242
use crate::parser::{
4343
ArithmeticForCommand, AssignmentValue, CaseCommand, Command, CommandList, CompoundCommand,
4444
ForCommand, FunctionDef, IfCommand, ListOperator, ParameterOp, Parser, Pipeline, Redirect,
45-
RedirectKind, Script, SimpleCommand, Span, TimeCommand, UntilCommand, WhileCommand, Word,
46-
WordPart,
45+
RedirectKind, Script, SelectCommand, SimpleCommand, Span, TimeCommand, UntilCommand,
46+
WhileCommand, Word, WordPart,
4747
};
4848

4949
#[cfg(feature = "failpoints")]
@@ -522,6 +522,7 @@ impl Interpreter {
522522
CompoundCommand::While(cmd) => cmd.span.line(),
523523
CompoundCommand::Until(cmd) => cmd.span.line(),
524524
CompoundCommand::Case(cmd) => cmd.span.line(),
525+
CompoundCommand::Select(cmd) => cmd.span.line(),
525526
CompoundCommand::Time(cmd) => cmd.span.line(),
526527
CompoundCommand::Subshell(_) | CompoundCommand::BraceGroup(_) => 1,
527528
CompoundCommand::Arithmetic(_) | CompoundCommand::Conditional(_) => 1,
@@ -621,6 +622,7 @@ impl Interpreter {
621622
}
622623
CompoundCommand::BraceGroup(commands) => self.execute_command_sequence(commands).await,
623624
CompoundCommand::Case(case_cmd) => self.execute_case(case_cmd).await,
625+
CompoundCommand::Select(select_cmd) => self.execute_select(select_cmd).await,
624626
CompoundCommand::Arithmetic(expr) => self.execute_arithmetic_command(expr).await,
625627
CompoundCommand::Time(time_cmd) => self.execute_time(time_cmd).await,
626628
CompoundCommand::Conditional(words) => self.execute_conditional(words).await,
@@ -775,6 +777,173 @@ impl Interpreter {
775777
})
776778
}
777779

780+
/// Execute a select loop: select var in list; do body; done
781+
///
782+
/// Reads lines from pipeline_stdin. Each line is treated as the user's
783+
/// menu selection. If the line is a valid number, the variable is set to
784+
/// the corresponding item; otherwise it is set to empty. REPLY is always
785+
/// set to the raw input. EOF ends the loop.
786+
async fn execute_select(&mut self, select_cmd: &SelectCommand) -> Result<ExecResult> {
787+
let mut stdout = String::new();
788+
let mut stderr = String::new();
789+
let mut exit_code = 0;
790+
791+
// Expand word list
792+
let mut values = Vec::new();
793+
for w in &select_cmd.words {
794+
let fields = self.expand_word_to_fields(w).await?;
795+
if w.quoted {
796+
values.extend(fields);
797+
} else {
798+
for expanded in fields {
799+
let brace_expanded = self.expand_braces(&expanded);
800+
for item in brace_expanded {
801+
if self.contains_glob_chars(&item) {
802+
let glob_matches = self.expand_glob(&item).await?;
803+
if glob_matches.is_empty() {
804+
values.push(item);
805+
} else {
806+
values.extend(glob_matches);
807+
}
808+
} else {
809+
values.push(item);
810+
}
811+
}
812+
}
813+
}
814+
}
815+
816+
if values.is_empty() {
817+
return Ok(ExecResult {
818+
stdout,
819+
stderr,
820+
exit_code,
821+
control_flow: ControlFlow::None,
822+
});
823+
}
824+
825+
// Build menu string
826+
let menu: String = values
827+
.iter()
828+
.enumerate()
829+
.map(|(i, v)| format!("{}) {}", i + 1, v))
830+
.collect::<Vec<_>>()
831+
.join("\n");
832+
833+
let ps3 = self
834+
.variables
835+
.get("PS3")
836+
.cloned()
837+
.unwrap_or_else(|| "#? ".to_string());
838+
839+
// Reset loop counter
840+
self.counters.reset_loop();
841+
842+
loop {
843+
self.counters.tick_loop(&self.limits)?;
844+
845+
// Output menu to stderr
846+
stderr.push_str(&menu);
847+
stderr.push('\n');
848+
stderr.push_str(&ps3);
849+
850+
// Read a line from pipeline_stdin
851+
let line = if let Some(ref ps) = self.pipeline_stdin {
852+
if ps.is_empty() {
853+
// EOF: bash prints newline and exits with code 1
854+
stdout.push('\n');
855+
exit_code = 1;
856+
break;
857+
}
858+
let data = ps.clone();
859+
if let Some(newline_pos) = data.find('\n') {
860+
let line = data[..newline_pos].to_string();
861+
self.pipeline_stdin = Some(data[newline_pos + 1..].to_string());
862+
line
863+
} else {
864+
self.pipeline_stdin = Some(String::new());
865+
data
866+
}
867+
} else {
868+
// No stdin: bash prints newline and exits with code 1
869+
stdout.push('\n');
870+
exit_code = 1;
871+
break;
872+
};
873+
874+
// Set REPLY to raw input
875+
self.variables.insert("REPLY".to_string(), line.clone());
876+
877+
// Parse selection number
878+
let selected = line
879+
.trim()
880+
.parse::<usize>()
881+
.ok()
882+
.and_then(|n| {
883+
if n >= 1 && n <= values.len() {
884+
Some(values[n - 1].clone())
885+
} else {
886+
None
887+
}
888+
})
889+
.unwrap_or_default();
890+
891+
self.variables.insert(select_cmd.variable.clone(), selected);
892+
893+
// Execute body
894+
let emit_before = self.output_emit_count;
895+
let result = self.execute_command_sequence(&select_cmd.body).await?;
896+
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
897+
stdout.push_str(&result.stdout);
898+
stderr.push_str(&result.stderr);
899+
exit_code = result.exit_code;
900+
901+
// Check for break/continue
902+
match result.control_flow {
903+
ControlFlow::Break(n) => {
904+
if n <= 1 {
905+
break;
906+
} else {
907+
return Ok(ExecResult {
908+
stdout,
909+
stderr,
910+
exit_code,
911+
control_flow: ControlFlow::Break(n - 1),
912+
});
913+
}
914+
}
915+
ControlFlow::Continue(n) => {
916+
if n <= 1 {
917+
continue;
918+
} else {
919+
return Ok(ExecResult {
920+
stdout,
921+
stderr,
922+
exit_code,
923+
control_flow: ControlFlow::Continue(n - 1),
924+
});
925+
}
926+
}
927+
ControlFlow::Return(code) => {
928+
return Ok(ExecResult {
929+
stdout,
930+
stderr,
931+
exit_code: code,
932+
control_flow: ControlFlow::Return(code),
933+
});
934+
}
935+
ControlFlow::None => {}
936+
}
937+
}
938+
939+
Ok(ExecResult {
940+
stdout,
941+
stderr,
942+
exit_code,
943+
control_flow: ControlFlow::None,
944+
})
945+
}
946+
778947
/// Execute a C-style arithmetic for loop: for ((init; cond; step))
779948
async fn execute_arithmetic_for(
780949
&mut self,

crates/bashkit/src/parser/ast.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ pub enum CompoundCommand {
100100
Until(UntilCommand),
101101
/// Case statement
102102
Case(CaseCommand),
103+
/// Select loop
104+
Select(SelectCommand),
103105
/// Subshell (commands in parentheses)
104106
Subshell(Vec<Command>),
105107
/// Brace group
@@ -148,6 +150,16 @@ pub struct ForCommand {
148150
pub span: Span,
149151
}
150152

153+
/// Select loop.
154+
#[derive(Debug, Clone)]
155+
pub struct SelectCommand {
156+
pub variable: String,
157+
pub words: Vec<Word>,
158+
pub body: Vec<Command>,
159+
/// Source span of this command
160+
pub span: Span,
161+
}
162+
151163
/// C-style arithmetic for loop: for ((init; cond; step)); do body; done
152164
#[derive(Debug, Clone)]
153165
pub struct ArithmeticForCommand {

crates/bashkit/src/parser/mod.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ impl<'a> Parser<'a> {
497497
"while" => return self.parse_compound_with_redirects(|s| s.parse_while()),
498498
"until" => return self.parse_compound_with_redirects(|s| s.parse_until()),
499499
"case" => return self.parse_compound_with_redirects(|s| s.parse_case()),
500+
"select" => return self.parse_compound_with_redirects(|s| s.parse_select()),
500501
"time" => return self.parse_compound_with_redirects(|s| s.parse_time()),
501502
"function" => return self.parse_function_keyword().map(Some),
502503
_ => {
@@ -683,6 +684,83 @@ impl<'a> Parser<'a> {
683684
}))
684685
}
685686

687+
/// Parse select loop: select var in list; do body; done
688+
fn parse_select(&mut self) -> Result<CompoundCommand> {
689+
let start_span = self.current_span;
690+
self.push_depth()?;
691+
self.advance(); // consume 'select'
692+
self.skip_newlines()?;
693+
694+
// Expect variable name
695+
let variable = match &self.current_token {
696+
Some(tokens::Token::Word(w))
697+
| Some(tokens::Token::LiteralWord(w))
698+
| Some(tokens::Token::QuotedWord(w)) => w.clone(),
699+
_ => {
700+
self.pop_depth();
701+
return Err(Error::Parse("expected variable name in select".to_string()));
702+
}
703+
};
704+
self.advance();
705+
706+
// Expect 'in' keyword
707+
if !self.is_keyword("in") {
708+
self.pop_depth();
709+
return Err(Error::Parse("expected 'in' in select".to_string()));
710+
}
711+
self.advance(); // consume 'in'
712+
713+
// Parse word list until do/newline/;
714+
let mut words = Vec::new();
715+
loop {
716+
match &self.current_token {
717+
Some(tokens::Token::Word(w)) if w == "do" => break,
718+
Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) => {
719+
let is_quoted =
720+
matches!(&self.current_token, Some(tokens::Token::QuotedWord(_)));
721+
let mut word = self.parse_word(w.clone());
722+
if is_quoted {
723+
word.quoted = true;
724+
}
725+
words.push(word);
726+
self.advance();
727+
}
728+
Some(tokens::Token::LiteralWord(w)) => {
729+
words.push(Word {
730+
parts: vec![WordPart::Literal(w.clone())],
731+
quoted: true,
732+
});
733+
self.advance();
734+
}
735+
Some(tokens::Token::Newline) | Some(tokens::Token::Semicolon) => {
736+
self.advance();
737+
break;
738+
}
739+
_ => break,
740+
}
741+
}
742+
743+
self.skip_newlines()?;
744+
745+
// Expect 'do'
746+
self.expect_keyword("do")?;
747+
self.skip_newlines()?;
748+
749+
// Parse body
750+
let body = self.parse_compound_list("done")?;
751+
752+
// Expect 'done'
753+
self.expect_keyword("done")?;
754+
755+
self.pop_depth();
756+
Ok(CompoundCommand::Select(SelectCommand {
757+
variable,
758+
words,
759+
body,
760+
span: start_span.merge(self.current_span),
761+
}))
762+
}
763+
686764
/// Parse C-style arithmetic for loop inner: for ((init; cond; step)); do body; done
687765
/// Note: depth tracking is done by parse_for which calls this
688766
fn parse_arithmetic_for_inner(&mut self, start_span: Span) -> Result<CompoundCommand> {

crates/bashkit/tests/spec_cases/bash/control-flow.test.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,3 +429,42 @@ fi
429429
### expect
430430
line 42
431431
### end
432+
433+
### select_basic
434+
# select reads from stdin and sets variable
435+
echo "2" | select item in alpha beta gamma; do echo "got: $item"; break; done
436+
### expect
437+
got: beta
438+
### end
439+
440+
### select_reply
441+
# select sets REPLY to raw input
442+
echo "1" | select x in one two three; do echo "REPLY=$REPLY x=$x"; break; done
443+
### expect
444+
REPLY=1 x=one
445+
### end
446+
447+
### select_invalid
448+
# select with invalid number sets variable to empty
449+
echo "9" | select x in a b c; do echo "x='$x' REPLY=$REPLY"; break; done
450+
### expect
451+
x='' REPLY=9
452+
### end
453+
454+
### select_multiple_iterations
455+
# select loops until break
456+
printf "1\n2\n3\n" | select x in a b c; do echo "$x"; if [ "$REPLY" = "3" ]; then break; fi; done
457+
### expect
458+
a
459+
b
460+
c
461+
### end
462+
463+
### select_eof_exits
464+
# select exits on EOF (prints newline, exit code 1)
465+
echo "1" | select x in a b; do echo "$x"; done
466+
### exit_code: 1
467+
### expect
468+
a
469+
470+
### end

specs/009-implementation-status.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
103103

104104
## Spec Test Coverage
105105

106-
**Total spec test cases:** 1277 (1272 pass, 5 skip)
106+
**Total spec test cases:** 1282 (1277 pass, 5 skip)
107107

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

118118
### Bash Spec Tests Breakdown
119119

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

0 commit comments

Comments
 (0)