diff --git a/file_length/repo_exempt.py b/file_length/repo_exempt.py index d6195a5..948762c 100644 --- a/file_length/repo_exempt.py +++ b/file_length/repo_exempt.py @@ -17,9 +17,10 @@ fails closed on it (exit 2) instead of honouring it silently. from __future__ import annotations +import re from dataclasses import dataclass from functools import cache -from pathlib import Path, PurePosixPath +from pathlib import Path #: Basename of the per-repo exemption list, looked up in the gate's cwd. EXEMPT_FILE = ".file-length-exempt" @@ -53,6 +54,35 @@ def parse(text: str) -> list[Exemption]: return entries +def glob_to_regex(pattern: str) -> re.Pattern[str]: + """A repo-relative glob as an anchored regex. + + `**` spans directories, `*` and `?` stop at a slash. Hand-rolled rather + than `PurePath.full_match` because that needs Python 3.13 and the gate + runs on whatever `python3` a CI runner ships (3.12 broke it in the + TachiyomiSY fork's first CI run). + """ + out: list[str] = [] + i = 0 + while i < len(pattern): + char = pattern[i] + if pattern.startswith("**", i): + out.append(".*") + i += 2 + if pattern.startswith("/", i): + out[-1] = "(?:.*/)?" + i += 1 + continue + if char == "*": + out.append("[^/]*") + elif char == "?": + out.append("[^/]") + else: + out.append(re.escape(char)) + i += 1 + return re.compile("^" + "".join(out) + "$") + + @cache def load(root: Path) -> tuple[Exemption, ...]: """The exemptions for the repo at `root`, empty when it has no file.""" @@ -72,10 +102,10 @@ def repo_exempt_reason(path: Path, root: Path) -> str | None: if not entries: return None try: - relative = PurePosixPath(path.relative_to(root).as_posix()) + relative = path.relative_to(root).as_posix() except ValueError: return None for entry in entries: - if relative.full_match(entry.pattern): + if glob_to_regex(entry.pattern).match(relative): return f"repo exemption: {entry.reason}" return None diff --git a/file_length/tests/test_repo_exempt.py b/file_length/tests/test_repo_exempt.py index f36a229..8223f1d 100644 --- a/file_length/tests/test_repo_exempt.py +++ b/file_length/tests/test_repo_exempt.py @@ -10,6 +10,7 @@ from file_length.repo_exempt import ( EXEMPT_FILE, ExemptFileError, Exemption, + glob_to_regex, load, parse, repo_exempt_reason, @@ -64,3 +65,21 @@ def test_reason_none_for_paths_outside_root(tmp_path: Path) -> None: write(tmp_path, EXEMPT_FILE, "**/*.kt # everything\n") outside = tmp_path.parent / "elsewhere.kt" assert repo_exempt_reason(outside, tmp_path) is None + + +@pytest.mark.parametrize( + ("pattern", "hit", "miss"), + [ + ("a/*.kt", "a/x.kt", "a/b/x.kt"), + ("a/**/*.kt", "a/b/c/x.kt", "b/x.kt"), + ("a/**/*.kt", "a/x.kt", "a/x.kts"), + ("**/tags/*.kt", "tags/x.kt", "tags/sub/x.kt"), + ("a/?.kt", "a/x.kt", "a/xy.kt"), + ("a/**", "a/b/c.kt", "b/a"), + ("a.b", "a.b", "axb"), + ], +) +def test_glob_to_regex(pattern: str, hit: str, miss: str) -> None: + regex = glob_to_regex(pattern) + assert regex.match(hit), (pattern, hit) + assert not regex.match(miss), (pattern, miss)