| 1 |
"""`.dlm/ignore` grammar tests — comments, blanks, negation, anchors, |
| 2 |
directory-only, globstar, malformed lines.""" |
| 3 |
|
| 4 |
from __future__ import annotations |
| 5 |
|
| 6 |
import logging |
| 7 |
|
| 8 |
import pytest |
| 9 |
|
| 10 |
from dlm.directives.ignore_parser import ( |
| 11 |
IgnoreRule, |
| 12 |
matches, |
| 13 |
parse_ignore_file, |
| 14 |
) |
| 15 |
|
| 16 |
# ---- parse_ignore_file ----------------------------------------------------- |
| 17 |
|
| 18 |
|
| 19 |
def test_parse_empty() -> None: |
| 20 |
assert parse_ignore_file("") == () |
| 21 |
assert parse_ignore_file(" \n\n\t\n") == () |
| 22 |
|
| 23 |
|
| 24 |
def test_parse_skips_comments_and_blanks() -> None: |
| 25 |
text = "# top comment\n\n # indented comment\n*.log\n" |
| 26 |
rules = parse_ignore_file(text) |
| 27 |
assert len(rules) == 1 |
| 28 |
assert rules[0].pattern == "*.log" |
| 29 |
|
| 30 |
|
| 31 |
def test_parse_negation() -> None: |
| 32 |
rules = parse_ignore_file("!keep.txt\n") |
| 33 |
assert rules[0].negate is True |
| 34 |
assert rules[0].pattern == "keep.txt" |
| 35 |
|
| 36 |
|
| 37 |
def test_parse_anchored() -> None: |
| 38 |
rules = parse_ignore_file("/scripts/local.sh\n") |
| 39 |
assert rules[0].anchored is True |
| 40 |
assert rules[0].pattern == "scripts/local.sh" |
| 41 |
|
| 42 |
|
| 43 |
def test_parse_directory_only() -> None: |
| 44 |
rules = parse_ignore_file("build/\n") |
| 45 |
assert rules[0].directory_only is True |
| 46 |
assert rules[0].pattern == "build" |
| 47 |
|
| 48 |
|
| 49 |
def test_parse_combined_flags() -> None: |
| 50 |
rules = parse_ignore_file("!/scripts/build/\n") |
| 51 |
assert rules[0].negate is True |
| 52 |
assert rules[0].anchored is True |
| 53 |
assert rules[0].directory_only is True |
| 54 |
assert rules[0].pattern == "scripts/build" |
| 55 |
|
| 56 |
|
| 57 |
def test_parse_bare_bang_skipped(caplog: pytest.LogCaptureFixture) -> None: |
| 58 |
caplog.set_level(logging.WARNING, logger="dlm.directives.ignore_parser") |
| 59 |
rules = parse_ignore_file("!\n") |
| 60 |
assert rules == () |
| 61 |
assert any("bare '!'" in rec.message for rec in caplog.records) |
| 62 |
|
| 63 |
|
| 64 |
def test_parse_bare_slash_skipped(caplog: pytest.LogCaptureFixture) -> None: |
| 65 |
caplog.set_level(logging.WARNING, logger="dlm.directives.ignore_parser") |
| 66 |
rules = parse_ignore_file("/\n") |
| 67 |
assert rules == () |
| 68 |
assert any("bare '/'" in rec.message for rec in caplog.records) |
| 69 |
|
| 70 |
|
| 71 |
def test_parse_pattern_reduced_to_empty_skipped(caplog: pytest.LogCaptureFixture) -> None: |
| 72 |
caplog.set_level(logging.WARNING, logger="dlm.directives.ignore_parser") |
| 73 |
rules = parse_ignore_file("//\n") |
| 74 |
assert rules == () |
| 75 |
assert any("pattern reduced to empty" in rec.message for rec in caplog.records) |
| 76 |
|
| 77 |
|
| 78 |
# ---- matches --------------------------------------------------------------- |
| 79 |
|
| 80 |
|
| 81 |
def _rule( |
| 82 |
pattern: str, |
| 83 |
*, |
| 84 |
anchored: bool = False, |
| 85 |
directory_only: bool = False, |
| 86 |
negate: bool = False, |
| 87 |
) -> IgnoreRule: |
| 88 |
return IgnoreRule( |
| 89 |
pattern=pattern, |
| 90 |
anchored=anchored, |
| 91 |
directory_only=directory_only, |
| 92 |
negate=negate, |
| 93 |
) |
| 94 |
|
| 95 |
|
| 96 |
def test_matches_unanchored_globstar() -> None: |
| 97 |
rule = _rule("**/__pycache__/**") |
| 98 |
assert matches(rule, "src/__pycache__/foo.pyc", is_dir=False) |
| 99 |
assert matches(rule, "a/b/__pycache__/c/d.pyc", is_dir=False) |
| 100 |
|
| 101 |
|
| 102 |
def test_matches_unanchored_basename() -> None: |
| 103 |
rule = _rule("*.log") |
| 104 |
assert matches(rule, "debug.log", is_dir=False) |
| 105 |
assert matches(rule, "a/b/debug.log", is_dir=False) |
| 106 |
|
| 107 |
|
| 108 |
def test_matches_anchored_only_at_root() -> None: |
| 109 |
rule = _rule("scripts/local.sh", anchored=True) |
| 110 |
assert matches(rule, "scripts/local.sh", is_dir=False) |
| 111 |
assert not matches(rule, "a/scripts/local.sh", is_dir=False) |
| 112 |
|
| 113 |
|
| 114 |
def test_matches_directory_only_flags_subtree() -> None: |
| 115 |
rule = _rule("build", directory_only=True) |
| 116 |
# Any path under a "build" directory matches via ancestor-component |
| 117 |
assert matches(rule, "build/x.txt", is_dir=False) |
| 118 |
assert matches(rule, "a/build/x/y.txt", is_dir=False) |
| 119 |
# No "build" component → no match |
| 120 |
assert not matches(rule, "src/main.py", is_dir=False) |
| 121 |
|
| 122 |
|
| 123 |
def test_matches_directory_only_with_file_not_dir() -> None: |
| 124 |
# A file *named* "build" without the directory flag matches; with |
| 125 |
# directory_only=True, a plain file at that path does NOT match |
| 126 |
# (only directories and their descendants do). |
| 127 |
rule = _rule("build", directory_only=True) |
| 128 |
# A file literally named "build" at root: no ancestor "build/" path. |
| 129 |
assert not matches(rule, "build", is_dir=False) |
| 130 |
# But matches when is_dir=True: |
| 131 |
assert matches(rule, "build", is_dir=True) |
| 132 |
|
| 133 |
|
| 134 |
def test_matches_single_star_does_not_cross_slash() -> None: |
| 135 |
rule = _rule("*.py") |
| 136 |
assert matches(rule, "foo.py", is_dir=False) |
| 137 |
# `*.py` anchored (via unanchored suffix-matching) should match the |
| 138 |
# last component of a/b.py too, since we try suffixes. |
| 139 |
assert matches(rule, "a/b.py", is_dir=False) |
| 140 |
|
| 141 |
|
| 142 |
def test_matches_question_mark_single_char() -> None: |
| 143 |
rule = _rule("foo?.txt") |
| 144 |
assert matches(rule, "foo1.txt", is_dir=False) |
| 145 |
assert not matches(rule, "foo12.txt", is_dir=False) |
| 146 |
|
| 147 |
|
| 148 |
def test_matches_globstar_prefix() -> None: |
| 149 |
rule = _rule("tests/**") |
| 150 |
assert matches(rule, "tests/a.py", is_dir=False) |
| 151 |
assert matches(rule, "tests/a/b/c.py", is_dir=False) |
| 152 |
assert not matches(rule, "src/a.py", is_dir=False) |