90 lines
2.8 KiB
Python
90 lines
2.8 KiB
Python
"""Path globs, with the one extension the guides need: `**/` matches nothing.
|
|
|
|
`fnmatch` is close but wrong for this job in two ways: it lets `*` cross a `/`,
|
|
and it has no `**`. Platform guides are read by a human and written as ordinary
|
|
gitignore-style globs (`**/node_modules/**`, `200 projects/**`), so this module
|
|
implements exactly that little language and nothing else.
|
|
|
|
Matching is always against a normalised, repository-relative POSIX path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
_CACHE: dict = {}
|
|
|
|
|
|
def normalise(path: str) -> str:
|
|
"""Repository-relative, POSIX separators, no leading `./` or `/`."""
|
|
path = str(path).replace("\\", "/").strip()
|
|
while path.startswith("./"):
|
|
path = path[2:]
|
|
return path.lstrip("/")
|
|
|
|
|
|
def compile_glob(pattern: str):
|
|
"""Compile one glob to a regular expression. Cached."""
|
|
cached = _CACHE.get(pattern)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
pattern = normalise(pattern)
|
|
out = ["^"]
|
|
index = 0
|
|
while index < len(pattern):
|
|
char = pattern[index]
|
|
if char == "*":
|
|
if pattern[index : index + 2] == "**":
|
|
index += 2
|
|
if pattern[index : index + 1] == "/":
|
|
# `**/` — any number of whole directories, including none.
|
|
out.append("(?:[^/]+/)*")
|
|
index += 1
|
|
else:
|
|
out.append(".*")
|
|
continue
|
|
out.append("[^/]*")
|
|
elif char == "?":
|
|
out.append("[^/]")
|
|
elif char == "[":
|
|
close = pattern.find("]", index + 1)
|
|
if close == -1:
|
|
out.append(re.escape(char))
|
|
else:
|
|
body = pattern[index + 1 : close]
|
|
if body.startswith("!"):
|
|
body = "^" + body[1:]
|
|
out.append("[" + body + "]")
|
|
index = close
|
|
else:
|
|
out.append(re.escape(char))
|
|
index += 1
|
|
out.append("$")
|
|
compiled = re.compile("".join(out))
|
|
_CACHE[pattern] = compiled
|
|
return compiled
|
|
|
|
|
|
def matches(path: str, pattern: str) -> bool:
|
|
return bool(compile_glob(pattern).match(normalise(path)))
|
|
|
|
|
|
def matches_any(path: str, patterns) -> bool:
|
|
return any(matches(path, pattern) for pattern in (patterns or ()))
|
|
|
|
|
|
def under_any(path: str, patterns) -> bool:
|
|
"""True when `path` is a pattern's directory itself or inside it.
|
|
|
|
`200 projects/**` should also exclude the bare directory entry `200 projects`
|
|
that a tree listing returns, not only the files underneath it.
|
|
"""
|
|
path = normalise(path)
|
|
for pattern in patterns or ():
|
|
if matches(path, pattern):
|
|
return True
|
|
if pattern.endswith("/**") and path == normalise(pattern[:-3]):
|
|
return True
|
|
return False
|