filefilter 0.2.1__tar.gz → 0.2.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: filefilter
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Filter files in a directory tree based on configurable glob rules.
5
5
  Author-email: "Ioannis D (devcoons)" <support@devcoons.com>
6
6
  License-Expression: MIT
@@ -229,9 +229,12 @@ When scanning files, the library applies filters in this exact sequence:
229
229
  |:-------|:--------|
230
230
  | `*` | one or more characters (no slashes) |
231
231
  | `**` | zero or more characters (can cross dirs) |
232
+ | `*ABC` / `ABC*` | within a **directory name**, `*` = 1+ chars (e.g. `Myhello` matches `*hello`, not bare `hello`) |
233
+ | `**ABC` / `ABC**` | within a **directory name**, `**` = 0+ chars (e.g. `hello` and `Myhello` both match `**hello`) |
232
234
  | Leading `**/` | match anywhere in the tree |
233
235
  | Leading `*/` | match exactly N levels below root |
234
236
  | Trailing `/**` | match directory and all descendants |
237
+ | `***` (3+ asterisks) | literal directory name, not a wildcard |
235
238
  | `.ext` or `ext` | file extension match (case-insensitive) |
236
239
  | `.*` in extensions | any non-empty extension |
237
240
 
@@ -196,9 +196,12 @@ When scanning files, the library applies filters in this exact sequence:
196
196
  |:-------|:--------|
197
197
  | `*` | one or more characters (no slashes) |
198
198
  | `**` | zero or more characters (can cross dirs) |
199
+ | `*ABC` / `ABC*` | within a **directory name**, `*` = 1+ chars (e.g. `Myhello` matches `*hello`, not bare `hello`) |
200
+ | `**ABC` / `ABC**` | within a **directory name**, `**` = 0+ chars (e.g. `hello` and `Myhello` both match `**hello`) |
199
201
  | Leading `**/` | match anywhere in the tree |
200
202
  | Leading `*/` | match exactly N levels below root |
201
203
  | Trailing `/**` | match directory and all descendants |
204
+ | `***` (3+ asterisks) | literal directory name, not a wildcard |
202
205
  | `.ext` or `ext` | file extension match (case-insensitive) |
203
206
  | `.*` in extensions | any non-empty extension |
204
207
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "filefilter"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Filter files in a directory tree based on configurable glob rules."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Ioannis D (devcoons)", email = "support@devcoons.com" }]
@@ -24,7 +24,7 @@
24
24
  # #
25
25
  #########################################################################################
26
26
 
27
- __version__ = '0.2.1'
27
+ __version__ = '0.2.2'
28
28
 
29
29
  #########################################################################################
30
30
  # IMPORTS #
@@ -127,7 +127,7 @@ def match_file(filepath: str, include_files: list[str]) -> bool:
127
127
  start_positions = range(0, depth + 1)
128
128
  for s in start_positions:
129
129
  ends = match_doublestar_segments(dir_parts, s, pat_dirs)
130
- if ends:
130
+ if any(e == depth for e in ends):
131
131
  return True
132
132
  elif lead_stars == 0:
133
133
  s = 0
@@ -98,6 +98,21 @@ def count_leading_stars(patt: str) -> tuple[int, str]:
98
98
 
99
99
  #########################################################################################
100
100
 
101
+ def segment_matches(token: str, part: str) -> bool:
102
+ """Match one directory name; * is 1+ chars, ** is 0+ chars within the segment."""
103
+ token = normalize_path(token)
104
+ part = normalize_path(part)
105
+ if len(token) >= 3 and set(token) == {'*'}:
106
+ return part == token
107
+ if '*' not in token:
108
+ return part == token
109
+ rx = re.escape(token)
110
+ rx = rx.replace(r'\*\*', '.*')
111
+ rx = rx.replace(r'\*', '.+')
112
+ return re.match(r'^' + rx + r'$', part) is not None
113
+
114
+ #########################################################################################
115
+
101
116
  def match_doublestar_segments(path_parts: list[str], start: int, patt_parts: list[str]) -> list[int]:
102
117
  """Return a list of end indices j such that path_parts[start:j] matches patt_parts where '**' means zero+ segments."""
103
118
  results = []
@@ -113,7 +128,7 @@ def match_doublestar_segments(path_parts: list[str], start: int, patt_parts: lis
113
128
  if j < len(path_parts):
114
129
  rec(i + 1, j + 1)
115
130
  else:
116
- if j < len(path_parts) and path_parts[j] == token:
131
+ if j < len(path_parts) and segment_matches(token, path_parts[j]):
117
132
  rec(i + 1, j + 1)
118
133
  rec(0, start)
119
134
  return results
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: filefilter
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Filter files in a directory tree based on configurable glob rules.
5
5
  Author-email: "Ioannis D (devcoons)" <support@devcoons.com>
6
6
  License-Expression: MIT
@@ -229,9 +229,12 @@ When scanning files, the library applies filters in this exact sequence:
229
229
  |:-------|:--------|
230
230
  | `*` | one or more characters (no slashes) |
231
231
  | `**` | zero or more characters (can cross dirs) |
232
+ | `*ABC` / `ABC*` | within a **directory name**, `*` = 1+ chars (e.g. `Myhello` matches `*hello`, not bare `hello`) |
233
+ | `**ABC` / `ABC**` | within a **directory name**, `**` = 0+ chars (e.g. `hello` and `Myhello` both match `**hello`) |
232
234
  | Leading `**/` | match anywhere in the tree |
233
235
  | Leading `*/` | match exactly N levels below root |
234
236
  | Trailing `/**` | match directory and all descendants |
237
+ | `***` (3+ asterisks) | literal directory name, not a wildcard |
235
238
  | `.ext` or `ext` | file extension match (case-insensitive) |
236
239
  | `.*` in extensions | any non-empty extension |
237
240
 
@@ -10,6 +10,7 @@ src/filefilter.egg-info/SOURCES.txt
10
10
  src/filefilter.egg-info/dependency_links.txt
11
11
  src/filefilter.egg-info/requires.txt
12
12
  src/filefilter.egg-info/top_level.txt
13
+ tests/test_comprehensive.py
13
14
  tests/test_inclusion_logic.py
14
15
  tests/test_matcher.py
15
16
  tests/test_readme_verification.py
@@ -0,0 +1,560 @@
1
+ """
2
+ Exhaustive behavioural tests beyond line coverage — README tables, segment
3
+ globs, specificity, regressions, and end-to-end selection outcomes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from conftest import make_config, select_paths, touch
13
+ from filefilter import match_dir, match_file, matches
14
+ from filefilter.utilities import segment_matches
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Segment glob matrix
18
+ # ---------------------------------------------------------------------------
19
+
20
+ SEGMENT_GLOB_CASES = [
21
+ # suffix glob *hello
22
+ ("*hello", "myhello", True),
23
+ ("*hello", "xhello", True),
24
+ ("*hello", "hello", False),
25
+ ("*hello", "helloworld", False),
26
+ ("*hello", "notmatch", False),
27
+ # suffix glob **hello (0+ prefix)
28
+ ("**hello", "hello", True),
29
+ ("**hello", "myhello", True),
30
+ ("**hello", "xhello", True),
31
+ ("**hello", "helloworld", False),
32
+ # prefix glob hello*
33
+ ("hello*", "helloworld", True),
34
+ ("hello*", "hellox", True),
35
+ ("hello*", "hello", False),
36
+ # prefix glob hello**
37
+ ("hello**", "hello", True),
38
+ ("hello**", "helloworld", True),
39
+ ("hello**", "myhello", False),
40
+ # prefix *abc / suffix abc*
41
+ ("*abc", "xabc", True),
42
+ ("*abc", "myabc", True),
43
+ ("*abc", "abc", False),
44
+ ("abc*", "abcx", True),
45
+ ("abc*", "abc123", True),
46
+ ("abc*", "abc", False),
47
+ # infix *abc*
48
+ ("*abc*", "xabcy", True),
49
+ ("*abc*", "dataabcdata", True),
50
+ ("*abc*", "abc", False),
51
+ ("*abc*", "ab", False),
52
+ # **abc / abc** (0+ on one side)
53
+ ("**abc", "abc", True),
54
+ ("**abc", "xabc", True),
55
+ ("abc**", "abc", True),
56
+ ("abc**", "abcd", True),
57
+ ("**abc**", "abc", True),
58
+ ("**abc**", "xabcy", True),
59
+ ("**abc**", "xab", False),
60
+ # literals
61
+ ("folder", "folder", True),
62
+ ("folder", "Folder", True),
63
+ ("folder", "other", False),
64
+ ("***", "***", True),
65
+ ("***", "**", False),
66
+ ("***", "****", False),
67
+ ("***", "myhello", False),
68
+ # mixed literal + glob
69
+ ("pre*fix", "preabcfix", True),
70
+ ("pre*fix", "prefix", False),
71
+ ("pre**fix", "prefix", True),
72
+ ("pre**fix", "preabcfix", True),
73
+ ]
74
+
75
+
76
+ @pytest.mark.parametrize("token,part,expected", SEGMENT_GLOB_CASES)
77
+ def test_segment_glob_matrix(token: str, part: str, expected: bool):
78
+ assert segment_matches(token, part) is expected
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # README folder-pattern table (match_dir)
83
+ # ---------------------------------------------------------------------------
84
+
85
+ README_DIR_PATTERNS = [
86
+ ("folder", "folder", True),
87
+ ("folder", "folder/sub", False),
88
+ ("folder/*", "folder/a", True),
89
+ ("folder/*", "folder/a/b", False),
90
+ ("folder/*/*", "folder/a/b", True),
91
+ ("folder/*/*", "folder/a", False),
92
+ ("folder/**", "folder/x/y", True),
93
+ ("folder/**/sub", "folder/a/b/sub", True),
94
+ ("folder/**/sub", "folder/sub", True),
95
+ ("*/folder", "src/folder", True),
96
+ ("*/folder", "folder", False),
97
+ ("*/folder/**", "src/folder/x/y", True),
98
+ ("*/*/folder", "apps/web/folder", True),
99
+ ("*/*/folder", "apps/folder", False),
100
+ ("*/*/*/folder", "a/b/c/folder", True),
101
+ ("*/*/*/*/folder/**", "a/b/c/d/folder/x/y", True),
102
+ ("**/folder", "a/b/c/folder", True),
103
+ ("**/folder", "folder", True),
104
+ ("**/folder/**", "x/y/folder/z", True),
105
+ ("**/folder/another", "folder/another", True),
106
+ ("**/folder/another", "x/folder/another", True),
107
+ ("**/pkg/*", "pkg/a", True),
108
+ ("**/pkg/*", "x/y/pkg/z", True),
109
+ ("**/pkg/*", "pkg/a/b", False),
110
+ ("**/pkg/*/*", "pkg/a/b", True),
111
+ ("**/pkg/*/*", "x/y/pkg/z/t", True),
112
+ ("**/pkg/*/*", "pkg/a", False),
113
+ ("modules/*/test", "modules/auth/test", True),
114
+ ("modules/*/test", "modules/auth/pay/test", False),
115
+ ("apps/**/dist", "apps/web/dist", True),
116
+ ("apps/**/dist", "apps/mobile/a/b/dist", True),
117
+ ("apps/**/dist", "other/dist", False),
118
+ ("*/*/folder/*/another_folder", "a/b/folder/c/another_folder", True),
119
+ ("*/*/folder/*/another_folder", "a/b/folder/c/d/another_folder", False),
120
+ ("*/*/folder/**/another_folder", "a/b/folder/another_folder", True),
121
+ ("*/*/folder/**/another_folder", "a/b/folder/x/another_folder", True),
122
+ ("*/*/folder/*/*/another_folder", "a/b/folder/x/y/another_folder", True),
123
+ ("*/*/folder/*/*/another_folder", "a/b/folder/x/another_folder", False),
124
+ ("**/services/**/migrations/*", "services/migrations/2", True),
125
+ ("**/services/**/migrations/*", "x/services/a/migrations/001", True),
126
+ ("**/services/**/migrations/*", "services/m/2", False),
127
+ ("**/features/*/*", "a/features/x/y", True),
128
+ ("**/features/*/*", "features/p/q", True),
129
+ ("**/features/*/*", "features/p/q/r", False),
130
+ ("packages/*/**/dist", "packages/core/dist", True),
131
+ ("packages/*/**/dist", "packages/ui/x/y/dist", True),
132
+ ("packages/*/**/dist", "packages/dist", False),
133
+ ("**/folder/***/anotherfolder", "folder/***/anotherfolder", True),
134
+ ("**/folder/***/anotherfolder", "folder/x/anotherfolder", False),
135
+ # segment globs in dir patterns
136
+ ("**/*hello/**", "abc/myhello/efg", True),
137
+ ("**/*hello/**", "abc/hello/efg", False),
138
+ ("**/**hello/**", "abc/hello/efg", True),
139
+ ("**/**hello/**", "abc/myhello/efg", True),
140
+ ("**/hello*/**", "abc/helloworld/x", True),
141
+ ("**/hello*/**", "abc/hello/x", False),
142
+ ("**/hello**/**", "abc/hello/x", True),
143
+ ("**/*abc/**", "data/myabc/nested", True),
144
+ ("**/abc*/**", "data/abc123/nested", True),
145
+ ]
146
+
147
+
148
+ @pytest.mark.parametrize("pattern,dirpath,expected", README_DIR_PATTERNS)
149
+ def test_readme_folder_pattern_table(pattern: str, dirpath: str, expected: bool):
150
+ assert match_dir(dirpath, [pattern]) is expected
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # README file-pattern table (match_file)
155
+ # ---------------------------------------------------------------------------
156
+
157
+ README_FILE_PATTERNS = [
158
+ ("file.py", "file.py", True),
159
+ ("file.py", "src/file.py", False),
160
+ ("README.*", "README.md", True),
161
+ ("README.*", "README.rst", True),
162
+ ("README.*", "README", False),
163
+ ("README.**", "README", True),
164
+ ("README.**", "README.md", True),
165
+ (".*", ".env", True),
166
+ (".*", ".gitignore", True),
167
+ (".*", "env", False),
168
+ ("folder/*.py", "folder/a.py", True),
169
+ ("folder/*.py", "folder/sub/a.py", False),
170
+ ("folder/**/test_*.py", "folder/test_a.py", True),
171
+ ("folder/**/test_*.py", "folder/x/y/test_utils.py", True),
172
+ ("Makefile", "Makefile", True),
173
+ ("Makefile", "src/Makefile", False),
174
+ ("*.toml", "pyproject.toml", True),
175
+ ("*.toml", "pkg/a/config.toml", False),
176
+ ("**/*.toml", "pyproject.toml", True),
177
+ ("**/*.toml", "pkg/a/b/config.toml", True),
178
+ ("folder/**/*.md", "folder/README.md", True),
179
+ ("folder/**/*.md", "folder/docs/a/b/guide.md", True),
180
+ ("*/Dockerfile", "api/Dockerfile", True),
181
+ ("*/Dockerfile", "Dockerfile", False),
182
+ ("*/LICENSE*", "pkg/LICENSE-MIT", True),
183
+ ("*/LICENSE*", "pkg/LICENSE", False),
184
+ ("*/*/Makefile", "x/y/Makefile", True),
185
+ ("*/*/Makefile", "x/Makefile", False),
186
+ ("*/*/*/package.json", "a/b/c/package.json", True),
187
+ ("**/Makefile", "Makefile", True),
188
+ ("**/Makefile", "a/b/c/Makefile", True),
189
+ ("**/*.py", "a.py", True),
190
+ ("**/*.py", "pkg/mod/x.py", True),
191
+ ("**", "any/depth/file.dat", True),
192
+ ("src/**/test_*.py", "src/test_a.py", True),
193
+ ("src/**/test_*.py", "src/unit/core/test_utils.py", True),
194
+ ("**/migrations/*", "app/migrations/001.sql", True),
195
+ ("**/migrations/*", "app/migrations/v1/001.sql", False),
196
+ ("**/migrations/*/*", "app/migrations/v1/001.sql", True),
197
+ ("**/assets/**/*.png", "assets/logo.png", True),
198
+ ("**/assets/**/*.png", "x/assets/img/icons/a.png", True),
199
+ ("packages/*/**/dist/*.js", "packages/core/dist/index.js", True),
200
+ ("packages/*/**/dist/*.js", "packages/ui/x/y/dist/app.js", True),
201
+ ("**/pkg/*/*/index.*", "pkg/a/b/index.js", True),
202
+ ("**/pkg/*/*/index.*", "x/y/pkg/z/t/index.html", True),
203
+ ("**/file*.py", "fileA.py", True),
204
+ ("**/file*.py", "file.py", False),
205
+ ("**/file**.py", "file.py", True),
206
+ ("**/file**.py", "x/fileA.py", True),
207
+ ("**/*test*.py", "x/y/unittest_tools.py", True),
208
+ ("**/*test*.py", "test_main.py", False),
209
+ ("*/*/folder/*/another.py", "a/b/folder/c/another.py", True),
210
+ ("*/*/folder/*/another.py", "a/b/folder/c/d/another.py", False),
211
+ ("*/*/folder/**/another.py", "a/b/folder/another.py", True),
212
+ ("*/*/folder/**/another.py", "a/b/folder/x/y/another.py", True),
213
+ ("*/*/folder/*/*/another.py", "a/b/folder/x/y/another.py", True),
214
+ ("**/folder/***/file.txt", "folder/***/file.txt", True),
215
+ ("**/folder/***/file.txt", "folder/x/file.txt", False),
216
+ ("folder/*/file.txt", "folder/a/file.txt", True),
217
+ ("folder/*/file.txt", "folder/a/b/file.txt", False),
218
+ ("folder/**/file.txt", "folder/file.txt", True),
219
+ ("folder/**/file.txt", "folder/a/b/file.txt", True),
220
+ # file patterns with segment globs in directory part
221
+ ("**/*hello/**/target.c", "abc/myhello/target.c", True),
222
+ ("**/*hello/**/target.c", "abc/hello/target.c", False),
223
+ ("**/**hello/**/target.c", "abc/hello/target.c", True),
224
+ ("**/**hello/**/target.c", "abc/myhello/target.c", True),
225
+ ("**/pre*fix/**/out.dat", "abc/preXYZfix/out.dat", True),
226
+ ("**/pre*fix/**/out.dat", "abc/prefix/out.dat", False),
227
+ ]
228
+
229
+
230
+ @pytest.mark.parametrize("pattern,filepath,expected", README_FILE_PATTERNS)
231
+ def test_readme_file_pattern_table(pattern: str, filepath: str, expected: bool):
232
+ assert match_file(filepath, [pattern]) is expected
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Regressions (bugs found during development)
237
+ # ---------------------------------------------------------------------------
238
+
239
+ REGRESSION_FILE_CASES = [
240
+ ("file.txt", ["file.txt"], True),
241
+ ("x/file.txt", ["file.txt"], False),
242
+ ("main.py", ["*.py"], True),
243
+ ("src/app.py", ["*.py"], False),
244
+ ("any/deep/file.dat", ["**"], True),
245
+ ("README", ["README.**"], True),
246
+ ("pkg/LICENSE", ["**/LICENSE**"], True),
247
+ # **/migrations/* must match only one level under migrations (not deeper)
248
+ ("app/migrations/001.sql", ["**/migrations/*"], True),
249
+ ("app/migrations/v1/001.sql", ["**/migrations/*"], False),
250
+ ("app/migrations/v1/001.sql", ["**/migrations/*/*"], True),
251
+ ]
252
+
253
+
254
+ @pytest.mark.parametrize("path,patterns,expected", REGRESSION_FILE_CASES)
255
+ def test_regression_file_matching(path: str, patterns: list[str], expected: bool):
256
+ assert match_file(path, patterns) is expected
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Specificity / precedence integration
261
+ # ---------------------------------------------------------------------------
262
+
263
+ SPECIFICITY_SCENARIOS = [
264
+ pytest.param(
265
+ ["**", "**/KLM/ABC/**"],
266
+ ["**/KLM/**"],
267
+ {
268
+ "src/foo.py": True,
269
+ "src/KLM/skip.py": False,
270
+ "src/KLM/ABC/keep.py": True,
271
+ "src/x/KLM/ABC/deep/keep.py": True,
272
+ },
273
+ id="klm-abc-exception",
274
+ ),
275
+ pytest.param(
276
+ ["src/**", "**/ABC/KLM/**"],
277
+ ["**/ABC/**"],
278
+ {
279
+ "src/foo.py": True,
280
+ "src/ABC/only.py": False,
281
+ "src/ABC/other/skip.py": False,
282
+ "src/ABC/KLM/keep.py": True,
283
+ "src/x/ABC/KLM/keep.py": True,
284
+ },
285
+ id="abc-klm-exception",
286
+ ),
287
+ pytest.param(
288
+ ["**"],
289
+ ["**/__pycache__/**"],
290
+ {
291
+ "src/app.py": True,
292
+ "src/__pycache__/cached.py": False,
293
+ },
294
+ id="pycache-broad-exclude-wins",
295
+ ),
296
+ pytest.param(
297
+ ["**", "**/keep/**"],
298
+ ["**/skip/**"],
299
+ {
300
+ "skip/only.py": False,
301
+ "keep/only.py": True,
302
+ "other/only.py": True,
303
+ },
304
+ id="unrelated-exclude-does-not-block",
305
+ ),
306
+ ]
307
+
308
+
309
+ @pytest.mark.parametrize("inc_dirs,exc_dirs,files", SPECIFICITY_SCENARIOS)
310
+ def test_specificity_integration(
311
+ tmp_path: Path,
312
+ inc_dirs: list[str],
313
+ exc_dirs: list[str],
314
+ files: dict[str, bool],
315
+ ):
316
+ for rel in files:
317
+ touch(tmp_path / rel)
318
+ cfg = make_config(
319
+ include_dirs=inc_dirs,
320
+ include_extensions=["py"],
321
+ exclude_dirs=exc_dirs,
322
+ )
323
+ got = select_paths(tmp_path, cfg)
324
+ for rel, should_include in files.items():
325
+ assert (str(tmp_path / rel) in got) is should_include, rel
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Segment-glob exclude integration (end-to-end)
330
+ # ---------------------------------------------------------------------------
331
+
332
+ SEGMENT_EXCLUDE_SCENARIOS = [
333
+ pytest.param(
334
+ ["**/*hello/**"],
335
+ {
336
+ "ok/file.c": True,
337
+ "ABC/Myhello/EFG/skip.c": False,
338
+ "ABC/xhello/skip.c": False,
339
+ "ABC/hello/EFG/keep.c": True,
340
+ },
341
+ id="star-hello-suffix",
342
+ ),
343
+ pytest.param(
344
+ ["**/**hello/**"],
345
+ {
346
+ "ok/file.c": True,
347
+ "ABC/hello/EFG/skip.c": False,
348
+ "ABC/Myhello/EFG/skip.c": False,
349
+ "ABC/other/keep.c": True,
350
+ },
351
+ id="doublestar-hello-suffix",
352
+ ),
353
+ pytest.param(
354
+ ["**/hello*/**"],
355
+ {
356
+ "ABC/helloworld/x/skip.c": False,
357
+ "ABC/hello/x/keep.c": True,
358
+ "ABC/myhello/x/keep.c": True,
359
+ },
360
+ id="hello-prefix-glob",
361
+ ),
362
+ pytest.param(
363
+ ["**/*abc*/**"],
364
+ {
365
+ "data/xabcy/nested/skip.c": False,
366
+ "data/abc/keep.c": True,
367
+ "data/ab/keep.c": True,
368
+ },
369
+ id="infix-abc-glob",
370
+ ),
371
+ ]
372
+
373
+
374
+ @pytest.mark.parametrize("exclude_dirs,files", SEGMENT_EXCLUDE_SCENARIOS)
375
+ def test_segment_glob_exclude_integration(
376
+ tmp_path: Path,
377
+ exclude_dirs: list[str],
378
+ files: dict[str, bool],
379
+ ):
380
+ for rel in files:
381
+ touch(tmp_path / rel)
382
+ cfg = make_config(
383
+ include_dirs=["**"],
384
+ include_extensions=["c"],
385
+ exclude_dirs=exclude_dirs,
386
+ )
387
+ got = select_paths(tmp_path, cfg)
388
+ for rel, should_include in files.items():
389
+ assert (str(tmp_path / rel) in got) is should_include, rel
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Multi-pattern OR semantics
394
+ # ---------------------------------------------------------------------------
395
+
396
+ def test_multiple_include_file_patterns_or(tmp_path: Path):
397
+ touch(tmp_path / "a.py")
398
+ touch(tmp_path / "b.js")
399
+ touch(tmp_path / "c.txt")
400
+ cfg = make_config(include_files=["**/*.py", "**/*.js"])
401
+ paths = select_paths(tmp_path, cfg)
402
+ assert str(tmp_path / "a.py") in paths
403
+ assert str(tmp_path / "b.js") in paths
404
+ assert str(tmp_path / "c.txt") not in paths
405
+
406
+
407
+ def test_multiple_exclude_dir_patterns_or(tmp_path: Path):
408
+ touch(tmp_path / "ok/file.c")
409
+ touch(tmp_path / "build/out.c")
410
+ touch(tmp_path / "tmp/out.c")
411
+ touch(tmp_path / "src/out.c")
412
+ cfg = make_config(
413
+ include_dirs=["**"],
414
+ include_extensions=["c"],
415
+ exclude_dirs=["**/build/**", "**/tmp/**"],
416
+ )
417
+ paths = select_paths(tmp_path, cfg)
418
+ assert str(tmp_path / "ok/file.c") in paths
419
+ assert str(tmp_path / "src/out.c") in paths
420
+ assert str(tmp_path / "build/out.c") not in paths
421
+ assert str(tmp_path / "tmp/out.c") not in paths
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # Extension + path filter combinations
426
+ # ---------------------------------------------------------------------------
427
+
428
+ EXTENSION_COMBO_CASES = [
429
+ pytest.param(["py"], [], "main.py", True, id="ext-match"),
430
+ pytest.param(["py"], [], "main.md", False, id="ext-mismatch"),
431
+ pytest.param(["tar.gz"], [], "archive.tar.gz", True, id="compound-ext"),
432
+ pytest.param(["tar.gz"], [], "archive.tar", False, id="compound-ext-partial"),
433
+ pytest.param([".*"], [], "dotted.txt", True, id="dot-star"),
434
+ pytest.param([".*"], [], "noext", False, id="dot-star-no-ext"),
435
+ pytest.param([], [".log"], "app.log", False, id="exclude-ext"),
436
+ pytest.param([], [".log"], "app.py", True, id="exclude-ext-other"),
437
+ ]
438
+
439
+
440
+ @pytest.mark.parametrize(
441
+ "inc_ext,exc_ext,rel,expected", EXTENSION_COMBO_CASES
442
+ )
443
+ def test_extension_combinations(
444
+ tmp_path: Path,
445
+ inc_ext: list[str],
446
+ exc_ext: list[str],
447
+ rel: str,
448
+ expected: bool,
449
+ ):
450
+ touch(tmp_path / rel)
451
+ cfg = make_config(
452
+ include_extensions=inc_ext or None,
453
+ exclude_extensions=exc_ext or None,
454
+ )
455
+ paths = select_paths(tmp_path, cfg)
456
+ assert (str(tmp_path / rel) in paths) is expected
457
+
458
+
459
+ # ---------------------------------------------------------------------------
460
+ # Case insensitivity across path, pattern, and extensions
461
+ # ---------------------------------------------------------------------------
462
+
463
+ @pytest.mark.parametrize(
464
+ "pattern,dirpath",
465
+ [
466
+ ("**/myfolder/**", "abc/myfolder/sub"),
467
+ ("**/*hello/**", "abc/xhello/efg"),
468
+ ("folder/**/sub", "folder/a/sub"),
469
+ ],
470
+ )
471
+ def test_case_insensitivity_dir_patterns(pattern: str, dirpath: str):
472
+ assert match_dir(dirpath, [pattern.upper()]) is True
473
+
474
+
475
+ def test_case_insensitivity_file_pattern():
476
+ assert match_file("src/MODULE.PY", ["**/*.PY"]) is True
477
+
478
+
479
+ def test_case_insensitive_full_pipeline(tree: Path):
480
+ touch(tree / "SRC" / "MODULE.PY")
481
+ cfg = make_config(include_extensions=["PY"], include_dirs=["**/src/**"])
482
+ paths = select_paths(tree, cfg)
483
+ assert str(tree / "SRC" / "MODULE.PY") in paths
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Depth / anchoring edge cases
488
+ # ---------------------------------------------------------------------------
489
+
490
+ DEPTH_CASES = [
491
+ ("folder", "folder", True),
492
+ ("folder", "a/folder", False),
493
+ ("folder/*", "folder/a", True),
494
+ ("folder/*", "a/folder/x", False),
495
+ ("folder/*/*", "folder/a/b", True),
496
+ ("folder/*/*", "folder/a", False),
497
+ ("*/*/x", "a/b/x", True),
498
+ ("*/*/x", "a/x", False),
499
+ ("*/*/*/x", "a/b/c/x", True),
500
+ ("*/*/*/x", "a/b/x", False),
501
+ ]
502
+
503
+
504
+ @pytest.mark.parametrize("pattern,dirpath,expected", DEPTH_CASES)
505
+ def test_dir_depth_anchoring(pattern: str, dirpath: str, expected: bool):
506
+ assert match_dir(dirpath, [pattern]) is expected
507
+
508
+
509
+ # ---------------------------------------------------------------------------
510
+ # matches() API consistency with select()
511
+ # ---------------------------------------------------------------------------
512
+
513
+ def test_matches_agrees_with_select(tmp_path: Path):
514
+ from conftest import rules_for
515
+ import json
516
+ from filefilter import load
517
+
518
+ files = {
519
+ "a.py": True,
520
+ "build/a.py": False,
521
+ "src/b.py": True,
522
+ }
523
+ for rel in files:
524
+ touch(tmp_path / rel)
525
+ cfg = make_config(
526
+ include_dirs=["src/**", "*.py"],
527
+ include_extensions=["py"],
528
+ exclude_dirs=["build"],
529
+ )
530
+ rules = rules_for(tmp_path, cfg)
531
+ selected = select_paths(tmp_path, cfg)
532
+ for rel, _ in files.items():
533
+ full = str(tmp_path / rel)
534
+ assert matches(full, rules) == (full in selected or str(tmp_path / rel) in selected)
535
+
536
+
537
+ # ---------------------------------------------------------------------------
538
+ # Empty / degenerate inputs
539
+ # ---------------------------------------------------------------------------
540
+
541
+ @pytest.mark.parametrize(
542
+ "pattern,path",
543
+ [
544
+ ("", "a/b"),
545
+ ("**", ""),
546
+ ],
547
+ )
548
+ def test_degenerate_dir_patterns_do_not_crash(pattern: str, path: str):
549
+ assert match_dir(path, [pattern]) in (True, False)
550
+
551
+
552
+ @pytest.mark.parametrize(
553
+ "pattern,path",
554
+ [
555
+ ("", "file.txt"),
556
+ ("**", "a/b/c"),
557
+ ],
558
+ )
559
+ def test_degenerate_file_patterns_do_not_crash(pattern: str, path: str):
560
+ assert match_file(path, [pattern]) in (True, False)
@@ -106,12 +106,67 @@ def test_dir_root_one_any_semantics(tree: Path):
106
106
  ("folder/**/another", "folder/another", True),
107
107
  ("folder/***/another", "folder/***/another", True),
108
108
  ("folder/***/another", "folder/x/another", False),
109
+ ("**/*hello/**", "abc/myhello/efg", True),
110
+ ("**/*hello/**", "abc/hello/efg", False),
111
+ ("**/**hello/**", "abc/hello/efg", True),
112
+ ("**/**hello/**", "abc/myhello/efg", True),
113
+ ("**/*hello/**", "abc/xhello/efg", True),
114
+ ("**/hello*/**", "abc/helloworld/x", True),
115
+ ("**/hello*/**", "abc/hello/x", False),
116
+ ("**/*abc/**", "xyz/dataabc/nested", True),
117
+ ("**/abc*/**", "xyz/abc123/nested", True),
118
+ ("**/*abc*", "somewhere/myabcfolder", True),
109
119
  ],
110
120
  )
111
121
  def test_match_dir_patterns(pattern: str, dirpath: str, expected: bool):
112
122
  assert match_dir(dirpath, [pattern]) is expected
113
123
 
114
124
 
125
+ def test_exclude_dirs_with_segment_glob(tmp_path: Path):
126
+ touch(tmp_path / "ok" / "file.c")
127
+ touch(tmp_path / "ABC" / "Myhello" / "EFG" / "skip.c")
128
+ touch(tmp_path / "ABC" / "hello" / "EFG" / "skip.c")
129
+ touch(tmp_path / "ABC" / "xhello" / "keep.c")
130
+
131
+ cfg = make_config(
132
+ include_dirs=["**"],
133
+ include_extensions=["c"],
134
+ exclude_dirs=["**/*hello/**"],
135
+ )
136
+ paths = select_paths(tmp_path, cfg)
137
+ assert str(tmp_path / "ok" / "file.c") in paths
138
+ assert str(tmp_path / "ABC" / "Myhello" / "EFG" / "skip.c") not in paths
139
+ assert str(tmp_path / "ABC" / "xhello" / "keep.c") not in paths
140
+ assert str(tmp_path / "ABC" / "hello" / "EFG" / "skip.c") in paths
141
+
142
+
143
+ def test_exclude_dirs_segment_glob_includes_bare_hello_with_doublestar(tmp_path: Path):
144
+ touch(tmp_path / "ABC" / "hello" / "EFG" / "skip.c")
145
+ touch(tmp_path / "ABC" / "Myhello" / "EFG" / "skip.c")
146
+ touch(tmp_path / "ok" / "file.c")
147
+
148
+ cfg = make_config(
149
+ include_dirs=["**"],
150
+ include_extensions=["c"],
151
+ exclude_dirs=["**/**hello/**"],
152
+ )
153
+ paths = select_paths(tmp_path, cfg)
154
+ assert str(tmp_path / "ok" / "file.c") in paths
155
+ assert str(tmp_path / "ABC" / "hello" / "EFG" / "skip.c") not in paths
156
+ assert str(tmp_path / "ABC" / "Myhello" / "EFG" / "skip.c") not in paths
157
+
158
+ def test_match_file_dir_segment_glob(tmp_path: Path):
159
+ touch(tmp_path / "ABC" / "Myhello" / "target.c")
160
+ touch(tmp_path / "ABC" / "hello" / "target.c")
161
+ cfg = make_config(
162
+ include_files=["**/*hello/**/target.c"],
163
+ include_extensions=["c"],
164
+ )
165
+ paths = select_paths(tmp_path, cfg)
166
+ assert str(tmp_path / "ABC" / "Myhello" / "target.c") in paths
167
+ assert str(tmp_path / "ABC" / "hello" / "target.c") not in paths
168
+
169
+
115
170
  def test_root_only_dir_pattern_does_not_match_nested_descendants(tree: Path):
116
171
  cfg = make_config(include_dirs=["folder"], include_extensions=[".*"])
117
172
  paths = select_paths(tree, cfg)
@@ -11,6 +11,7 @@ from filefilter.utilities import (
11
11
  parse_dir_patterns,
12
12
  parse_extensions,
13
13
  pattern_specificity,
14
+ segment_matches,
14
15
  split_path,
15
16
  )
16
17
 
@@ -58,6 +59,45 @@ def test_match_doublestar_single_segment_wildcard():
58
59
  assert 2 not in ends
59
60
 
60
61
 
62
+ @pytest.mark.parametrize(
63
+ "token,part,expected",
64
+ [
65
+ ("*hello", "myhello", True),
66
+ ("*hello", "hello", False),
67
+ ("*hello", "xhello", True),
68
+ ("**hello", "hello", True),
69
+ ("**hello", "myhello", True),
70
+ ("**hello", "xhello", True),
71
+ ("hello*", "helloworld", True),
72
+ ("hello*", "hello", False),
73
+ ("hello**", "hello", True),
74
+ ("hello**", "helloworld", True),
75
+ ("abc*", "abcx", True),
76
+ ("abc*", "abc", False),
77
+ ("*abc", "xabc", True),
78
+ ("*abc", "abc", False),
79
+ ("*abc*", "xabcy", True),
80
+ ("*abc*", "abc", False),
81
+ ("folder", "folder", True),
82
+ ("folder", "other", False),
83
+ ("***", "***", True),
84
+ ("***", "myhello", False),
85
+ ],
86
+ )
87
+ def test_segment_matches_glob(token: str, part: str, expected: bool):
88
+ assert segment_matches(token, part) is expected
89
+
90
+
91
+ def test_match_doublestar_segments_glob_in_segment():
92
+ parts = ["abc", "myhello", "efg"]
93
+ assert match_doublestar_segments(parts, 1, ["*hello"]) == [2]
94
+ assert match_doublestar_segments(parts, 0, ["abc", "*hello"]) == [2]
95
+ assert match_doublestar_segments(parts, 0, ["**", "*hello"]) == [2]
96
+ parts2 = ["abc", "helloworld", "efg"]
97
+ assert match_doublestar_segments(parts2, 1, ["hello*"]) == [2]
98
+ assert match_doublestar_segments(parts2, 0, ["abc", "hello*"]) == [2]
99
+
100
+
61
101
  def test_parse_dir_patterns_buckets():
62
102
  root, one, any_ = parse_dir_patterns(
63
103
  ["src", "*/pkg", "**/cache", "/", " "]
File without changes
File without changes