- query language: substrings, phrases, /regex/, AND/OR/NOT (+ UND/ODER/NICHT), parentheses; case-insensitive; ReDoS protection via regex timeouts; syntax errors with stable code and position - SearchEngine over in-memory blocks: filters, predicate, grouping, sorting, paging, time budget, automatic reload, highlighted snippets - Markdown loader with split modes file/heading/list/paragraph - grep-like CLI (mdgrep), JSON output, grep exit codes - Flask blueprint/app with JSON API and widget assets, dev server - framework-free JS widget: live search, URL state, filters, paging, syntax help, en/de labels, CSS custom properties for theming Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
5.8 KiB
Python
187 lines
5.8 KiB
Python
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)
|