Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ serialization = ["serde"]

[dependencies]
anyhow = "1.0.75"
bitflags = "2.6"
futures = { version = "0.3.29", optional = true }
glob = { version = "0.3.1", optional = true }
path-dedot = { version = "3.1.1", optional = true }
Expand Down
2 changes: 1 addition & 1 deletion rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.89.0"
channel = "1.92.0"
components = ["clippy", "rustfmt"]
5 changes: 5 additions & 0 deletions src/shell/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod head;
mod mkdir;
mod pwd;
mod rm;
mod shopt;
mod sleep;
mod unset;
mod xargs;
Expand Down Expand Up @@ -76,6 +77,10 @@ pub fn builtin_commands() -> HashMap<String, Rc<dyn ShellCommand>> {
"rm".to_string(),
Rc::new(rm::RmCommand) as Rc<dyn ShellCommand>,
),
(
"shopt".to_string(),
Rc::new(shopt::ShoptCommand) as Rc<dyn ShellCommand>,
),
(
"sleep".to_string(),
Rc::new(sleep::SleepCommand) as Rc<dyn ShellCommand>,
Expand Down
137 changes: 137 additions & 0 deletions src/shell/commands/shopt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright 2018-2025 the Deno authors. MIT license.

use futures::future::LocalBoxFuture;

use crate::shell::types::EnvChange;
use crate::shell::types::ExecuteResult;
use crate::shell::types::ShellOptions;

use super::ShellCommand;
use super::ShellCommandContext;

pub struct ShoptCommand;

impl ShellCommand for ShoptCommand {
fn execute(
&self,
mut context: ShellCommandContext,
) -> LocalBoxFuture<'static, ExecuteResult> {
Box::pin(async move {
let mut set_mode = None; // None = query, Some(true) = -s, Some(false) = -u
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this written by AI? :D This combination is strange compared to using an enum

Copy link
Member Author

@dsherret dsherret Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I don’t think it’s that strange because each individual one can be set in an iteration of the loop.

Oh wait, I misunderstood this when I originally commented, but I think Option<bool> makes sense here.

let mut options_to_change = Vec::new();

for arg in context.args.into_iter().peekable() {
let arg_str = arg.to_string_lossy();
match arg_str.as_ref() {
"-s" => {
if set_mode == Some(false) {
let _ = context.stderr.write_line(
"shopt: cannot set and unset options simultaneously",
);
return ExecuteResult::from_exit_code(1);
}
set_mode = Some(true);
}
"-u" => {
if set_mode == Some(true) {
let _ = context.stderr.write_line(
"shopt: cannot set and unset options simultaneously",
);
return ExecuteResult::from_exit_code(1);
}
set_mode = Some(false);
}
_ => {
// treat as option name
match parse_option_name(&arg_str) {
Some(opt) => options_to_change.push(opt),
None => {
let _ = context.stderr.write_line(&format!(
"shopt: {}: invalid shell option name",
arg_str
));
return ExecuteResult::from_exit_code(1);
}
}
}
}
}

match set_mode {
Some(enabled) => {
// set or unset mode
if options_to_change.is_empty() {
let _ = context.stderr.write_line("shopt: option name required");
return ExecuteResult::from_exit_code(1);
}

let changes: Vec<EnvChange> = options_to_change
.into_iter()
.map(|opt| EnvChange::SetShellOption(opt, enabled))
.collect();

ExecuteResult::Continue(0, changes, Vec::new())
}
None => {
// query mode - print option status
let current_options = context.state.shell_options();

if options_to_change.is_empty() {
// print all options
let _ = context.stdout.write_line(&format!(
"failglob\t{}",
if current_options.contains(ShellOptions::FAILGLOB) {
"on"
} else {
"off"
}
));
let _ = context.stdout.write_line(&format!(
"nullglob\t{}",
if current_options.contains(ShellOptions::NULLGLOB) {
"on"
} else {
"off"
}
));
ExecuteResult::from_exit_code(0)
} else {
// print specified options and return non-zero if any are off
let mut any_off = false;
for opt in options_to_change {
let is_on = current_options.contains(opt);
if !is_on {
any_off = true;
}
let name = option_to_name(opt);
let _ = context.stdout.write_line(&format!(
"{}\t{}",
name,
if is_on { "on" } else { "off" }
));
}
ExecuteResult::from_exit_code(if any_off { 1 } else { 0 })
}
}
}
})
}
}

fn parse_option_name(name: &str) -> Option<ShellOptions> {
match name {
"nullglob" => Some(ShellOptions::NULLGLOB),
"failglob" => Some(ShellOptions::FAILGLOB),
_ => None,
}
}

fn option_to_name(opt: ShellOptions) -> &'static str {
if opt == ShellOptions::NULLGLOB {
"nullglob"
} else if opt == ShellOptions::FAILGLOB {
"failglob"
} else {
"unknown"
}
}
20 changes: 17 additions & 3 deletions src/shell/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ use crate::shell::types::EnvChange;
use crate::shell::types::ExecuteResult;
use crate::shell::types::FutureExecuteResult;
use crate::shell::types::KillSignal;
use crate::shell::types::ShellOptions;
use crate::shell::types::ShellPipeReader;
use crate::shell::types::ShellPipeWriter;
use crate::shell::types::ShellState;
Expand Down Expand Up @@ -748,7 +749,10 @@ pub enum EvaluateWordTextError {
},
#[error("glob: no matches found '{}'. Pattern part was not valid utf-8", part.to_string_lossy())]
NotUtf8Pattern { part: OsString },
#[error("glob: no matches found '{}'", pattern)]
#[error(
"glob: no matches found '{}' (run `shopt -u failglob` to pass unmatched glob patterns literally)",
pattern
)]
NoFilesMatched { pattern: String },
#[error("invalid utf-8: {}", err)]
InvalidUtf8 {
Expand Down Expand Up @@ -842,7 +846,7 @@ fn evaluate_word_parts(
let is_absolute = Path::new(&current_text).is_absolute();
let cwd = state.cwd();
let pattern = if is_absolute {
current_text
current_text.clone()
} else {
format!("{}/{}", cwd.display(), current_text)
};
Expand All @@ -862,7 +866,17 @@ fn evaluate_word_parts(
let paths =
paths.into_iter().filter_map(|p| p.ok()).collect::<Vec<_>>();
if paths.is_empty() {
Err(EvaluateWordTextError::NoFilesMatched { pattern })
let options = state.shell_options();
// failglob - error when set
if options.contains(ShellOptions::FAILGLOB) {
Err(EvaluateWordTextError::NoFilesMatched { pattern })
} else if options.contains(ShellOptions::NULLGLOB) {
// nullglob - return empty vec (pattern expands to nothing)
Ok(Vec::new())
} else {
// default bash behavior - return pattern literally
Ok(vec![current_text.into()])
}
} else {
let paths = if is_absolute {
paths
Expand Down
1 change: 1 addition & 0 deletions src/shell/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub use types::ExecuteResult;
pub use types::FutureExecuteResult;
pub use types::KillSignal;
pub use types::KillSignalDropGuard;
pub use types::ShellOptions;
pub use types::ShellPipeReader;
pub use types::ShellPipeWriter;
pub use types::ShellState;
Expand Down
44 changes: 43 additions & 1 deletion src/shell/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::rc::Rc;
use std::rc::Weak;

use anyhow::Result;
use bitflags::bitflags;
use futures::future::LocalBoxFuture;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
Expand All @@ -23,6 +24,27 @@ use crate::shell::child_process_tracker::ChildProcessTracker;
use super::commands::ShellCommand;
use super::commands::builtin_commands;

bitflags! {
/// Shell options that can be set via `shopt`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShellOptions: u32 {
/// When set, a glob pattern that matches no files expands to nothing
/// (empty) rather than returning an error.
const NULLGLOB = 1 << 0;
/// When set, a glob pattern that matches no files causes an error.
/// This is the default for deno_task_shell (differs from bash).
/// When unset, unmatched globs are passed through literally (bash default).
const FAILGLOB = 1 << 1;
}
}

impl Default for ShellOptions {
fn default() -> Self {
// failglob is on by default to preserve existing deno_task_shell behavior
ShellOptions::FAILGLOB
}
}

/// Exit code set when an async task fails or the main execution
/// line fail.
#[derive(Debug, Default, Clone)]
Expand Down Expand Up @@ -57,6 +79,8 @@ pub struct ShellState {
kill_signal: KillSignal,
process_tracker: ChildProcessTracker,
tree_exit_code_cell: TreeExitCodeCell,
/// Shell options set via `shopt`.
shell_options: ShellOptions,
}

impl ShellState {
Expand All @@ -77,6 +101,7 @@ impl ShellState {
kill_signal,
process_tracker: ChildProcessTracker::new(),
tree_exit_code_cell: Default::default(),
shell_options: ShellOptions::default(),
};
// ensure the data is normalized
for (name, value) in env_vars {
Expand Down Expand Up @@ -138,6 +163,9 @@ impl ShellState {
EnvChange::Cd(new_dir) => {
self.set_cwd(new_dir.clone());
}
EnvChange::SetShellOption(option, enabled) => {
self.set_shell_option(*option, *enabled);
}
}
}

Expand Down Expand Up @@ -169,6 +197,18 @@ impl ShellState {
&self.kill_signal
}

pub fn shell_options(&self) -> ShellOptions {
self.shell_options
}

pub fn set_shell_option(&mut self, option: ShellOptions, enabled: bool) {
if enabled {
self.shell_options.insert(option);
} else {
self.shell_options.remove(option);
}
}

pub fn track_child_process(&self, child: &tokio::process::Child) {
self.process_tracker.track(child);
}
Expand Down Expand Up @@ -213,7 +253,7 @@ impl sys_traits::BaseEnvVar for ShellState {
}
}

#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvChange {
// `export ENV_VAR=VALUE`
SetEnvVar(OsString, OsString),
Expand All @@ -222,6 +262,8 @@ pub enum EnvChange {
// `unset ENV_VAR`
UnsetVar(OsString),
Cd(PathBuf),
// `shopt -s/-u option`
SetShellOption(ShellOptions, bool),
}

pub type FutureExecuteResult = LocalBoxFuture<'static, ExecuteResult>;
Expand Down
Loading