"""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