mdgrep/tests/test_query.py

187 lines
5.8 KiB
Python
Raw Permalink Normal View History

import time
import pytest
from mdgrep import QuerySyntaxError, QueryTimeout, compile_query
def m(query, text):
return compile_query(query).matches(text)
# ── Terms ────────────────────────────────────────────────────────────────
def test_substring_case_insensitive():
assert m("rust", "The Rustacean book")
assert m("RUST", "trusted")
assert not m("rust", "golang")
def test_umlauts_case_insensitive():
assert m("übersetzung", "Die ÜBERSETZUNG ist fertig")
assert m("ÄRGER", "kein ärger")
def test_phrase_matches_any_whitespace():
assert m('"open source"', "Open\n Source software")
assert not m('"open source"', "open the source")
def test_phrase_escaped_quote():
assert m(r'"say \"hi\""', 'they say "hi" loudly')
def test_regex():
assert m(r"/llms?\b/", "Large LLMs today")
assert m(r"/gpt-\d+/", "GPT-5 release")
assert not m(r"/^foo$/", "foobar")
def test_regex_escaped_slash():
assert m(r"/tcp\/ip/", "TCP/IP stack")
def test_words_with_special_characters():
assert m("AT&T", "at&t merger")
assert m("TCP/IP", "the tcp/ip suite")
assert m("e-mail", "send an E-Mail")
def test_lowercase_keywords_are_terms():
assert m("and", "this AND that")
assert not m("and", "nothing here")
# ── Boolean logic ────────────────────────────────────────────────────────
@pytest.mark.parametrize("query", ["a b", "a AND b", "a UND b", "a & b", "a && b"])
def test_and_variants(query):
assert compile_query(query).matches("a b")
assert not compile_query(query).matches("a only")
@pytest.mark.parametrize("query", ["a OR b", "a ODER b", "a | b", "a || b"])
def test_or_variants(query):
assert compile_query(query).matches("b only")
assert not compile_query(query).matches("nothing")
@pytest.mark.parametrize("query", ["NOT x", "NICHT x", "-x", "!x"])
def test_not_variants(query):
assert compile_query(query).matches("abc")
assert not compile_query(query).matches("xyz")
def test_precedence_and_binds_tighter_than_or():
q = compile_query("alpha OR beta AND gamma") # alpha OR (beta AND gamma)
assert q.matches("alpha")
assert not q.matches("beta")
assert q.matches("beta gamma")
def test_parentheses_override_precedence():
q = compile_query("(alpha OR beta) AND gamma")
assert not q.matches("alpha")
assert q.matches("alpha gamma")
assert q.matches("beta gamma")
def test_not_binds_tighter_than_and():
q = compile_query("NOT alpha beta") # (NOT alpha) AND beta
assert q.matches("beta")
assert not q.matches("alpha beta")
def test_nested_groups_and_negation():
q = compile_query("(rust OR go) AND NOT (crypto OR /blockchain/)")
assert q.matches("Rust compiler")
assert not q.matches("Go crypto wallet")
assert not q.matches("rust blockchain")
def test_double_negation():
assert m("NOT NOT foo", "foo")
# ── Spans (highlighting) ─────────────────────────────────────────────────
def test_spans_merged_and_sorted():
q = compile_query("open OR source")
assert q.spans("open source open") == [(0, 4), (5, 11), (12, 16)]
def test_spans_exclude_negated_terms():
q = compile_query("rust -crypto")
assert q.spans("rust and crypto") == [(0, 4)]
def test_spans_double_negation_counts_as_positive():
q = compile_query("NOT NOT foo")
assert q.spans("a foo") == [(2, 5)]
def test_spans_ignore_empty_regex_matches():
q = compile_query("/x*/")
assert q.spans("abc") == []
assert q.spans("axxb") == [(1, 3)]
def test_spans_overlapping_merged():
q = compile_query("abc OR bcd")
assert q.spans("abcd") == [(0, 4)]
# ── Errors ───────────────────────────────────────────────────────────────
@pytest.mark.parametrize("query,code,position", [
("", "empty_query", 0),
(" ", "empty_query", 0),
("(a OR b", "unclosed_paren", 0),
("a OR b)", "unexpected_paren", 6),
("a OR", "missing_operand", 4),
("OR a", "missing_operand", 0),
("a AND OR b", "missing_operand", 6),
("()", "empty_group", 0),
('"open', "unterminated_phrase", 0),
("x /abc", "unterminated_regex", 2),
("x /a(b/", "invalid_regex", 2),
("NOT", "missing_operand", 3),
])
def test_syntax_errors(query, code, position):
with pytest.raises(QuerySyntaxError) as exc:
compile_query(query)
assert exc.value.code == code
assert exc.value.position == position
assert exc.value.to_dict()["code"] == code
def test_limits():
with pytest.raises(QuerySyntaxError) as exc:
compile_query("a" * 20, max_length=10)
assert exc.value.code == "too_long"
with pytest.raises(QuerySyntaxError) as exc:
compile_query("a b c d", max_terms=3)
assert exc.value.code == "too_many_terms"
# ── Timeouts (ReDoS protection) ──────────────────────────────────────────
def test_catastrophic_regex_times_out():
q = compile_query("/(a|aa)+$/")
text = "a" * 36 + "!"
start = time.monotonic()
with pytest.raises(QueryTimeout):
q.matches(text, timeout=0.2)
assert time.monotonic() - start < 2
def test_spans_timeout():
q = compile_query("/(a|aa)+$/")
with pytest.raises(QueryTimeout):
q.spans("a" * 36 + "!", timeout=0.2)
def test_expired_deadline_raises():
q = compile_query("foo")
with pytest.raises(QueryTimeout):
q.matches("foo", deadline=time.monotonic() - 1)