phi-gate 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phi_gate/globs.py ADDED
@@ -0,0 +1,227 @@
1
+ """Which files a Grep's `glob` lets it search.
2
+
3
+ The Grep pre-check scans those files before the search runs. The glob only
4
+ narrows that set, so where its meaning is uncertain the set is made larger,
5
+ never smaller: a file left out here is a file searched unchecked.
6
+
7
+ Read as ripgrep reads a glob:
8
+
9
+ * commas between globs (`*.py,*.csv`), but not inside braces;
10
+ * brace alternatives, nested too (`*.{csv,txt}`, `*.{json,{csv,tsv}}`);
11
+ * `*` and `?` within a path segment, `[...]` classes, and `**` across segments.
12
+
13
+ Matched generously: each alternative is tried against every run of
14
+ consecutive segments of a file's path (`visits.csv`, `nov/visits.csv`,
15
+ `2026/nov`, ...), so a glob relative to the search root or to the working
16
+ directory, or one naming a directory, selects the file either way. Matching
17
+ ignores case and Unicode normalisation (macOS stores names decomposed), and
18
+ its cost is bounded by the glob's and the path's length: no pattern can make
19
+ it backtrack.
20
+
21
+ A glob that cannot be followed (a negation `!`, a backslash escape,
22
+ unbalanced or empty braces, more than `MAX_ALTERNATIVES` alternatives, an
23
+ overlong pattern) gives `None`: every file is searched, so every file is
24
+ scanned.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import fnmatch
30
+ import re
31
+ import unicodedata
32
+ from pathlib import Path
33
+
34
+ MAX_ALTERNATIVES = 256
35
+ MAX_PATTERN = 4096
36
+ MAX_DOUBLE_STARS = 8
37
+
38
+
39
+ class _Unsupported(Exception):
40
+ pass
41
+
42
+
43
+ def _split_top(text: str) -> list[str]:
44
+ """Split on commas outside braces."""
45
+ parts, depth, start = [], 0, 0
46
+ for i, ch in enumerate(text):
47
+ if ch == "{":
48
+ depth += 1
49
+ elif ch == "}":
50
+ depth -= 1
51
+ if depth < 0:
52
+ raise _Unsupported("unbalanced braces")
53
+ elif ch == "," and depth == 0:
54
+ parts.append(text[start:i])
55
+ start = i + 1
56
+ if depth != 0:
57
+ raise _Unsupported("unbalanced braces")
58
+ parts.append(text[start:])
59
+ return parts
60
+
61
+
62
+ def _expand(glob: str) -> list[str]:
63
+ """Every alternative a brace glob stands for."""
64
+ open_at = glob.find("{")
65
+ if open_at < 0:
66
+ if "}" in glob:
67
+ raise _Unsupported("unbalanced braces")
68
+ return [glob]
69
+ depth = 0
70
+ close_at = -1
71
+ for i in range(open_at, len(glob)):
72
+ if glob[i] == "{":
73
+ depth += 1
74
+ elif glob[i] == "}":
75
+ depth -= 1
76
+ if depth == 0:
77
+ close_at = i
78
+ break
79
+ if close_at < 0:
80
+ raise _Unsupported("unbalanced braces")
81
+ head, body, tail = glob[:open_at], glob[open_at + 1:close_at], glob[close_at + 1:]
82
+ if not body:
83
+ raise _Unsupported("empty braces")
84
+ out: list[str] = []
85
+ for choice in _split_top(body):
86
+ for rest in _expand(head + choice + tail):
87
+ out.append(rest)
88
+ if len(out) > MAX_ALTERNATIVES:
89
+ raise _Unsupported("too many alternatives")
90
+ return out
91
+
92
+
93
+ def _normal(text: str) -> str:
94
+ return unicodedata.normalize("NFC", text)
95
+
96
+
97
+ def _alternatives(pattern: str) -> list[str]:
98
+ if len(pattern) > MAX_PATTERN or "\\" in pattern:
99
+ raise _Unsupported("escape or overlong pattern")
100
+ globs = [g.strip() for g in _split_top(pattern)]
101
+ if any(g.startswith("!") for g in globs):
102
+ raise _Unsupported("negation")
103
+ alternatives = []
104
+ for glob in globs:
105
+ for alt in _expand(glob):
106
+ alt = alt.strip().removeprefix("./").strip("/")
107
+ if alt:
108
+ alternatives.append(alt)
109
+ if not alternatives or len(alternatives) > MAX_ALTERNATIVES:
110
+ raise _Unsupported("no usable alternatives")
111
+ if any(alt.count("**") > MAX_DOUBLE_STARS for alt in alternatives):
112
+ raise _Unsupported("too many **")
113
+ if not all(_classes_closed(alt) for alt in alternatives):
114
+ raise _Unsupported("unclosed class")
115
+ return alternatives
116
+
117
+
118
+ def _classes_closed(glob: str) -> bool:
119
+ """Whether every `[` opens a class that closes. A `]` straight after the
120
+ opening (or after its `!` or `^`) is a member, not the end."""
121
+ i = 0
122
+ while i < len(glob):
123
+ if glob[i] == "[":
124
+ j = i + 1
125
+ if glob[j:j + 1] in ("!", "^"):
126
+ j += 1
127
+ if glob[j:j + 1] == "]":
128
+ j += 1
129
+ close = glob.find("]", j)
130
+ if close < 0:
131
+ return False
132
+ i = close + 1
133
+ else:
134
+ i += 1
135
+ return True
136
+
137
+
138
+ Compiled = list[tuple[str, ...]]
139
+
140
+
141
+ def compile_glob(pattern: str) -> Compiled | None:
142
+ """Each alternative of a Grep glob as path segments, lower-cased, with
143
+ `**` standing for any number of segments; None when every file must be
144
+ scanned."""
145
+ try:
146
+ alternatives = _alternatives(_normal(pattern))
147
+ except _Unsupported:
148
+ return None
149
+ compiled = []
150
+ for alt in alternatives:
151
+ segments: list[str] = []
152
+ for seg in alt.lower().split("/"):
153
+ if not seg:
154
+ continue
155
+ if seg == "**":
156
+ if segments[-1:] != ["**"]:
157
+ segments.append("**")
158
+ else:
159
+ segments.append(re.sub(r"\*{2,}", "*", seg).replace("[^", "[!"))
160
+ compiled.append(tuple(segments))
161
+ return compiled
162
+
163
+
164
+ def _segment_match(name: str, pattern: str, cache: dict[tuple[str, str], bool]) -> bool:
165
+ """One path segment against one glob segment. fnmatch translates to a
166
+ regular expression with atomic groups, so no pattern backtracks badly."""
167
+ key = (name, pattern)
168
+ if key not in cache:
169
+ cache[key] = fnmatch.fnmatchcase(name, pattern)
170
+ return cache[key]
171
+
172
+
173
+ def _matches(segments: tuple[str, ...], parts: list[str], cache: dict[tuple[str, str], bool]) -> bool:
174
+ """Whether the glob matches some run of consecutive path segments: a
175
+ dynamic programme over (glob segment, path segment), so its cost is their
176
+ product however many `**` the glob holds."""
177
+ full = ("**", *segments, "**")
178
+ n, m = len(full), len(parts)
179
+ # reach[j]: the glob's first i segments can match parts[:j].
180
+ reach = [True] + [False] * m
181
+ for i in range(n):
182
+ seg = full[i]
183
+ nxt = [False] * (m + 1)
184
+ if seg == "**":
185
+ seen = False
186
+ for j in range(m + 1):
187
+ seen = seen or reach[j]
188
+ nxt[j] = seen
189
+ else:
190
+ for j in range(m):
191
+ if reach[j] and _segment_match(parts[j], seg, cache):
192
+ nxt[j + 1] = True
193
+ reach = nxt
194
+ return reach[m]
195
+
196
+
197
+ def named_segments(pattern: str | None) -> set[str]:
198
+ """Path segments a glob names literally, such as `build` in
199
+ `build/**/*.csv`. A directory skipped by default is still walked when a
200
+ glob names it."""
201
+ if not pattern:
202
+ return set()
203
+ try:
204
+ alternatives = _alternatives(_normal(pattern))
205
+ except _Unsupported:
206
+ return set()
207
+ return {seg for alt in alternatives for seg in alt.split("/") if seg and not set(seg) & set("*?[]")}
208
+
209
+
210
+ def select(files: list[Path], pattern: str | None) -> list[Path]:
211
+ """The files a Grep with `pattern` would search, or more. Every file when
212
+ there is no glob, or one this cannot follow."""
213
+ if not pattern:
214
+ return files
215
+ compiled = compile_glob(pattern)
216
+ if compiled is None:
217
+ return files
218
+ cache: dict[tuple[str, str], bool] = {}
219
+ chosen = []
220
+ for f in files:
221
+ parts = [_normal(p).lower() for p in f.parts if p not in ("/", "")]
222
+ if any(_matches(segments, parts, cache) for segments in compiled):
223
+ chosen.append(f)
224
+ return chosen
225
+
226
+
227
+ __all__ = ["compile_glob", "named_segments", "select", "MAX_ALTERNATIVES"]