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)