Skip to content

Commit 766ee5b

Browse files
authored
feat: configurable shell options and shopt command (#164)
1 parent a72d30b commit 766ee5b

File tree

9 files changed

+358
-7
lines changed

9 files changed

+358
-7
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ serialization = ["serde"]
1616

1717
[dependencies]
1818
anyhow = "1.0.75"
19+
bitflags = "2.6"
1920
futures = { version = "0.3.29", optional = true }
2021
glob = { version = "0.3.1", optional = true }
2122
path-dedot = { version = "3.1.1", optional = true }

rust-toolchain.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[toolchain]
2-
channel = "1.89.0"
2+
channel = "1.92.0"
33
components = ["clippy", "rustfmt"]

src/shell/commands/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod head;
1212
mod mkdir;
1313
mod pwd;
1414
mod rm;
15+
mod shopt;
1516
mod sleep;
1617
mod unset;
1718
mod xargs;
@@ -76,6 +77,10 @@ pub fn builtin_commands() -> HashMap<String, Rc<dyn ShellCommand>> {
7677
"rm".to_string(),
7778
Rc::new(rm::RmCommand) as Rc<dyn ShellCommand>,
7879
),
80+
(
81+
"shopt".to_string(),
82+
Rc::new(shopt::ShoptCommand) as Rc<dyn ShellCommand>,
83+
),
7984
(
8085
"sleep".to_string(),
8186
Rc::new(sleep::SleepCommand) as Rc<dyn ShellCommand>,

src/shell/commands/shopt.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright 2018-2025 the Deno authors. MIT license.
2+
3+
use futures::future::LocalBoxFuture;
4+
5+
use crate::shell::types::EnvChange;
6+
use crate::shell::types::ExecuteResult;
7+
use crate::shell::types::ShellOptions;
8+
9+
use super::ShellCommand;
10+
use super::ShellCommandContext;
11+
12+
pub struct ShoptCommand;
13+
14+
impl ShellCommand for ShoptCommand {
15+
fn execute(
16+
&self,
17+
mut context: ShellCommandContext,
18+
) -> LocalBoxFuture<'static, ExecuteResult> {
19+
Box::pin(async move {
20+
let mut set_mode = None; // None = query, Some(true) = -s, Some(false) = -u
21+
let mut options_to_change = Vec::new();
22+
23+
for arg in context.args.into_iter().peekable() {
24+
let arg_str = arg.to_string_lossy();
25+
match arg_str.as_ref() {
26+
"-s" => {
27+
if set_mode == Some(false) {
28+
let _ = context.stderr.write_line(
29+
"shopt: cannot set and unset options simultaneously",
30+
);
31+
return ExecuteResult::from_exit_code(1);
32+
}
33+
set_mode = Some(true);
34+
}
35+
"-u" => {
36+
if set_mode == Some(true) {
37+
let _ = context.stderr.write_line(
38+
"shopt: cannot set and unset options simultaneously",
39+
);
40+
return ExecuteResult::from_exit_code(1);
41+
}
42+
set_mode = Some(false);
43+
}
44+
_ => {
45+
// treat as option name
46+
match parse_option_name(&arg_str) {
47+
Some(opt) => options_to_change.push(opt),
48+
None => {
49+
let _ = context.stderr.write_line(&format!(
50+
"shopt: {}: invalid shell option name",
51+
arg_str
52+
));
53+
return ExecuteResult::from_exit_code(1);
54+
}
55+
}
56+
}
57+
}
58+
}
59+
60+
match set_mode {
61+
Some(enabled) => {
62+
// set or unset mode
63+
if options_to_change.is_empty() {
64+
let _ = context.stderr.write_line("shopt: option name required");
65+
return ExecuteResult::from_exit_code(1);
66+
}
67+
68+
let changes: Vec<EnvChange> = options_to_change
69+
.into_iter()
70+
.map(|opt| EnvChange::SetShellOption(opt, enabled))
71+
.collect();
72+
73+
ExecuteResult::Continue(0, changes, Vec::new())
74+
}
75+
None => {
76+
// query mode - print option status
77+
let current_options = context.state.shell_options();
78+
79+
if options_to_change.is_empty() {
80+
// print all options
81+
let _ = context.stdout.write_line(&format!(
82+
"failglob\t{}",
83+
if current_options.contains(ShellOptions::FAILGLOB) {
84+
"on"
85+
} else {
86+
"off"
87+
}
88+
));
89+
let _ = context.stdout.write_line(&format!(
90+
"nullglob\t{}",
91+
if current_options.contains(ShellOptions::NULLGLOB) {
92+
"on"
93+
} else {
94+
"off"
95+
}
96+
));
97+
ExecuteResult::from_exit_code(0)
98+
} else {
99+
// print specified options and return non-zero if any are off
100+
let mut any_off = false;
101+
for opt in options_to_change {
102+
let is_on = current_options.contains(opt);
103+
if !is_on {
104+
any_off = true;
105+
}
106+
let name = option_to_name(opt);
107+
let _ = context.stdout.write_line(&format!(
108+
"{}\t{}",
109+
name,
110+
if is_on { "on" } else { "off" }
111+
));
112+
}
113+
ExecuteResult::from_exit_code(if any_off { 1 } else { 0 })
114+
}
115+
}
116+
}
117+
})
118+
}
119+
}
120+
121+
fn parse_option_name(name: &str) -> Option<ShellOptions> {
122+
match name {
123+
"nullglob" => Some(ShellOptions::NULLGLOB),
124+
"failglob" => Some(ShellOptions::FAILGLOB),
125+
_ => None,
126+
}
127+
}
128+
129+
fn option_to_name(opt: ShellOptions) -> &'static str {
130+
if opt == ShellOptions::NULLGLOB {
131+
"nullglob"
132+
} else if opt == ShellOptions::FAILGLOB {
133+
"failglob"
134+
} else {
135+
"unknown"
136+
}
137+
}

src/shell/execute.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use crate::shell::types::EnvChange;
3737
use crate::shell::types::ExecuteResult;
3838
use crate::shell::types::FutureExecuteResult;
3939
use crate::shell::types::KillSignal;
40+
use crate::shell::types::ShellOptions;
4041
use crate::shell::types::ShellPipeReader;
4142
use crate::shell::types::ShellPipeWriter;
4243
use crate::shell::types::ShellState;
@@ -748,7 +749,10 @@ pub enum EvaluateWordTextError {
748749
},
749750
#[error("glob: no matches found '{}'. Pattern part was not valid utf-8", part.to_string_lossy())]
750751
NotUtf8Pattern { part: OsString },
751-
#[error("glob: no matches found '{}'", pattern)]
752+
#[error(
753+
"glob: no matches found '{}' (run `shopt -u failglob` to pass unmatched glob patterns literally)",
754+
pattern
755+
)]
752756
NoFilesMatched { pattern: String },
753757
#[error("invalid utf-8: {}", err)]
754758
InvalidUtf8 {
@@ -842,7 +846,7 @@ fn evaluate_word_parts(
842846
let is_absolute = Path::new(&current_text).is_absolute();
843847
let cwd = state.cwd();
844848
let pattern = if is_absolute {
845-
current_text
849+
current_text.clone()
846850
} else {
847851
format!("{}/{}", cwd.display(), current_text)
848852
};
@@ -862,7 +866,17 @@ fn evaluate_word_parts(
862866
let paths =
863867
paths.into_iter().filter_map(|p| p.ok()).collect::<Vec<_>>();
864868
if paths.is_empty() {
865-
Err(EvaluateWordTextError::NoFilesMatched { pattern })
869+
let options = state.shell_options();
870+
// failglob - error when set
871+
if options.contains(ShellOptions::FAILGLOB) {
872+
Err(EvaluateWordTextError::NoFilesMatched { pattern })
873+
} else if options.contains(ShellOptions::NULLGLOB) {
874+
// nullglob - return empty vec (pattern expands to nothing)
875+
Ok(Vec::new())
876+
} else {
877+
// default bash behavior - return pattern literally
878+
Ok(vec![current_text.into()])
879+
}
866880
} else {
867881
let paths = if is_absolute {
868882
paths

src/shell/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub use types::ExecuteResult;
1111
pub use types::FutureExecuteResult;
1212
pub use types::KillSignal;
1313
pub use types::KillSignalDropGuard;
14+
pub use types::ShellOptions;
1415
pub use types::ShellPipeReader;
1516
pub use types::ShellPipeWriter;
1617
pub use types::ShellState;

src/shell/types.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use std::rc::Rc;
1414
use std::rc::Weak;
1515

1616
use anyhow::Result;
17+
use bitflags::bitflags;
1718
use futures::future::LocalBoxFuture;
1819
use tokio::sync::broadcast;
1920
use tokio::task::JoinHandle;
@@ -23,6 +24,27 @@ use crate::shell::child_process_tracker::ChildProcessTracker;
2324
use super::commands::ShellCommand;
2425
use super::commands::builtin_commands;
2526

27+
bitflags! {
28+
/// Shell options that can be set via `shopt`.
29+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30+
pub struct ShellOptions: u32 {
31+
/// When set, a glob pattern that matches no files expands to nothing
32+
/// (empty) rather than returning an error.
33+
const NULLGLOB = 1 << 0;
34+
/// When set, a glob pattern that matches no files causes an error.
35+
/// This is the default for deno_task_shell (differs from bash).
36+
/// When unset, unmatched globs are passed through literally (bash default).
37+
const FAILGLOB = 1 << 1;
38+
}
39+
}
40+
41+
impl Default for ShellOptions {
42+
fn default() -> Self {
43+
// failglob is on by default to preserve existing deno_task_shell behavior
44+
ShellOptions::FAILGLOB
45+
}
46+
}
47+
2648
/// Exit code set when an async task fails or the main execution
2749
/// line fail.
2850
#[derive(Debug, Default, Clone)]
@@ -57,6 +79,8 @@ pub struct ShellState {
5779
kill_signal: KillSignal,
5880
process_tracker: ChildProcessTracker,
5981
tree_exit_code_cell: TreeExitCodeCell,
82+
/// Shell options set via `shopt`.
83+
shell_options: ShellOptions,
6084
}
6185

6286
impl ShellState {
@@ -77,6 +101,7 @@ impl ShellState {
77101
kill_signal,
78102
process_tracker: ChildProcessTracker::new(),
79103
tree_exit_code_cell: Default::default(),
104+
shell_options: ShellOptions::default(),
80105
};
81106
// ensure the data is normalized
82107
for (name, value) in env_vars {
@@ -138,6 +163,9 @@ impl ShellState {
138163
EnvChange::Cd(new_dir) => {
139164
self.set_cwd(new_dir.clone());
140165
}
166+
EnvChange::SetShellOption(option, enabled) => {
167+
self.set_shell_option(*option, *enabled);
168+
}
141169
}
142170
}
143171

@@ -169,6 +197,18 @@ impl ShellState {
169197
&self.kill_signal
170198
}
171199

200+
pub fn shell_options(&self) -> ShellOptions {
201+
self.shell_options
202+
}
203+
204+
pub fn set_shell_option(&mut self, option: ShellOptions, enabled: bool) {
205+
if enabled {
206+
self.shell_options.insert(option);
207+
} else {
208+
self.shell_options.remove(option);
209+
}
210+
}
211+
172212
pub fn track_child_process(&self, child: &tokio::process::Child) {
173213
self.process_tracker.track(child);
174214
}
@@ -213,7 +253,7 @@ impl sys_traits::BaseEnvVar for ShellState {
213253
}
214254
}
215255

216-
#[derive(Debug, PartialEq, Eq)]
256+
#[derive(Debug, Clone, PartialEq, Eq)]
217257
pub enum EnvChange {
218258
// `export ENV_VAR=VALUE`
219259
SetEnvVar(OsString, OsString),
@@ -222,6 +262,8 @@ pub enum EnvChange {
222262
// `unset ENV_VAR`
223263
UnsetVar(OsString),
224264
Cd(PathBuf),
265+
// `shopt -s/-u option`
266+
SetShellOption(ShellOptions, bool),
225267
}
226268

227269
pub type FutureExecuteResult = LocalBoxFuture<'static, ExecuteResult>;

0 commit comments

Comments
 (0)