Skip to content

Commit d65dcd7

Browse files
committed
feat: configurable globstar
1 parent 0fde85b commit d65dcd7

File tree

4 files changed

+119
-8
lines changed

4 files changed

+119
-8
lines changed

src/shell/commands/shopt.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ impl ShellCommand for ShoptCommand {
8686
"off"
8787
}
8888
));
89+
let _ = context.stdout.write_line(&format!(
90+
"globstar\t{}",
91+
if current_options.contains(ShellOptions::GLOBSTAR) {
92+
"on"
93+
} else {
94+
"off"
95+
}
96+
));
8997
let _ = context.stdout.write_line(&format!(
9098
"nullglob\t{}",
9199
if current_options.contains(ShellOptions::NULLGLOB) {
@@ -122,6 +130,7 @@ fn parse_option_name(name: &str) -> Option<ShellOptions> {
122130
match name {
123131
"nullglob" => Some(ShellOptions::NULLGLOB),
124132
"failglob" => Some(ShellOptions::FAILGLOB),
133+
"globstar" => Some(ShellOptions::GLOBSTAR),
125134
_ => None,
126135
}
127136
}
@@ -131,6 +140,8 @@ fn option_to_name(opt: ShellOptions) -> &'static str {
131140
"nullglob"
132141
} else if opt == ShellOptions::FAILGLOB {
133142
"failglob"
143+
} else if opt == ShellOptions::GLOBSTAR {
144+
"globstar"
134145
} else {
135146
"unknown"
136147
}

src/shell/execute.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -858,11 +858,25 @@ fn evaluate_word_parts(
858858
}
859859
let is_absolute = Path::new(&current_text).is_absolute();
860860
let cwd = state.cwd();
861-
let pattern = if is_absolute {
861+
let options = state.shell_options();
862+
863+
// when globstar is disabled, replace ** with * so it doesn't match
864+
// across directory boundaries (the glob crate always treats ** as recursive)
865+
let pattern_text = if !options.contains(ShellOptions::GLOBSTAR)
866+
&& current_text.contains("**")
867+
{
868+
// replace ** with * to disable recursive matching
869+
current_text.replace("**", "*")
870+
} else {
862871
current_text.clone()
872+
};
873+
874+
let pattern = if is_absolute {
875+
pattern_text.clone()
863876
} else {
864-
format!("{}/{}", cwd.display(), current_text)
877+
format!("{}/{}", cwd.display(), pattern_text)
865878
};
879+
866880
let result = glob::glob_with(
867881
&pattern,
868882
glob::MatchOptions {
@@ -879,7 +893,6 @@ fn evaluate_word_parts(
879893
let paths =
880894
paths.into_iter().filter_map(|p| p.ok()).collect::<Vec<_>>();
881895
if paths.is_empty() {
882-
let options = state.shell_options();
883896
// failglob - error when set
884897
if options.contains(ShellOptions::FAILGLOB) {
885898
Err(EvaluateWordTextError::NoFilesMatched { pattern })

src/shell/types.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ bitflags! {
3838
/// When set, pipeline exit code is the rightmost non-zero exit code.
3939
/// Set via `set -o pipefail`.
4040
const PIPEFAIL = 1 << 2;
41+
/// When set, the pattern `**` used in a pathname expansion context will
42+
/// match all files and zero or more directories and subdirectories.
43+
const GLOBSTAR = 1 << 3;
4144
}
4245
}
4346

tests/integration_test.rs

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,13 +1372,14 @@ async fn glob_basic() {
13721372
.run()
13731373
.await;
13741374

1375+
// ** only matches recursively when globstar is enabled
13751376
TestBuilder::new()
13761377
.directory("sub_dir/sub")
13771378
.file("sub_dir/sub/1.txt", "1\n")
13781379
.file("sub_dir/2.txt", "2\n")
13791380
.file("sub_dir/other.ts", "other\n")
13801381
.file("3.txt", "3\n")
1381-
.command("cat **/*.txt")
1382+
.command("shopt -s globstar && cat **/*.txt")
13821383
.assert_stdout("3\n2\n1\n")
13831384
.run()
13841385
.await;
@@ -1389,7 +1390,7 @@ async fn glob_basic() {
13891390
.file("sub_dir/2.txt", "2\n")
13901391
.file("sub_dir/other.ts", "other\n")
13911392
.file("3.txt", "3\n")
1392-
.command("cat $PWD/**/*.txt")
1393+
.command("shopt -s globstar && cat $PWD/**/*.txt")
13931394
.assert_stdout("3\n2\n1\n")
13941395
.run()
13951396
.await;
@@ -1702,10 +1703,10 @@ async fn sigpipe_from_pipeline() {
17021703

17031704
#[tokio::test]
17041705
async fn shopt() {
1705-
// query all options (default: failglob on, nullglob off)
1706+
// query all options (default: failglob on, globstar off, nullglob off)
17061707
TestBuilder::new()
17071708
.command("shopt")
1708-
.assert_stdout("failglob\ton\nnullglob\toff\n")
1709+
.assert_stdout("failglob\ton\nglobstar\toff\nnullglob\toff\n")
17091710
.run()
17101711
.await;
17111712

@@ -1767,7 +1768,23 @@ async fn shopt() {
17671768
// multiple options
17681769
TestBuilder::new()
17691770
.command("shopt -s nullglob && shopt -u failglob && shopt")
1770-
.assert_stdout("failglob\toff\nnullglob\ton\n")
1771+
.assert_stdout("failglob\toff\nglobstar\toff\nnullglob\ton\n")
1772+
.run()
1773+
.await;
1774+
1775+
// query globstar option
1776+
TestBuilder::new()
1777+
.command("shopt globstar")
1778+
.assert_stdout("globstar\toff\n")
1779+
.assert_exit_code(1) // returns 1 when option is off
1780+
.run()
1781+
.await;
1782+
1783+
// enable globstar
1784+
TestBuilder::new()
1785+
.command("shopt -s globstar && shopt globstar")
1786+
.assert_stdout("globstar\ton\n")
1787+
.assert_exit_code(0)
17711788
.run()
17721789
.await;
17731790
}
@@ -1850,6 +1867,73 @@ async fn shopt_failglob() {
18501867
.await;
18511868
}
18521869

1870+
#[tokio::test]
1871+
async fn shopt_globstar() {
1872+
// without globstar: ** behaves like * (single-segment match, doesn't recurse)
1873+
// **/*.txt without globstar is like */*.txt - matches one level deep only
1874+
TestBuilder::new()
1875+
.directory("sub/deep")
1876+
.file("a.txt", "a\n")
1877+
.file("sub/b.txt", "b\n")
1878+
.file("sub/deep/c.txt", "c\n")
1879+
.command("echo **/*.txt")
1880+
.assert_stdout(&format!("sub{FOLDER_SEPERATOR}b.txt\n")) // only matches one level deep
1881+
.assert_exit_code(0)
1882+
.run()
1883+
.await;
1884+
1885+
// with globstar: ** matches zero or more directories
1886+
TestBuilder::new()
1887+
.directory("sub/deep")
1888+
.file("a.txt", "a\n")
1889+
.file("sub/b.txt", "b\n")
1890+
.file("sub/deep/c.txt", "c\n")
1891+
.command("shopt -s globstar && echo **/*.txt | tr ' ' '\\n' | sort")
1892+
.assert_stdout(&format!(
1893+
"a.txt\nsub{FOLDER_SEPERATOR}b.txt\nsub{FOLDER_SEPERATOR}deep{FOLDER_SEPERATOR}c.txt\n"
1894+
))
1895+
.assert_exit_code(0)
1896+
.run()
1897+
.await;
1898+
1899+
// globstar with **/ prefix matches recursively in subdirectories
1900+
TestBuilder::new()
1901+
.directory("sub/deep")
1902+
.file("root.txt", "r\n")
1903+
.file("sub/nested.txt", "n\n")
1904+
.file("sub/deep/deeper.txt", "d\n")
1905+
.command("shopt -s globstar && echo **/*.txt | tr ' ' '\\n' | sort")
1906+
.assert_stdout(&format!(
1907+
"root.txt\nsub{FOLDER_SEPERATOR}deep{FOLDER_SEPERATOR}deeper.txt\nsub{FOLDER_SEPERATOR}nested.txt\n"
1908+
))
1909+
.assert_exit_code(0)
1910+
.run()
1911+
.await;
1912+
1913+
// single * still behaves normally even with globstar enabled
1914+
TestBuilder::new()
1915+
.directory("sub")
1916+
.file("a.txt", "a\n")
1917+
.file("sub/b.txt", "b\n")
1918+
.command("shopt -s globstar && echo *.txt")
1919+
.assert_stdout("a.txt\n")
1920+
.assert_exit_code(0)
1921+
.run()
1922+
.await;
1923+
1924+
// disabling globstar goes back to single-segment behavior
1925+
TestBuilder::new()
1926+
.directory("sub/deep")
1927+
.file("a.txt", "a\n")
1928+
.file("sub/b.txt", "b\n")
1929+
.file("sub/deep/c.txt", "c\n")
1930+
.command("shopt -s globstar && shopt -u globstar && echo **/*.txt")
1931+
.assert_stdout(&format!("sub{FOLDER_SEPERATOR}b.txt\n")) // back to single-level match
1932+
.assert_exit_code(0)
1933+
.run()
1934+
.await;
1935+
}
1936+
18531937
#[tokio::test]
18541938
async fn pipefail_option() {
18551939
// Without pipefail: exit code is from last command (0)

0 commit comments

Comments
 (0)