Repository: reubeno/brush (rust)

Issue:
dot at the beginning of a filename should only be matched by a pattern that starts with a dot literal
For example running 
```sh
echo [[:punct:]]*
```
in brush and bash produces different results. If I run it inside the repository I get:
- brush:
```text
.cargo .cliffignore .config .devcontainer .git .github .gitignore .idea .lycheeignore .vscode
```
- bash:
```text
[[:punct:]]*
```

Pull request description:
feat(options): implement dotglob semantics
Ensures that filename expansion complies with `dotglob` option setting. The tests added are marked for bash 5.2+ as the oracle, since we're seeing different results from earlier versions of bash related to enumeration of `.` and `..`.

Resolves #328 

Patch:
diff --git a/brush-core/src/completion.rs b/brush-core/src/completion.rs
index bc861ca..36349a0 100644
--- a/brush-core/src/completion.rs
+++ b/brush-core/src/completion.rs
@@ -260,6 +260,7 @@ impl Spec {
             let expansions = pattern.expand(
                 shell.working_dir.as_path(),
                 Some(&patterns::Pattern::accept_all_expand_filter),
+                &patterns::FilenameExpansionOptions::default(),
             )?;
 
             for expansion in expansions {
@@ -1062,7 +1063,11 @@ async fn get_file_completions(
         .set_case_insensitive(shell.options.case_insensitive_pathname_expansion);
 
     pattern
-        .expand(shell.working_dir.as_path(), Some(&path_filter))
+        .expand(
+            shell.working_dir.as_path(),
+            Some(&path_filter),
+            &patterns::FilenameExpansionOptions::default(),
+        )
         .unwrap_or_default()
         .into_iter()
         .collect()
diff --git a/brush-core/src/expansion.rs b/brush-core/src/expansion.rs
index a2ef88f..7400d73 100644
--- a/brush-core/src/expansion.rs
+++ b/brush-core/src/expansion.rs
@@ -581,10 +581,15 @@ impl<'a> WordExpander<'a> {
             .set_extended_globbing(self.parser_options.enable_extended_globbing)
             .set_case_insensitive(self.shell.options.case_insensitive_pathname_expansion);
 
+        let options = patterns::FilenameExpansionOptions {
+            require_dot_in_pattern_to_match_dot_files: !self.shell.options.glob_matches_dotfiles,
+        };
+
         let expansions = pattern
             .expand(
                 self.shell.working_dir.as_path(),
                 Some(&patterns::Pattern::accept_all_expand_filter),
+                &options,
             )
             .unwrap_or_default();
 
diff --git a/brush-core/src/patterns.rs b/brush-core/src/patterns.rs
index 9ecbeff..ca88a02 100644
--- a/brush-core/src/patterns.rs
+++ b/brush-core/src/patterns.rs
@@ -24,6 +24,12 @@ impl PatternPiece {
 
 type PatternWord = Vec<PatternPiece>;
 
+/// Options for filename expansion.
+#[derive(Clone, Debug, Default)]
+pub(crate) struct FilenameExpansionOptions {
+    pub require_dot_in_pattern_to_match_dot_files: bool,
+}
+
 /// Encapsulates a shell pattern.
 #[derive(Clone, Debug)]
 pub struct Pattern {
@@ -134,6 +140,7 @@ impl Pattern {
         &self,
         working_dir: &Path,
         path_filter: Option<&PF>,
+        options: &FilenameExpansionOptions,
     ) -> Result<Vec<String>, error::Error>
     where
         PF: Fn(&Path) -> bool,
@@ -230,8 +237,19 @@ impl Pattern {
                     .set_extended_globbing(self.enable_extended_globbing)
                     .set_case_insensitive(self.case_insensitive);
 
-                let regex = subpattern.to_regex(true, true)?;
+                let subpattern_starts_with_dot = subpattern
+                    .pieces
+                    .first()
+                    .is_some_and(|piece| piece.as_str().starts_with('.'));
+
+                let allow_dot_files = !options.require_dot_in_pattern_to_match_dot_files
+                    || subpattern_starts_with_dot;
 
+                let matches_dotfile_policy = |dir_entry: &std::fs::DirEntry| {
+                    !dir_entry.file_name().to_string_lossy().starts_with('.') || allow_dot_files
+                };
+
+                let regex = subpattern.to_regex(true, true)?;
                 let matches_regex = |dir_entry: &std::fs::DirEntry| {
                     regex
                         .is_match(dir_entry.file_name().to_string_lossy().as_ref())
@@ -244,6 +262,7 @@ impl Pattern {
                     .into_iter()
                     .filter_map(|result| result.ok())
                     .filter(matches_regex)
+                    .filter(matches_dotfile_policy)
                     .map(|entry| entry.path())
                     .collect();
 
diff --git a/brush-core/src/shell.rs b/brush-core/src/shell.rs
index 4f9986f..deeb22c 100644
--- a/brush-core/src/shell.rs
+++ b/brush-core/src/shell.rs
@@ -976,7 +976,11 @@ impl Shell {
                     .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)) {
+            if let Ok(entries) = pattern.expand(
+                &self.working_dir,
+                Some(&is_executable),
+                &patterns::FilenameExpansionOptions::default(),
+            ) {
                 for entry in entries {
                     executables.push(PathBuf::from(entry));
                 }


Test patch:
diff --git a/brush-shell/tests/cases/patterns/filename_expansion.yaml b/brush-shell/tests/cases/patterns/filename_expansion.yaml
index 16a457a..ec4d2d6 100644
--- a/brush-shell/tests/cases/patterns/filename_expansion.yaml
+++ b/brush-shell/tests/cases/patterns/filename_expansion.yaml
@@ -197,7 +197,7 @@ cases:
       echo "test.*"
 
   - name: "Pathname expansion: dot files (no dotglob)"
-    known_failure: true # https://github.com/reubeno/brush/issues/328
+    min_oracle_version: 5.2
     stdin: |
       touch .file
       touch .dir
@@ -210,7 +210,7 @@ cases:
       echo "./.*: " ./.*
 
   - name: "Pathname expansion: dot files (with dotglob)"
-    skip: true # TODO: passes on *some* platforms but not others (due to . and ..); needs investigation
+    min_oracle_version: 5.2
     stdin: |
       touch .file
       touch .dir