From 2e9447d27678c73df99be900428a0f38f8bb41b7 Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Fri, 29 Nov 2024 23:29:59 -0800 Subject: [PATCH] feat(set): implement nocaseglob + nocasematch options (#282) --- brush-core/src/builtins/help.rs | 4 +- brush-core/src/completion.rs | 8 ++-- brush-core/src/expansion.rs | 12 ++--- brush-core/src/extendedtests.rs | 16 +++++-- brush-core/src/interp.rs | 6 ++- brush-core/src/patterns.rs | 19 ++++++-- brush-core/src/regex.rs | 28 ++++++++++-- brush-core/src/shell.rs | 3 +- .../tests/cases/compound_cmds/case.yaml | 8 ++++ brush-shell/tests/cases/extended_tests.yaml | 8 ++++ brush-shell/tests/cases/patterns.yaml | 19 ++++++++ brush-shell/tests/cases/word_expansion.yaml | 45 +++++++++++++++++++ brush-shell/tests/completion_tests.rs | 18 ++++++++ 13 files changed, 172 insertions(+), 22 deletions(-) diff --git a/brush-core/src/builtins/help.rs b/brush-core/src/builtins/help.rs index 3fee6af4..d706ae48 100644 --- a/brush-core/src/builtins/help.rs +++ b/brush-core/src/builtins/help.rs @@ -76,7 +76,9 @@ impl HelpCommand { context: &commands::ExecutionContext<'_>, topic_pattern: &str, ) -> Result<(), crate::error::Error> { - let pattern = crate::patterns::Pattern::from(topic_pattern); + let pattern = crate::patterns::Pattern::from(topic_pattern) + .set_extended_globbing(context.shell.options.extended_globbing) + .set_case_insensitive(context.shell.options.case_insensitive_pathname_expansion); let mut found_count = 0; for (builtin_name, builtin_registration) in get_builtins_sorted_by_name(context) { diff --git a/brush-core/src/completion.rs b/brush-core/src/completion.rs index f15b3280..5cd97ad3 100644 --- a/brush-core/src/completion.rs +++ b/brush-core/src/completion.rs @@ -253,7 +253,8 @@ impl Spec { if let Some(glob_pattern) = &self.glob_pattern { let pattern = patterns::Pattern::from(glob_pattern.as_str()) - .set_extended_globbing(shell.options.extended_globbing); + .set_extended_globbing(shell.options.extended_globbing) + .set_case_insensitive(shell.options.case_insensitive_pathname_expansion); let expansions = pattern.expand( shell.working_dir.as_path(), @@ -956,8 +957,9 @@ async fn get_file_completions( let path_filter = |path: &Path| !must_be_dir || shell.get_absolute_path(path).is_dir(); - let pattern = - patterns::Pattern::from(glob).set_extended_globbing(shell.options.extended_globbing); + let pattern = patterns::Pattern::from(glob) + .set_extended_globbing(shell.options.extended_globbing) + .set_case_insensitive(shell.options.case_insensitive_pathname_expansion); pattern .expand(shell.working_dir.as_path(), Some(&path_filter)) diff --git a/brush-core/src/expansion.rs b/brush-core/src/expansion.rs index 43d94ece..de04f739 100644 --- a/brush-core/src/expansion.rs +++ b/brush-core/src/expansion.rs @@ -404,8 +404,7 @@ impl<'a> WordExpander<'a> { .flatten() .collect(); - let pattern = patterns::Pattern::from(pattern_pieces) - .set_extended_globbing(self.parser_options.enable_extended_globbing); + let pattern = patterns::Pattern::from(pattern_pieces); Ok(pattern) } @@ -432,7 +431,8 @@ impl<'a> WordExpander<'a> { .flatten() .collect(); - Ok(crate::regex::Regex::from(regex_pieces)) + Ok(crate::regex::Regex::from(regex_pieces) + .set_case_insensitive(self.shell.options.case_insensitive_conditionals)) } /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion; @@ -528,7 +528,8 @@ impl<'a> WordExpander<'a> { fn expand_pathnames_in_field(&self, field: WordField) -> Vec { let pattern = patterns::Pattern::from(field.clone()) - .set_extended_globbing(self.parser_options.enable_extended_globbing); + .set_extended_globbing(self.parser_options.enable_extended_globbing) + .set_case_insensitive(self.shell.options.case_insensitive_pathname_expansion); let expansions = pattern .expand( @@ -1032,7 +1033,8 @@ impl<'a> WordExpander<'a> { let expanded_replacement = self.basic_expand_to_str(&replacement).await?; let pattern = patterns::Pattern::from(expanded_pattern.as_str()) - .set_extended_globbing(self.parser_options.enable_extended_globbing); + .set_extended_globbing(self.parser_options.enable_extended_globbing) + .set_case_insensitive(self.shell.options.case_insensitive_conditionals); let regex = pattern.to_regex( matches!(match_kind, brush_parser::word::SubstringMatchKind::Prefix), diff --git a/brush-core/src/extendedtests.rs b/brush-core/src/extendedtests.rs index 31c48da4..fc8a3dcf 100644 --- a/brush-core/src/extendedtests.rs +++ b/brush-core/src/extendedtests.rs @@ -351,7 +351,10 @@ async fn apply_binary_predicate( // TODO: implement case-insensitive matching if relevant via shopt options (nocasematch). ast::BinaryPredicate::StringExactlyMatchesPattern => { let s = expansion::basic_expand_word(shell, left).await?; - let pattern = expansion::basic_expand_pattern(shell, right).await?; + let pattern = expansion::basic_expand_pattern(shell, right) + .await? + .set_extended_globbing(shell.options.extended_globbing) + .set_case_insensitive(shell.options.case_insensitive_conditionals); if shell.options.print_commands_and_arguments { let expanded_right = expansion::basic_expand_word(shell, right).await?; @@ -362,7 +365,10 @@ async fn apply_binary_predicate( } ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => { let s = expansion::basic_expand_word(shell, left).await?; - let pattern = expansion::basic_expand_pattern(shell, right).await?; + let pattern = expansion::basic_expand_pattern(shell, right) + .await? + .set_extended_globbing(shell.options.extended_globbing) + .set_case_insensitive(shell.options.case_insensitive_conditionals); if shell.options.print_commands_and_arguments { let expanded_right = expansion::basic_expand_word(shell, right).await?; @@ -429,13 +435,15 @@ pub(crate) fn apply_binary_predicate_to_strs( ), ast::BinaryPredicate::StringExactlyMatchesPattern => { let pattern = patterns::Pattern::from(right) - .set_extended_globbing(shell.options.extended_globbing); + .set_extended_globbing(shell.options.extended_globbing) + .set_case_insensitive(shell.options.case_insensitive_conditionals); pattern.exactly_matches(left) } ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => { let pattern = patterns::Pattern::from(right) - .set_extended_globbing(shell.options.extended_globbing); + .set_extended_globbing(shell.options.extended_globbing) + .set_case_insensitive(shell.options.case_insensitive_conditionals); let eq = pattern.exactly_matches(left)?; Ok(!eq) diff --git a/brush-core/src/interp.rs b/brush-core/src/interp.rs index 64d6c83e..4ce77c6e 100644 --- a/brush-core/src/interp.rs +++ b/brush-core/src/interp.rs @@ -583,7 +583,11 @@ impl Execute for ast::CaseClauseCommand { } else { let mut matches = false; for pattern in &case.patterns { - let expanded_pattern = expansion::basic_expand_pattern(shell, pattern).await?; + let expanded_pattern = expansion::basic_expand_pattern(shell, pattern) + .await? + .set_extended_globbing(shell.options.extended_globbing) + .set_case_insensitive(shell.options.case_insensitive_conditionals); + if expanded_pattern.exactly_matches(expanded_value.as_str())? { matches = true; break; diff --git a/brush-core/src/patterns.rs b/brush-core/src/patterns.rs index 36913abb..7ad501bc 100644 --- a/brush-core/src/patterns.rs +++ b/brush-core/src/patterns.rs @@ -30,6 +30,7 @@ pub struct Pattern { pieces: PatternWord, enable_extended_globbing: bool, multiline: bool, + case_insensitive: bool, } impl Default for Pattern { @@ -38,6 +39,7 @@ impl Default for Pattern { pieces: vec![], enable_extended_globbing: false, multiline: true, + case_insensitive: false, } } } @@ -100,6 +102,16 @@ impl Pattern { self } + /// Enables (or disables) case-insensitive matching for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable case-insensitive matching. + pub fn set_case_insensitive(mut self, value: bool) -> Pattern { + self.case_insensitive = value; + self + } + /// Returns whether or not the pattern is empty. pub fn is_empty(&self) -> bool { self.pieces.iter().all(|p| p.as_str().is_empty()) @@ -214,8 +226,9 @@ impl Pattern { let current_paths = std::mem::take(&mut paths_so_far); for current_path in current_paths { - let subpattern = - Pattern::from(&component).set_extended_globbing(self.enable_extended_globbing); + let subpattern = Pattern::from(&component) + .set_extended_globbing(self.enable_extended_globbing) + .set_case_insensitive(self.case_insensitive); let regex = subpattern.to_regex(true, true)?; @@ -333,7 +346,7 @@ impl Pattern { tracing::debug!(target: trace_categories::PATTERN, "pattern: '{self:?}' => regex: '{regex_str}'"); - let re = regex::compile_regex(regex_str)?; + let re = regex::compile_regex(regex_str, self.case_insensitive)?; Ok(re) } diff --git a/brush-core/src/regex.rs b/brush-core/src/regex.rs index 6e0e3152..f5675687 100644 --- a/brush-core/src/regex.rs +++ b/brush-core/src/regex.rs @@ -26,15 +26,29 @@ type RegexWord = Vec; #[derive(Clone, Debug)] pub struct Regex { pieces: RegexWord, + case_insensitive: bool, } impl From for Regex { fn from(pieces: RegexWord) -> Self { - Self { pieces } + Self { + pieces, + case_insensitive: false, + } } } impl Regex { + /// Sets the regular expression's case sensitivity. + /// + /// # Arguments + /// + /// * `value` - The new case sensitivity value. + pub fn set_case_insensitive(mut self, value: bool) -> Self { + self.case_insensitive = value; + self + } + /// Computes if the regular expression matches the given string. /// /// # Arguments @@ -48,7 +62,7 @@ impl Regex { .collect(); // TODO: Evaluate how compatible the `fancy_regex` crate is with POSIX EREs. - let re = compile_regex(regex_pattern)?; + let re = compile_regex(regex_pattern, self.case_insensitive)?; Ok(re.captures(value)?.map(|captures| { captures @@ -61,8 +75,14 @@ impl Regex { #[allow(clippy::needless_pass_by_value)] #[cached::proc_macro::cached(size = 64, result = true)] -pub(crate) fn compile_regex(regex_str: String) -> Result { - match fancy_regex::Regex::new(regex_str.as_str()) { +pub(crate) fn compile_regex( + regex_str: String, + case_insensitive: bool, +) -> Result { + let mut builder = fancy_regex::RegexBuilder::new(regex_str.as_str()); + builder.case_insensitive(case_insensitive); + + match builder.build() { Ok(re) => Ok(re), Err(e) => Err(error::Error::InvalidRegexError(e, regex_str)), } diff --git a/brush-core/src/shell.rs b/brush-core/src/shell.rs index 83115378..7e5db481 100644 --- a/brush-core/src/shell.rs +++ b/brush-core/src/shell.rs @@ -939,7 +939,8 @@ impl Shell { for dir_str in self.env.get_str("PATH").unwrap_or_default().split(':') { let pattern = patterns::Pattern::from(std::format!("{dir_str}/{required_glob_pattern}")) - .set_extended_globbing(self.options.extended_globbing); + .set_extended_globbing(self.options.extended_globbing) + .set_case_insensitive(self.options.case_insensitive_pathname_expansion); // TODO: Pass through quoting. if let Ok(entries) = pattern.expand(&self.working_dir, Some(&is_executable)) { diff --git a/brush-shell/tests/cases/compound_cmds/case.yaml b/brush-shell/tests/cases/compound_cmds/case.yaml index bcc00a09..f0ffcd35 100644 --- a/brush-shell/tests/cases/compound_cmds/case.yaml +++ b/brush-shell/tests/cases/compound_cmds/case.yaml @@ -27,6 +27,14 @@ cases: *) echo "unhandled case" ;; esac + - name: "Case with case insensitive pattern" + stdin: | + shopt -s nocasematch + case "A" in + a) echo "matched" ;; + *) echo "did not match" ;; + esac + - name: "Interesting patterns in cases" stdin: | for word in "-a" "!b" "*c" "(d" "{e" ":f" "'g"; do diff --git a/brush-shell/tests/cases/extended_tests.yaml b/brush-shell/tests/cases/extended_tests.yaml index a7bf1274..8c00a3e6 100644 --- a/brush-shell/tests/cases/extended_tests.yaml +++ b/brush-shell/tests/cases/extended_tests.yaml @@ -172,6 +172,14 @@ cases: [[ a =~ ^(a|b)$ ]] && echo "4. Pass" [[ a =~ c ]] && echo "5. Pass" + - name: "Regex with case insensitivity" + stdin: | + shopt -u nocasematch + [[ "a" =~ A ]] && echo "1. Pass" + + shopt -s nocasematch + [[ "a" =~ A ]] && echo "1. Pass" + - name: "Regex with capture" stdin: | pattern='(Hello), ([a-z]+)\.' diff --git a/brush-shell/tests/cases/patterns.yaml b/brush-shell/tests/cases/patterns.yaml index a049fb4c..81d342df 100644 --- a/brush-shell/tests/cases/patterns.yaml +++ b/brush-shell/tests/cases/patterns.yaml @@ -19,6 +19,15 @@ cases: echo *.txt echo *."txt" + - name: "File expansion with nocaseglob" + test_files: + - path: "FILE1.TXT" + - path: "file2.txt" + stdin: | + shopt -s nocaseglob + echo "*.txt:" *.txt + echo "*.TXT:" *.txt + - name: "Nested directory expansion" test_files: - path: "dir/file1.txt" @@ -274,3 +283,13 @@ cases: [[ "x" == [xyz] ]] && echo "2. Matched" [[ "1" == [[:digit:]] ]] && echo "3. Matched" [[ "(" == [\(] ]] && echo "4. Matched" + + - name: "Pattern matching: case sensitivity" + stdin: | + shopt -u nocasematch + [[ "abc" == "ABC" ]] && echo "1. Matched" + [[ "abc" == "[A-Z]BC" ]] && echo "2. Matched" + + shopt -s nocasematch + [[ "abc" == "ABC" ]] && echo "3. Matched" + [[ "abc" == "[A-Z]BC" ]] && echo "4. Matched" diff --git a/brush-shell/tests/cases/word_expansion.yaml b/brush-shell/tests/cases/word_expansion.yaml index b2a8532b..7e08285a 100644 --- a/brush-shell/tests/cases/word_expansion.yaml +++ b/brush-shell/tests/cases/word_expansion.yaml @@ -436,24 +436,40 @@ cases: var="prepre-abc-sufsuf" # Smallest suffix + shopt -u nocasematch echo "\${var%}: ${var%}" echo "\${var%pre}: ${var%pre}" echo "\${var%suf}: ${var%suf}" + echo "\${var%SUF}: ${var%SUF}" + shopt -s nocasematch + echo "\${var%SUF}(nocasematch): ${var%SUF}" # Largest suffix + shopt -u nocasematch echo "\${var%%}: ${var%%}" echo "\${var%%pre}: ${var%%pre}" echo "\${var%%suf}: ${var%%suf}" + echo "\${var%%SUF}: ${var%%SUF}" + shopt -s nocasematch + echo "\${var%%SUF}(nocasematch): ${var%%SUF}" # Smallest prefix + shopt -u nocasematch echo "\${var#}: ${var#}" echo "\${var#pre}: ${var#pre}" echo "\${var#suf}: ${var#suf}" + echo "\${var#PRE}: ${var#PRE}" + shopt -s nocasematch + echo "\${var#PRE}(nocasematch): ${var#PRE}" # Largest prefix + shopt -u nocasematch echo "\${var##}: ${var##}" echo "\${var##pre}: ${var##pre}" echo "\${var##suf}: ${var##suf}" + echo "\${var##PRE}: ${var##PRE}" + shopt -s nocasematch + echo "\${var##PRE}(nocasematch): ${var##PRE}" - name: "Indirect variable references" stdin: | @@ -532,14 +548,20 @@ cases: - name: "Uppercase first character" stdin: | var="hello" + + shopt -u nocasematch echo "\${var^}: ${var^}" echo "\${var^h}: ${var^h}" + echo "\${var^H}: ${var^H}" echo "\${var^l}: ${var^l}" echo "\${var^h*}: ${var^h*}" echo "\${var^he}: ${var^he}" echo "\${var^?}: ${var^?}" echo "\${var^*}: ${var^*}" + shopt -s nocasematch + echo "\${var^H}(nocasematch): ${var^H}" + arr=("hello" "world") echo "\${arr^}: ${arr^}" echo "\${arr[@]^}: ${arr[@]^}" @@ -548,10 +570,16 @@ cases: - name: "Uppercase matching pattern" stdin: | var="hello" + + shopt -u nocasematch echo "\${var^^}: ${var^^}" echo "\${var^^l}: ${var^^l}" + echo "\${var^^L}: ${var^^L}" echo "\${var^^m}: ${var^^m}" + shopt -s nocasematch + echo "\${var^^L}(nocasematch): ${var^^L}" + arr=("hello" "world") echo "\${arr^^}: ${arr^^}" echo "\${arr[@]^^}: ${arr[@]^^}" @@ -560,7 +588,10 @@ cases: - name: "Lowercase first character" stdin: | var="HELLO" + + shopt -u nocasematch echo "\${var,}: ${var,}" + echo "\${var,h}: ${var,h}" echo "\${var,H}: ${var,H}" echo "\${var,L}: ${var,L}" echo "\${var,H*}: ${var,H*}" @@ -568,6 +599,9 @@ cases: echo "\${var,?}: ${var,?}" echo "\${var,*}: ${var,*}" + shopt -s nocasematch + echo "\${var,h}(nocasematch): ${var,h}" + arr=("HELLO" "WORLD") echo "\${arr,}: ${arr,}" echo "\${arr[@],}: ${arr[@],}" @@ -576,9 +610,15 @@ cases: - name: "Lowercase matching pattern" stdin: | var="HELLO" + + shopt -u nocasematch echo "\${var,,}: ${var,,}" echo "\${var,,M}: ${var,,M}" echo "\${var,,L}: ${var,,L}" + echo "\${var,,l}: ${var,,l}" + + shopt -s nocasematch + echo "\${var,,l}(nocasematch): ${var,,l}" arr=("HELLO" "WORLD") echo "\${arr,,}: ${arr,,}" @@ -635,8 +675,13 @@ cases: - name: "Global substring removal" stdin: | var="That is not all" + + shopt -u nocasematch echo "\${var//not }: ${var//not}" + shopt -s nocasematch + echo "\${var//NOT }: ${var//NOT }" + - name: "Substring from offset" stdin: | var="Hello, world!" diff --git a/brush-shell/tests/completion_tests.rs b/brush-shell/tests/completion_tests.rs index 7cf98e06..777651f7 100644 --- a/brush-shell/tests/completion_tests.rs +++ b/brush-shell/tests/completion_tests.rs @@ -88,6 +88,24 @@ async fn complete_relative_file_path() -> Result<()> { Ok(()) } +#[tokio::test] +async fn complete_relative_file_path_ignoring_case() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + test_shell.shell.options.case_insensitive_pathname_expansion = true; + + // Create file and dir. + test_shell.temp_dir.child("ITEM1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see the two files. + let input = "ls item"; + let results = test_shell.complete(input, input.len()).await?; + + assert_eq!(results, ["ITEM1", "item2"]); + + Ok(()) +} + #[tokio::test] async fn complete_relative_dir_path() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?;