Repository: facelessuser/wcmatch

Issue:
Add `BASEMATCH` flag to glob
Add a flag to glob to enable a feature that performs a "base match". What this means is that when a pattern is given that contains no path separators (`some*.pattern`), the filename will be treated as a recursive search: (`**/some*.pattern`).

Basically it would allow you do do something like:

```py
>>> glob.glob('*.py|*.c', flags=glob.SPLIT | glob.BASEMATCH)
['a.py', 'b.c', 'folder/c.py', 'folder.d.py']
```

Discussion:

Patch:
diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt
index 6d93036..4ae8b4f 100644
--- a/docs/src/dictionary/en-custom.txt
+++ b/docs/src/dictionary/en-custom.txt
@@ -21,6 +21,7 @@ Wildcard
 XORed
 accessor
 amongst
+basename
 boolean
 builtin
 centric
diff --git a/docs/src/markdown/changelog.md b/docs/src/markdown/changelog.md
index 8f1125c..6d6bdc7 100644
--- a/docs/src/markdown/changelog.md
+++ b/docs/src/markdown/changelog.md
@@ -4,6 +4,7 @@
 
 - **NEW**: Deprecated `WcMatch` class methods `kill` and `reset`. `WcMatch` should be broken with a simple `break` statement instead.
 - **NEW**: Add a new flag `MARK` to force `glob` to return directories with a trailing slash.
+- **NEW**: Add `MATCHBASE` that causes `glob` and `WcMatch`, when the pattern has no slashes in it, to seek for any file anywhere in the tree with a matching basename.
 
 ## 3.0.2
 
diff --git a/docs/src/markdown/glob.md b/docs/src/markdown/glob.md
index 4330942..dd55190 100644
--- a/docs/src/markdown/glob.md
+++ b/docs/src/markdown/glob.md
@@ -351,11 +351,11 @@ True
 True
 ```
 
-#### `glob.MARK, glob.MK` {: #globmark}
+#### `glob.MARK, glob.K` {: #globmark}
 
-`MARK` ensures that [`glob`](#globglob) returns all directories with a trailing slash. This makes it very clear which
-paths are directories and allows you to save calling `os.path.isdir` as you can simply check for a path separator at the
-end of the path. This flag only applies to calls to `glob` or `iglob`.
+`MARK` ensures that [`glob`](#globglob) and [`iglob`](#globiglob) to return all directories with a trailing slash. This
+makes it very clear which paths are directories and allows you to save calling `os.path.isdir` as you can simply check
+for a path separator at the end of the path. This flag only applies to calls to `glob` or `iglob`.
 
 If you are passing the returned files from `glob` to [`globfilter`](#globglobfilter) or [`globmatch`](#globglobmatch),
 it is important to ensure directory paths have trailing slashes as these functions have no way of telling the path is a
@@ -366,11 +366,29 @@ no trailing slash.
 ```pycon3
 >>> from wcmatch import glob
 >>> glob.glob('*', flags=glob.MARK)
-['appveyor.yml', 'base.patch', 'basematch.diff', 'docs\\', 'LICENSE.md', 'MANIFEST.in', 'mkdocs.yml', 'README.md', 'requirements\\', 'setup.cfg', 'setup.py', 'tests\\', 'tools\\', 'tox.ini', 'wcmatch\\']
+['appveyor.yml', 'base.patch', 'basematch.diff', 'docs/', 'LICENSE.md', 'MANIFEST.in', 'mkdocs.yml', 'README.md', 'requirements/', 'setup.cfg', 'setup.py', 'tests/', 'tools/', 'tox.ini', 'wcmatch/']
 >>> glob.glob('*')
 ['appveyor.yml', 'base.patch', 'basematch.diff', 'docs', 'LICENSE.md', 'MANIFEST.in', 'mkdocs.yml', 'README.md', 'requirements', 'setup.cfg', 'setup.py', 'tests', 'tools', 'tox.ini', 'wcmatch']
 ```
 
+!!! new "New 3.1"
+    `MARK` added in 3.1.
+
+#### `glob.MATCHBASE, glob.X` {: #globmatchbase}
+
+`MATCHBASE`, when a pattern has no slashes in it, will cause [`glob`](#globglob) and [`iglob`](#globiglob) to seek for
+any file anywhere in the tree with a matching basename. When enabled for [`globfilter`](#globglobfilter) and
+[`globmatch`](#globglobmatch), they will match any basename in the tree.
+
+```pycon3
+>>> from wcmatch import glob
+>>> glob.glob('*.txt', flags=glob.MATCHBASE)
+['docs/src/dictionary/en-custom.txt', 'docs/src/markdown/_snippets/abbr.txt', 'docs/src/markdown/_snippets/links.txt', 'docs/src/markdown/_snippets/posix.txt', 'docs/src/markdown/_snippets/refs.txt', 'requirements/docs.txt', 'requirements/lint.txt', 'requirements/setup.txt', 'requirements/test.txt', 'requirements/tools.txt'] 
+```
+
+!!! new "New 3.1"
+    `MATCHBASE` added in 3.1.
+
 --8<--
 refs.txt
 --8<--
diff --git a/docs/src/markdown/wcmatch.md b/docs/src/markdown/wcmatch.md
index a81efa2..59ef95a 100644
--- a/docs/src/markdown/wcmatch.md
+++ b/docs/src/markdown/wcmatch.md
@@ -314,6 +314,31 @@ Be careful with patterns such as `{1..100}` which would generate one hundred pat
 
 `PATHNAME` enables both [`DIRPATHNAME`](#wcmatchdirpathname) and [`FILEPATHNAME`](#wcmathfilepathname). It is provided for convenience.
 
+#### `wcmatch.MATCHBASE, wcmatch.X` {: #wcmatchmatchbase}
+
+When `FILEPATHNAME` or `DIRPATHNAME` is enabled, `MATCHBASE` will ensure that that the respective file or directory pattern, when there are no slashes in the pattern, seeks for any file anywhere in the tree with a matching basename. This is essentially the behavior when `FILEPATHNAME` and `DIRPATHNAME` is disabled, but with `MATCHBASE`, you can toggle the behavior by included slashes in your pattern.
+
+When we include no slashes:
+
+```pycon3
+>>> wcmatch.WcMatch('.', '*.md', flags=wcmatch.FILEPATHNAME | wcmatch.GLOBSTAR | wcmatch.MATCHBASE | wcmatch.RECURSIVE).match()
+['./LICENSE.md', './README.md', './docs/src/markdown/changelog.md', './docs/src/markdown/fnmatch.md', './docs/src/markdown/glob.md', './docs/src/markdown/index.md', './docs/src/markdown/license.md', './docs/src/markdown/wcmatch.md']
+```
+
+If we include slashes in the pattern, the path, not the basename, must match the pattern:
+
+```pycon3
+>>> wcmatch.WcMatch('.', 'docs/**/*.md', flags=wcmatch.FILEPATHNAME | wcmatch.GLOBSTAR | wcmatch.MATCHBASE | wcmatch.RECURSIVE).match()
+['./docs/src/markdown/changelog.md', './docs/src/markdown/fnmatch.md', './docs/src/markdown/glob.md', './docs/src/markdown/index.md', './docs/src/markdown/license.md', './docs/src/markdown/wcmatch.md']
+```
+
+If we have a leading slash, the pattern will not perform a match on the basename, but will instead be a normal path pattern that is anchored to the current base path, in this case `.`.
+
+```pycon3
+>>> wcmatch.WcMatch('.', '/*.md', flags=wcmatch.FILEPATHNAME | wcmatch.GLOBSTAR | wcmatch.MATCHBASE | wcmatch.RECURSIVE).match()
+['./LICENSE.md', './README.md']
+```
+
 #### `wcmatch.GLOBSTAR, wcmatch.G` {: #wcmatchglobstar}
 
 When the [`PATHNAME`](#wcmatchpathname) flag is provided, you can also enable `GLOBSTAR` to enable the recursive directory pattern matches with `**`.
diff --git a/wcmatch/_wcparse.py b/wcmatch/_wcparse.py
index c0f7628..f494451 100644
--- a/wcmatch/_wcparse.py
+++ b/wcmatch/_wcparse.py
@@ -40,6 +40,8 @@ RE_WIN_MOUNT = re.compile(r'\\|[a-z]:(?:\\|$)', re.I)
 RE_MOUNT = re.compile(r'/')
 RE_BWIN_MOUNT = re.compile(br'\\|[a-z]:(?:\\|$)', re.I)
 RE_BMOUNT = re.compile(br'/')
+RE_ANCHOR = re.compile(r'^/+')
+RE_WIN_ANCHOR = re.compile(r'^(?:\\\\|/)+')
 
 SET_OPERATORS = frozenset(('&', '~', '|'))
 NEGATIVE_SYM = frozenset((b'!', '!'))
@@ -62,10 +64,12 @@ BRACE = 0x0200
 REALPATH = 0x0400
 FOLLOW = 0x0800
 SPLIT = 0x1000
+MATCHBASE = 0x2000
 
 # Internal flag
-_FORCEWIN = 0x100000000
-_TRANSLATE = 0x200000000
+_FORCEWIN = 0x100000000  # Forces Windows behavior (used to not assume Unix/Linux because of `FORCECASE` on Windows).
+_TRANSLATE = 0x200000000  # Lets us know we are performing a translation, and we just want the regex.
+_ANCHOR = 0x400000000  # The pattern, if it starts with a slash, is anchored to the working directory; strip the slash.
 
 FLAG_MASK = (
     FORCECASE |
@@ -80,8 +84,10 @@ FLAG_MASK = (
     BRACE |
     REALPATH |
     FOLLOW |
+    MATCHBASE |
     _FORCEWIN |
-    _TRANSLATE
+    _TRANSLATE |
+    _ANCHOR
 )
 CASE_FLAGS = FORCECASE | IGNORECASE
 
@@ -291,6 +297,7 @@ class WcPathSplit(object):
         self.flags = flags
         self.pattern = util.norm_pattern(pattern, not self.unix, flags & RAWCHARS)
         self.globstar = bool(flags & GLOBSTAR)
+        self.matchbase = bool(flags & MATCHBASE)
         if is_negative(self.pattern, flags):  # pragma: no cover
             # This isn't really used, but we'll keep it around
             # in case we find a reason to directly send inverse patterns
@@ -484,6 +491,10 @@ class WcPathSplit(object):
         if len(pattern) == 0:
             parts.append(WcGlob(pattern.encode('latin-1') if self.is_bytes else pattern, False, False, False, False))
 
+        if self.matchbase and len(parts) == 1 and not parts[0].dir_only:
+            self.globstar = True
+            parts.insert(0, WcGlob(b'**' if self.is_bytes else '**', True, True, True, False))
+
         return parts
 
 
@@ -636,6 +647,8 @@ class WcParse(object):
         self.globstar_capture = self.realpath and not bool(flags & _TRANSLATE)
         self.dot = bool(flags & DOTMATCH)
         self.extend = bool(flags & EXTMATCH)
+        self.matchbase = bool(flags & MATCHBASE)
+        self.anchor = bool(flags & _ANCHOR)
         self.case_sensitive = get_case(flags)
         self.in_list = False
         self.flags = flags
@@ -1175,6 +1188,9 @@ class WcParse(object):
         elif not self.win_drive_detect and self.pathname and pattern.startswith('/'):
             root_specified = True
 
+        if root_specified:
+            self.matchbase = False
+
         if not root_specified and self.realpath:
             current.append(_NO_WIN_ROOT if self.win_drive_detect else _NO_ROOT)
             current.append('')
@@ -1195,6 +1211,7 @@ class WcParse(object):
                     self.clean_up_inverse(current)
                     current.append(self.get_path_sep() + _ONE_OR_MORE)
                     self.consume_path_sep(i)
+                    self.matchbase = False
                 else:
                     current.append(re.escape(c))
             elif c == '\\':
@@ -1204,10 +1221,12 @@ class WcParse(object):
                     if self.dir_start:
                         self.clean_up_inverse(current)
                         self.consume_path_sep(i)
+                        self.matchbase = False
                     current.append(value)
                 except StopIteration:
                     i.rewind(i.index - index)
                     current.append(re.escape(c))
+                    self.matchbase = False
             elif c == '[':
                 index = i.index
                 try:
@@ -1228,6 +1247,7 @@ class WcParse(object):
         """Parse pattern list."""
 
         result = ['']
+        matchbase = ['']
         negative = False
 
         p = util.norm_pattern(self.pattern, not self.unix, self.raw_chars)
@@ -1237,8 +1257,22 @@ class WcParse(object):
             negative = True
             p = p[1:]
 
+        if self.anchor:
+            p, number = (RE_ANCHOR if not self.win_drive_detect else RE_WIN_ANCHOR).subn('', p)
+            if number:
+                self.matchbase = False
+
+        if self.matchbase:
+            globstar = self.globstar
+            self.globstar = True
+            self.root('**', matchbase)
+            self.globstar = globstar
+
         self.root(p, result)
 
+        if self.matchbase:
+            result = matchbase + result
+
         case_flag = 'i' if not self.case_sensitive else ''
         if util.PY36:
             pattern = (
diff --git a/wcmatch/glob.py b/wcmatch/glob.py
index 4c41998..46c9c32 100644
--- a/wcmatch/glob.py
+++ b/wcmatch/glob.py
@@ -29,8 +29,8 @@ from . import util
 __all__ = (
     "FORCECASE", "IGNORECASE", "RAWCHARS", "DOTGLOB", "DOTMATCH",
     "EXTGLOB", "EXTMATCH", "GLOBSTAR", "NEGATE", "MINUSNEGATE", "BRACE",
-    "REALPATH", "FOLLOW",
-    "F", "I", "R", "D", "E", "G", "N", "M", "P", "L",
+    "REALPATH", "FOLLOW", "MATCHBASE",
+    "F", "I", "R", "D", "E", "G", "N", "M", "P", "L", "X", 'K',
     "iglob", "glob", "globsplit", "globmatch", "globfilter", "escape"
 )
 
@@ -51,8 +51,8 @@ B = BRACE = _wcparse.BRACE
 P = REALPATH = _wcparse.REALPATH
 L = FOLLOW = _wcparse.FOLLOW
 S = SPLIT = _wcparse.SPLIT
-MK = MARK = 0x10000
-
+X = MATCHBASE = _wcparse.MATCHBASE
+K = MARK = 0x10000
 
 FLAG_MASK = (
     FORCECASE |
@@ -66,7 +66,8 @@ FLAG_MASK = (
     BRACE |
     REALPATH |
     FOLLOW |
-    SPLIT
+    SPLIT |
+    MATCHBASE
 )
 
 
@@ -97,6 +98,7 @@ class Glob(object):
         self.negate = bool(flags & NEGATE)
         self.globstar = bool(flags & _wcparse.GLOBSTAR)
         self.braces = bool(flags & _wcparse.BRACE)
+        self.matchbase = bool(flags & _wcparse.MATCHBASE)
         self.case_sensitive = _wcparse.get_case(flags)
         self.is_bytes = isinstance(pattern[0], bytes)
         self.specials = (b'.', b'..') if self.is_bytes else ('.', '..')
diff --git a/wcmatch/wcmatch.py b/wcmatch/wcmatch.py
index b76f559..60b5f73 100644
--- a/wcmatch/wcmatch.py
+++ b/wcmatch/wcmatch.py
@@ -39,6 +39,7 @@ E = EXTMATCH = _wcparse.EXTMATCH
 G = GLOBSTAR = _wcparse.GLOBSTAR
 B = BRACE = _wcparse.BRACE
 M = MINUSNEGATE = _wcparse.MINUSNEGATE
+X = MATCHBASE = _wcparse.MATCHBASE
 
 # Control `PATHNAME` individually for folder exclude and files
 DP = DIRPATHNAME = 0x10000
@@ -62,7 +63,8 @@ FLAG_MASK = (
     FILEPATHNAME |
     SYMLINKS |
     HIDDEN |
-    RECURSIVE
+    RECURSIVE |
+    MATCHBASE
 )
 
 
@@ -92,19 +94,24 @@ class WcMatch(object):
                 (re.compile(br'^.*$', re.DOTALL),) if self.is_bytes else (re.compile(r'^.*$', re.DOTALL),)
             )
         self.exclude_pattern = args.pop(0) if args else kwargs.pop('exclude_pattern', b'' if self.is_bytes else '')
-        self.flags = (args.pop(0) if args else kwargs.pop('flags', 0)) & FLAG_MASK
+        self._parse_flags(args.pop(0) if args else kwargs.pop('flags', 0))
+        self.on_init(*args, **kwargs)
+        self.file_check, self.folder_exclude_check = self._compile(self.file_pattern, self.exclude_pattern)
+
+    def _parse_flags(self, flags):
+        """Parse flags."""
+
+        self.flags = flags & FLAG_MASK
         self.flags |= _wcparse.NEGATE | _wcparse.DOTMATCH
         self.follow_links = bool(self.flags & SYMLINKS)
         self.show_hidden = bool(self.flags & HIDDEN)
         self.recursive = bool(self.flags & RECURSIVE)
         self.dir_pathname = bool(self.flags & DIRPATHNAME)
         self.file_pathname = bool(self.flags & FILEPATHNAME)
+        self.matchbase = bool(self.flags & MATCHBASE)
         if util.platform() == "windows":
             self.flags |= _wcparse._FORCEWIN
-        self.flags = self.flags & (_wcparse.FLAG_MASK ^ (SYMLINKS | DIRPATHNAME | FILEPATHNAME | HIDDEN))
-
-        self.on_init(*args, **kwargs)
-        self.file_check, self.folder_exclude_check = self._compile(self.file_pattern, self.exclude_pattern)
+        self.flags = self.flags & (_wcparse.FLAG_MASK ^ MATCHBASE)
 
     def _compile_wildcard(self, pattern, pathname=False):
         """Compile or format the wildcard inclusion/exclusion pattern."""
@@ -112,7 +119,9 @@ class WcMatch(object):
         patterns = None
         flags = self.flags
         if pathname:
-            flags |= _wcparse.PATHNAME
+            flags |= _wcparse.PATHNAME | _wcparse._ANCHOR
+            if self.matchbase:
+                flags |= _wcparse.MATCHBASE
         if pattern:
             patterns = _wcparse.WcSplit(pattern, flags=flags).split()
 
@@ -236,7 +245,7 @@ class WcMatch(object):
                     if value is not None:  # pragma: no cover
                         yield value
 
-                if self._abort:
+                if self._abort:  # pragma: no cover
                     break
 
             # Search files if they were found


Test patch:
diff --git a/tests/test_glob.py b/tests/test_glob.py
index 22d12da..4325de8 100644
--- a/tests/test_glob.py
+++ b/tests/test_glob.py
@@ -426,6 +426,30 @@ class Testglob(_TestGlob):
             glob.N | glob.L
         ],
 
+        Options(cwd_temp=True, absolute=True),
+        # Test base matching
+        [
+            ('*',),
+            [
+                ('EF',), ('ZZZ',), ('a',), ('a', 'D'), ('a', 'bcd'), ('a', 'bcd', 'EF'),
+                ('a', 'bcd', 'efg'), ('a', 'bcd', 'efg', 'ha'), ('aaa',), ('aaa', 'zzzF'), ('aab',),
+                ('aab', 'F')
+            ] if not can_symlink() else[
+                ('EF',), ('ZZZ',), ('a',), ('a', 'D'), ('a', 'bcd'), ('a', 'bcd', 'EF'),
+                ('a', 'bcd', 'efg'), ('a', 'bcd', 'efg', 'ha'), ('aaa',), ('aaa', 'zzzF'), ('aab',),
+                ('aab', 'F'), ('sym1',), ('sym2',)
+            ],
+            glob.L | glob.X
+        ],
+
+        # Test that base matching does not occur with a path pattern
+        [
+            ('aab', '*'),
+            [('aab', 'F')],
+            glob.L | glob.X
+        ],
+        Options(cwd_temp=False, absolute=False),
+
         # Test nested glob directory
         [
             ('a', 'bcd', 'E*'),
diff --git a/tests/test_globmatch.py b/tests/test_globmatch.py
index ea0ba5a..acef01e 100644
--- a/tests/test_globmatch.py
+++ b/tests/test_globmatch.py
@@ -279,15 +279,14 @@ class TestGlobFilter:
             ['ac', 'ad', 'cb', 'c,d']
         ],
 
-        # NOTE: We don't currently support the base match option
-        # ```
-        # [
-        #   'a?b',
-        #   ['x/y/acb', 'acb/'],
-        #   {matchBase: True},
-        #   ['x/y/acb', 'acb/', 'acb/d/e', 'x/y/acb/d']
-        # ],
-        # ```
+        # Test `MATCHBASE`.
+        [
+            'a?b',
+            ['x/y/acb', 'acb/'],
+            glob.X,
+            ['x/y/acb', 'acb/', 'acb/d/e', 'x/y/acb/d']
+        ],
+
         ['#*', ['#a', '#b'], 0, ['#a', '#b', 'c#d']],
 
         # begin channelling Boole and deMorgan...
diff --git a/tests/test_wcmatch.py b/tests/test_wcmatch.py
index 40e21b6..851ce7a 100644
--- a/tests/test_wcmatch.py
+++ b/tests/test_wcmatch.py
@@ -412,6 +412,54 @@ class TestWcmatch(_TestWcmatch):
         self.crawl_files(walker)
         self.assertEqual(len(self.error_records), 2)
 
+    def test_match_base_filepath(self):
+        """Test `MATCHBASE` with filepath."""
+
+        walker = wcmatch.WcMatch(
+            self.tempdir,
+            '*.txt', None,
+            self.default_flags | wcmatch.RECURSIVE | wcmatch.HIDDEN | wcmatch.FILEPATHNAME | wcmatch.MATCHBASE
+        )
+        self.crawl_files(walker)
+        self.assertEqual(
+            sorted(self.files),
+            self.norm_list(
+                ['a.txt', '.hidden/a.txt']
+            )
+        )
+
+    def test_match_base_absolute_filepath(self):
+        """Test `MATCHBASE` with filepath and an absolute path."""
+
+        walker = wcmatch.WcMatch(
+            self.tempdir,
+            '.hidden/*.txt', None,
+            self.default_flags | wcmatch.RECURSIVE | wcmatch.HIDDEN | wcmatch.FILEPATHNAME | wcmatch.MATCHBASE
+        )
+        self.crawl_files(walker)
+        self.assertEqual(
+            sorted(self.files),
+            self.norm_list(
+                ['.hidden/a.txt']
+            )
+        )
+
+    def test_match_base_anchored_filepath(self):
+        """Test `MATCHBASE` with filepath and an anchored pattern."""
+
+        walker = wcmatch.WcMatch(
+            self.tempdir,
+            '/*.txt', None,
+            self.default_flags | wcmatch.RECURSIVE | wcmatch.HIDDEN | wcmatch.FILEPATHNAME | wcmatch.MATCHBASE
+        )
+        self.crawl_files(walker)
+        self.assertEqual(
+            sorted(self.files),
+            self.norm_list(
+                ['a.txt']
+            )
+        )
+
 
 @skip_unless_symlink
 class TestWcmatchSymlink(_TestWcmatch):