From 61a9c448a02f58496355ade4b30e8b5f13b1ff8e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 4 Apr 2026 00:14:57 +0000 Subject: [PATCH] fix(interpreter): support exec {var}>&- fd-variable redirect syntax Closes #964 --- crates/bashkit/src/interpreter/mod.rs | 20 ++++-- crates/bashkit/src/parser/ast.rs | 4 ++ crates/bashkit/src/parser/mod.rs | 67 ++++++++++++++++++- .../spec_cases/bash/exec-fd-variable.test.sh | 18 +++++ 4 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/exec-fd-variable.test.sh diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index b81e408b..090972a4 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -3743,13 +3743,20 @@ impl Interpreter { }); } for redirect in redirects { + // Resolve fd from either explicit fd or {var} fd-variable syntax + let resolved_fd_var: Option = redirect.fd_var.as_ref().and_then(|var_name| { + self.variables + .get(var_name) + .and_then(|val| val.parse::().ok()) + }); match redirect.kind { RedirectKind::Input => { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); let content = self.fs.read_file(&path).await?; let text = bytes_to_latin1_string(&content); - if let Some(fd) = redirect.fd { + let fd = redirect.fd.or(resolved_fd_var); + if let Some(fd) = fd { let lines: Vec = text.lines().rev().map(|l| l.to_string()).collect(); self.coproc_buffers.insert(fd, lines); @@ -3760,14 +3767,15 @@ impl Interpreter { } RedirectKind::DupInput => { let target = self.expand_word(&redirect.target).await?; + let fd = redirect.fd.or(resolved_fd_var); if target == "-" - && let Some(fd) = redirect.fd + && let Some(fd) = fd { self.coproc_buffers.remove(&fd); } } RedirectKind::Output | RedirectKind::Clobber => { - let fd = redirect.fd.unwrap_or(1); + let fd = redirect.fd.or(resolved_fd_var).unwrap_or(1); let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); if is_dev_null(&path) { @@ -3780,7 +3788,7 @@ impl Interpreter { } } RedirectKind::Append => { - let fd = redirect.fd.unwrap_or(1); + let fd = redirect.fd.or(resolved_fd_var).unwrap_or(1); let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); if is_dev_null(&path) { @@ -3792,7 +3800,7 @@ impl Interpreter { } RedirectKind::DupOutput => { let target = self.expand_word(&redirect.target).await?; - let fd = redirect.fd.unwrap_or(1); + let fd = redirect.fd.or(resolved_fd_var).unwrap_or(1); if target == "-" { // exec N>&- closes the fd self.exec_fd_table.remove(&fd); @@ -5508,6 +5516,7 @@ impl Interpreter { let inner_redirects = if let Some(ref stdin_data) = command.stdin { vec![Redirect { fd: None, + fd_var: None, kind: RedirectKind::HereString, target: Word::literal(stdin_data.trim_end_matches('\n').to_string()), }] @@ -5551,6 +5560,7 @@ impl Interpreter { let cmd_redirects = if let Some(ref stdin_data) = cmd.stdin { vec![Redirect { fd: None, + fd_var: None, kind: RedirectKind::HereString, target: Word::literal(stdin_data.trim_end_matches('\n').to_string()), }] diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index 2c7917f6..c61fef3c 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -485,6 +485,8 @@ pub enum ParameterOp { pub struct Redirect { /// File descriptor (default: 1 for output, 0 for input) pub fd: Option, + /// Variable name for `{var}` fd-variable redirects (e.g. `exec {myfd}>&-`) + pub fd_var: Option, /// Type of redirection pub kind: RedirectKind, /// Target (file, fd, or heredoc content) @@ -921,6 +923,7 @@ mod tests { args: vec![Word::literal("hi")], redirects: vec![Redirect { fd: Some(1), + fd_var: None, kind: RedirectKind::Output, target: Word::literal("out.txt"), }], @@ -1049,6 +1052,7 @@ mod tests { fn redirect_default_fd_none() { let r = Redirect { fd: None, + fd_var: None, kind: RedirectKind::Input, target: Word::literal("input.txt"), }; diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 50395cd3..b6c7ee7b 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -371,6 +371,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: None, + fd_var: None, kind, target, }); @@ -381,6 +382,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: None, + fd_var: None, kind: RedirectKind::Append, target, }); @@ -391,6 +393,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: None, + fd_var: None, kind: RedirectKind::Input, target, }); @@ -401,6 +404,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: None, + fd_var: None, kind: RedirectKind::OutputBoth, target, }); @@ -411,6 +415,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: Some(1), + fd_var: None, kind: RedirectKind::DupOutput, target, }); @@ -422,6 +427,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::Output, target, }); @@ -433,6 +439,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::Append, target, }); @@ -444,6 +451,7 @@ impl<'a> Parser<'a> { self.advance(); redirects.push(Redirect { fd: Some(src_fd), + fd_var: None, kind: RedirectKind::DupOutput, target: Word::literal(dst_fd.to_string()), }); @@ -453,6 +461,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: Some(0), + fd_var: None, kind: RedirectKind::DupInput, target, }); @@ -464,6 +473,7 @@ impl<'a> Parser<'a> { self.advance(); redirects.push(Redirect { fd: Some(src_fd), + fd_var: None, kind: RedirectKind::DupInput, target: Word::literal(dst_fd.to_string()), }); @@ -473,6 +483,7 @@ impl<'a> Parser<'a> { self.advance(); redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::DupInput, target: Word::literal("-"), }); @@ -483,6 +494,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::Input, target, }); @@ -493,6 +505,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: None, + fd_var: None, kind: RedirectKind::HereString, target, }); @@ -536,6 +549,7 @@ impl<'a> Parser<'a> { }; redirects.push(Redirect { fd: None, + fd_var: None, kind, target, }); @@ -2043,6 +2057,7 @@ impl<'a> Parser<'a> { redirects.push(Redirect { fd: None, + fd_var: None, kind, target, }); @@ -2069,6 +2084,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: None, + fd_var: None, kind, target, }); @@ -2079,6 +2095,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: None, + fd_var: None, kind: RedirectKind::Append, target, }); @@ -2090,6 +2107,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::Output, target, }); @@ -2100,6 +2118,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: Some(0), + fd_var: None, kind: RedirectKind::DupInput, target, }); @@ -2111,6 +2130,7 @@ impl<'a> Parser<'a> { self.advance(); redirects.push(Redirect { fd: Some(src_fd), + fd_var: None, kind: RedirectKind::DupInput, target: Word::literal(dst_fd.to_string()), }); @@ -2120,6 +2140,7 @@ impl<'a> Parser<'a> { self.advance(); redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::DupInput, target: Word::literal("-"), }); @@ -2130,6 +2151,7 @@ impl<'a> Parser<'a> { if let Ok(target) = self.expect_word() { redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::Input, target, }); @@ -2140,6 +2162,27 @@ impl<'a> Parser<'a> { } } + /// Extract fd-variable name from `{varname}` pattern in the last word. + /// If the last word is a single literal `{identifier}`, pop it and return the name. + /// Used for `exec {var}>file` / `exec {var}>&-` syntax. + fn pop_fd_var(words: &mut Vec) -> Option { + if let Some(last) = words.last() + && last.parts.len() == 1 + && let WordPart::Literal(ref s) = last.parts[0] + && s.starts_with('{') + && s.ends_with('}') + && s.len() > 2 + && s[1..s.len() - 1] + .chars() + .all(|c| c.is_alphanumeric() || c == '_') + { + let var_name = s[1..s.len() - 1].to_string(); + words.pop(); + return Some(var_name); + } + None + } + fn parse_simple_command(&mut self) -> Result> { self.tick()?; self.skip_newlines()?; @@ -2225,37 +2268,45 @@ impl<'a> Parser<'a> { } else { RedirectKind::Output }; + let fd_var = Self::pop_fd_var(&mut words); self.advance(); let target = self.expect_word()?; redirects.push(Redirect { fd: None, + fd_var, kind, target, }); } Some(tokens::Token::RedirectAppend) => { + let fd_var = Self::pop_fd_var(&mut words); self.advance(); let target = self.expect_word()?; redirects.push(Redirect { fd: None, + fd_var, kind: RedirectKind::Append, target, }); } Some(tokens::Token::RedirectIn) => { + let fd_var = Self::pop_fd_var(&mut words); self.advance(); let target = self.expect_word()?; redirects.push(Redirect { fd: None, + fd_var, kind: RedirectKind::Input, target, }); } Some(tokens::Token::HereString) => { + let fd_var = Self::pop_fd_var(&mut words); self.advance(); let target = self.expect_word()?; redirects.push(Redirect { fd: None, + fd_var, kind: RedirectKind::HereString, target, }); @@ -2271,19 +2322,23 @@ impl<'a> Parser<'a> { words.push(word); } Some(tokens::Token::RedirectBoth) => { + let fd_var = Self::pop_fd_var(&mut words); self.advance(); let target = self.expect_word()?; redirects.push(Redirect { fd: None, + fd_var, kind: RedirectKind::OutputBoth, target, }); } Some(tokens::Token::DupOutput) => { + let fd_var = Self::pop_fd_var(&mut words); self.advance(); let target = self.expect_word()?; redirects.push(Redirect { - fd: Some(1), + fd: if fd_var.is_some() { None } else { Some(1) }, + fd_var, kind: RedirectKind::DupOutput, target, }); @@ -2294,6 +2349,7 @@ impl<'a> Parser<'a> { let target = self.expect_word()?; redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::Output, target, }); @@ -2304,6 +2360,7 @@ impl<'a> Parser<'a> { let target = self.expect_word()?; redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::Append, target, }); @@ -2314,15 +2371,18 @@ impl<'a> Parser<'a> { self.advance(); redirects.push(Redirect { fd: Some(src_fd), + fd_var: None, kind: RedirectKind::DupOutput, target: Word::literal(dst_fd.to_string()), }); } Some(tokens::Token::DupInput) => { + let fd_var = Self::pop_fd_var(&mut words); self.advance(); let target = self.expect_word()?; redirects.push(Redirect { - fd: Some(0), + fd: if fd_var.is_some() { None } else { Some(0) }, + fd_var, kind: RedirectKind::DupInput, target, }); @@ -2333,6 +2393,7 @@ impl<'a> Parser<'a> { self.advance(); redirects.push(Redirect { fd: Some(src_fd), + fd_var: None, kind: RedirectKind::DupInput, target: Word::literal(dst_fd.to_string()), }); @@ -2342,6 +2403,7 @@ impl<'a> Parser<'a> { self.advance(); redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::DupInput, target: Word::literal("-"), }); @@ -2352,6 +2414,7 @@ impl<'a> Parser<'a> { let target = self.expect_word()?; redirects.push(Redirect { fd: Some(fd), + fd_var: None, kind: RedirectKind::Input, target, }); diff --git a/crates/bashkit/tests/spec_cases/bash/exec-fd-variable.test.sh b/crates/bashkit/tests/spec_cases/bash/exec-fd-variable.test.sh new file mode 100644 index 00000000..188c1061 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/exec-fd-variable.test.sh @@ -0,0 +1,18 @@ +### exec_fd_variable_close +# exec {var}>&- should close fd stored in variable +exec 3>/dev/null +myfd=3 +exec {myfd}>&- +echo "closed" +### expect +closed +### end + +### exec_fd_variable_open +# exec {var}>file should open fd from variable value +myfd=4 +exec {myfd}>/dev/null +echo "opened" +### expect +opened +### end