feat: mdgrep 0.1.0 – boolean full-text search for Markdown
- 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>
This commit is contained in:
commit
9c7b7e81f9
23 changed files with 2642 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Dieter Schlüter
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
220
README.md
Normal file
220
README.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# mdgrep
|
||||
|
||||
Boolean full-text search over Markdown (and any other text) – as a **Python library**, a
|
||||
**grep-like command line tool**, a **Flask JSON API** and a **framework-free JavaScript search widget**.
|
||||
|
||||
- substrings, `"phrases"` and `/regular expressions/`, always case-insensitive (Unicode-aware)
|
||||
- `AND` / `OR` / `NOT` with parentheses – German keywords `UND` / `ODER` / `NICHT` work too
|
||||
- results as "boxes" (blocks) with highlighted context, grouping, sorting, filters and paging
|
||||
- protection against catastrophic regular expressions (time limit per search)
|
||||
- no search index: blocks live in memory, a few MB of text are searched in milliseconds
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.0" # library + CLI
|
||||
pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.0" # + Flask API
|
||||
```
|
||||
|
||||
Requires Python ≥ 3.10. Dependencies: [`regex`](https://pypi.org/project/regex/) (and `flask` for `[web]`).
|
||||
|
||||
## Query syntax
|
||||
|
||||
| Query | Meaning |
|
||||
|---|---|
|
||||
| `rust` | substring, case-insensitive – also matches *Rust*, *trusted*, *Rustacean* |
|
||||
| `"open source"` | phrase; any whitespace (incl. line breaks) between the words |
|
||||
| `/llms?\b/` | regular expression (`\/` for a literal slash), case-insensitive |
|
||||
| `a b` · `a AND b` · `a UND b` · `a & b` | both |
|
||||
| `a OR b` · `a ODER b` · `a \| b` | at least one |
|
||||
| `NOT a` · `NICHT a` · `-a` · `!a` | must not occur |
|
||||
| `( … )` | grouping |
|
||||
|
||||
Precedence: `NOT` > `AND` > `OR`, so `a OR b AND c` means `a OR (b AND c)`.
|
||||
Keywords are only recognised in upper case (`and` is a normal search word).
|
||||
Whole words: use a regex, e.g. `/\bgo\b/`.
|
||||
|
||||
Examples: `(rust OR go) AND NOT crypto` · `"machine learning" -/pytorch|tensorflow/` · `llm (privacy OR datenschutz)`
|
||||
|
||||
Invalid queries raise `QuerySyntaxError` with a stable `code`, a `message` and the 0-based `position`:
|
||||
`empty_query`, `empty_phrase`, `unterminated_phrase`, `unterminated_regex`, `invalid_regex`,
|
||||
`missing_operand`, `unexpected_paren`, `unexpected_token`, `unclosed_paren`, `empty_group`,
|
||||
`too_long`, `too_many_terms`.
|
||||
|
||||
## Command line
|
||||
|
||||
```bash
|
||||
mdgrep '(rust OR go) AND NOT crypto' notes/ # search all *.md below notes/
|
||||
mdgrep '/llms?\b/' docs/*.md --split list # one result per list item
|
||||
mdgrep 'todo' . -l # file names only
|
||||
mdgrep 'release' . -c # number of matching blocks
|
||||
mdgrep '"open source"' . --json # JSON (same format as the Web API)
|
||||
python -m mdgrep --help
|
||||
```
|
||||
|
||||
`--split` chooses the unit that is searched and shown: `paragraph` (default), `list`
|
||||
(top-level list items, other text as paragraphs), `heading` (sections) or `file`.
|
||||
Exit codes as with grep: `0` match, `1` no match, `2` error (syntax error, time limit).
|
||||
|
||||
## Python API
|
||||
|
||||
```python
|
||||
from mdgrep import compile_query, load_markdown, SearchEngine, Block
|
||||
|
||||
# 1. Just the query language – on any string
|
||||
q = compile_query('(rust OR go) AND NOT /crypto(currency)?/')
|
||||
q.matches("Rust 2.0 is out") # True
|
||||
q.spans("Rust and Go") # [(0, 4), (9, 11)] – for highlighting
|
||||
|
||||
# 2. Markdown files → blocks → search
|
||||
blocks = load_markdown("docs/", split="list", relative_to="docs/",
|
||||
meta=lambda path: {"lang": path.stem.rsplit("_", 1)[-1]})
|
||||
engine = SearchEngine(blocks)
|
||||
result = engine.search("rust", filters={"lang": "de"}, limit=20)
|
||||
for group in result.groups:
|
||||
print(group.block.source, group.block.line, group.segments)
|
||||
result.to_dict() # JSON-ready, see "JSON format"
|
||||
|
||||
# 3. Your own data – any text source
|
||||
engine = SearchEngine([
|
||||
Block(id="42", title="Ticket 42", text="Login fails with SSO", meta={"status": "open"}),
|
||||
Block(id="43", title="Ticket 43", text="Dark mode toggle broken", meta={"status": "closed"}),
|
||||
])
|
||||
engine.search("login OR sso", filters={"status": "open"})
|
||||
```
|
||||
|
||||
### `Block`
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `id` | unique id |
|
||||
| `text` | searched and shown as snippet |
|
||||
| `title` | searched and shown as heading (don't repeat it in `text`) |
|
||||
| `anchor` | optional fragment id, used by the widget as link target if no `href` is given |
|
||||
| `source`, `line` | origin (file, 1-based line) |
|
||||
| `meta` | free dict, passed through unchanged (filters work on it) |
|
||||
|
||||
`load_markdown()` adds `meta["section"]` (nearest heading), `meta["kind"]`
|
||||
(`file`/`section`/`item`/`paragraph`) and `meta["markdown"]` (raw source).
|
||||
|
||||
### `SearchEngine(source, *, reload_check=None, reload_interval=60)`
|
||||
|
||||
`source` is a list of blocks or a callable returning them. With a callable plus `reload_check`
|
||||
(returns any comparable token, e.g. newest file mtime) blocks are reloaded automatically when
|
||||
the token changes – checked at most every `reload_interval` seconds:
|
||||
|
||||
```python
|
||||
engine = SearchEngine(lambda: load_markdown("docs/"),
|
||||
reload_check=lambda: max(p.stat().st_mtime for p in Path("docs").rglob("*.md")))
|
||||
```
|
||||
|
||||
### `engine.search(query, **options)`
|
||||
|
||||
| Option | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `filters` | `None` | `{meta_key: value or [values]}`, all keys must match; empty values are ignored |
|
||||
| `predicate` | `None` | extra `callable(block) -> bool` |
|
||||
| `group_by` | `None` | `callable(block) -> key or None`; one result per key, the others in `also` |
|
||||
| `sort_key`, `reverse` | original order | sort matching blocks (e.g. newest first) |
|
||||
| `offset`, `limit` | `0`, `30` | paging, counted in groups (`limit=None` = all) |
|
||||
| `time_budget` | `1.5` | seconds for the whole search; on overrun `truncated=True` |
|
||||
| `snippet_chars`, `context` | `700`, `120` | longer texts are cut to windows around the hits |
|
||||
|
||||
## Web API (Flask)
|
||||
|
||||
```python
|
||||
from mdgrep import SearchEngine, load_markdown
|
||||
from mdgrep.web import create_app, create_blueprint
|
||||
|
||||
engine = SearchEngine(lambda: load_markdown("docs", split="list", relative_to="docs"))
|
||||
app = create_app(engine, filters=["lang"]) # standalone: GET /search
|
||||
# or inside an existing app:
|
||||
app.register_blueprint(create_blueprint(engine, filters=["lang"]), url_prefix="/api")
|
||||
```
|
||||
|
||||
`GET /search?q=…&offset=0&limit=30&<filter>=value` – for each name in `filters` an equally
|
||||
named parameter (`""`/`all` = no restriction, `a,b` = several values). The blueprint also serves the
|
||||
widget assets under `/mdgrep-static/`. `create_blueprint()` accepts the search options above plus
|
||||
`default_limit`, `max_limit`, `max_query_length` and `hide_meta` (meta keys removed from responses,
|
||||
default `("markdown",)`).
|
||||
|
||||
Development server (serves a static site and the API together):
|
||||
|
||||
```bash
|
||||
python -m mdgrep.web --dir examples/docs --split list --static examples/site --port 8000
|
||||
# → http://127.0.0.1:8000/ (API: /api/search)
|
||||
```
|
||||
|
||||
For production run the app with a WSGI server (e.g. `gunicorn -w 2 module:app`) behind a reverse
|
||||
proxy with rate limiting.
|
||||
|
||||
### JSON format
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "rust", "total_groups": 12, "total_blocks": 15,
|
||||
"offset": 0, "limit": 30, "next_offset": null, "truncated": false, "elapsed_ms": 3.2,
|
||||
"results": [{
|
||||
"block": {
|
||||
"id": "a.md:4", "title": "…", "anchor": "", "source": "a.md", "line": 4, "meta": {},
|
||||
"title_segments": [{"t": "…", "hit": false}],
|
||||
"segments": [{"t": "The ", "hit": false}, {"t": "Rust", "hit": true}, {"t": " team …", "hit": false}],
|
||||
"hits": 1
|
||||
},
|
||||
"also": [{"id": "b.md:9", "title": "…", "anchor": "", "source": "b.md", "line": 9, "meta": {}}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Errors: HTTP 400 `{"error": {"code": "unclosed_paren", "message": "…", "position": 0}}`.
|
||||
|
||||
## JavaScript widget
|
||||
|
||||
Framework-free, live search with debounce, cancels outdated requests, keeps the query in the URL
|
||||
(shareable, back button), filters, "load more", collapsible syntax help, error messages with
|
||||
position, English and German labels. All result text is inserted via `textContent` (no XSS).
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="/api/mdgrep-static/mdgrep-widget.css">
|
||||
<div id="search"></div>
|
||||
<script src="/api/mdgrep-static/mdgrep-widget.js"></script>
|
||||
<script>
|
||||
MdGrep.mount(document.getElementById('search'), {
|
||||
endpoint: '/api/search',
|
||||
labels: 'de', // 'en' | 'de' | {…overrides}
|
||||
filters: [{ name: 'lang', label: 'Sprache',
|
||||
options: [{ value: '', label: 'Alle' }, { value: 'de', label: 'Deutsch' }] }],
|
||||
badgeFields: ['lang'], // meta keys shown as badges
|
||||
dateField: 'date', // meta key shown as date
|
||||
hrefField: 'href', // meta key with the link target (else #anchor)
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Without JavaScript code: `<div data-mdgrep data-endpoint="/api/search" data-labels="de"></div>`.
|
||||
|
||||
Other options: `live`, `debounce` (300 ms), `minChars` (2), `limit` (30), `syncUrl`, `autofocus`,
|
||||
`help`, `href(block)`, `formatBadge(field, value, block)`, `alsoText(block)`.
|
||||
The widget fires a `mdgrep:results` event with the response on its root element.
|
||||
|
||||
For static sites copy the assets instead of serving them from Flask:
|
||||
|
||||
```python
|
||||
import mdgrep
|
||||
mdgrep.export_static("site/static") # writes mdgrep-widget.js / .css
|
||||
```
|
||||
|
||||
Theming via CSS custom properties (defaults are light):
|
||||
`--mdgrep-text`, `--mdgrep-muted`, `--mdgrep-surface`, `--mdgrep-border`, `--mdgrep-accent`,
|
||||
`--mdgrep-mark-bg`, `--mdgrep-mark-text`, `--mdgrep-error`, `--mdgrep-radius`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
||||
.venv/bin/pytest
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
13
examples/docs/languages.md
Normal file
13
examples/docs/languages.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Programming languages
|
||||
|
||||
## Systems
|
||||
|
||||
- **Rust** guarantees memory safety without a garbage collector. Its borrow checker
|
||||
rejects data races at compile time.
|
||||
- **Go** favours simplicity and fast compilation; goroutines make concurrency cheap.
|
||||
- **Zig** offers manual memory management with compile-time code execution.
|
||||
|
||||
## Scripting
|
||||
|
||||
- **Python** is popular for data science and machine learning (LLMs, PyTorch).
|
||||
- **Lua** is a tiny embeddable language used in games and Neovim.
|
||||
7
examples/docs/tools.md
Normal file
7
examples/docs/tools.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Tools
|
||||
|
||||
Regular expressions are supported by grep, ripgrep and most editors.
|
||||
|
||||
Large language models (LLMs) can explain a regex, but testing it is still wise.
|
||||
|
||||
Open source licenses such as MIT and GPL differ in their copyleft requirements.
|
||||
21
examples/site/index.html
Normal file
21
examples/site/index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>mdgrep demo</title>
|
||||
<link rel="stylesheet" href="api/mdgrep-static/mdgrep-widget.css">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 48rem; margin: 2rem auto; padding: 0 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>mdgrep demo</h1>
|
||||
<!-- Run: python -m mdgrep.web --dir examples/docs --split list --static examples/site -->
|
||||
<div id="search"></div>
|
||||
<script src="api/mdgrep-static/mdgrep-widget.js"></script>
|
||||
<script>
|
||||
MdGrep.mount(document.getElementById('search'), { endpoint: 'api/search', labels: 'en' });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "mdgrep"
|
||||
version = "0.1.0"
|
||||
description = "Boolean full-text search (substrings, phrases, regex, AND/OR/NOT, parentheses) over Markdown and other text blocks, with CLI, Flask API and a JavaScript search widget"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Dieter Schlüter" }]
|
||||
dependencies = ["regex>=2023.0"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
web = ["flask>=2.3"]
|
||||
dev = ["pytest>=7", "flask>=2.3"]
|
||||
|
||||
[project.scripts]
|
||||
mdgrep = "mdgrep.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
mdgrep = ["static/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
47
src/mdgrep/__init__.py
Normal file
47
src/mdgrep/__init__.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""mdgrep – boolean full-text search over Markdown and other text blocks."""
|
||||
|
||||
from .engine import Block, Group, SearchEngine, SearchResult
|
||||
from .markdown import load_markdown, markdown_to_text, split_markdown
|
||||
from .query import Query, QuerySyntaxError, QueryTimeout, compile_query
|
||||
from .snippets import segments
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
STATIC_FILES = ("mdgrep-widget.js", "mdgrep-widget.css")
|
||||
|
||||
|
||||
def export_static(target_dir) -> list:
|
||||
"""Copy the widget assets (JS + CSS) into `target_dir`, e.g. a static site's asset folder.
|
||||
|
||||
Returns the written paths. Files are only rewritten when their content changed.
|
||||
"""
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
|
||||
target = Path(target_dir)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
written = []
|
||||
for name in STATIC_FILES:
|
||||
data = files("mdgrep").joinpath("static", name).read_bytes()
|
||||
dest = target / name
|
||||
if not dest.exists() or dest.read_bytes() != data:
|
||||
dest.write_bytes(data)
|
||||
written.append(dest)
|
||||
return written
|
||||
|
||||
__all__ = [
|
||||
"Block",
|
||||
"Group",
|
||||
"Query",
|
||||
"QuerySyntaxError",
|
||||
"QueryTimeout",
|
||||
"SearchEngine",
|
||||
"SearchResult",
|
||||
"compile_query",
|
||||
"export_static",
|
||||
"load_markdown",
|
||||
"markdown_to_text",
|
||||
"split_markdown",
|
||||
"segments",
|
||||
"__version__",
|
||||
]
|
||||
5
src/mdgrep/__main__.py
Normal file
5
src/mdgrep/__main__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
sys.exit(main())
|
||||
123
src/mdgrep/cli.py
Normal file
123
src/mdgrep/cli.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""grep-like command line: mdgrep QUERY [PATH ...]
|
||||
|
||||
Exit codes as with grep: 0 = matches found, 1 = no match, 2 = error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .engine import SearchEngine
|
||||
from .markdown import SPLIT_MODES, load_markdown
|
||||
from .query import QuerySyntaxError, compile_query
|
||||
|
||||
_SYNTAX = """query syntax (case-insensitive):
|
||||
word substring ("rust" matches "Rustacean")
|
||||
"a phrase" exact phrase
|
||||
/regex/ regular expression (\\/ for a literal slash)
|
||||
a b, a AND b both (also UND, &)
|
||||
a OR b either (also ODER, |)
|
||||
NOT a, -a, !a negation (also NICHT)
|
||||
( ... ) grouping; precedence NOT > AND > OR
|
||||
|
||||
examples:
|
||||
mdgrep '(rust OR go) AND NOT crypto' notes/
|
||||
mdgrep '/llms?\\b/ "open source"' --split list docs/*.md
|
||||
"""
|
||||
|
||||
_COLORS = {"file": "\033[35m", "line": "\033[32m", "hit": "\033[1;31m", "dim": "\033[2m", "reset": "\033[0m"}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="mdgrep",
|
||||
description="Boolean full-text search in Markdown files.",
|
||||
epilog=_SYNTAX,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
p.add_argument("query", help="search query (see syntax below)")
|
||||
p.add_argument("paths", nargs="*", default=["."], help="files or directories (default: .)")
|
||||
p.add_argument("-s", "--split", choices=SPLIT_MODES, default="paragraph",
|
||||
help="unit that is searched and shown (default: paragraph)")
|
||||
p.add_argument("-g", "--glob", default="*.md", help="file pattern in directories (default: *.md)")
|
||||
p.add_argument("-c", "--count", action="store_true", help="only print the number of matching blocks")
|
||||
p.add_argument("-l", "--files-with-matches", action="store_true", help="only print file names")
|
||||
p.add_argument("-j", "--json", action="store_true", help="JSON output (same format as the Web API)")
|
||||
p.add_argument("-C", "--context", type=int, default=80, metavar="N",
|
||||
help="characters of context around hits in long blocks (default: 80)")
|
||||
p.add_argument("--max-chars", type=int, default=400, metavar="N",
|
||||
help="shorten blocks longer than N characters (0 = never, default: 400)")
|
||||
p.add_argument("-m", "--max-count", type=int, default=0, metavar="N",
|
||||
help="stop after N matching blocks (0 = all)")
|
||||
p.add_argument("--timeout", type=float, default=30.0, help="time limit in seconds (default: 30)")
|
||||
p.add_argument("--color", choices=("auto", "always", "never"), default="auto")
|
||||
p.add_argument("-V", "--version", action="version", version=f"mdgrep {__version__}")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
color = args.color == "always" or (args.color == "auto" and sys.stdout.isatty()
|
||||
and os.environ.get("NO_COLOR") is None)
|
||||
c = _COLORS if color else dict.fromkeys(_COLORS, "")
|
||||
|
||||
try:
|
||||
query = compile_query(args.query)
|
||||
except QuerySyntaxError as e:
|
||||
print(f"mdgrep: {e.message}", file=sys.stderr)
|
||||
print(f" {args.query}\n {' ' * e.position}^", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
blocks = load_markdown(args.paths, split=args.split, pattern=args.glob)
|
||||
except OSError as e:
|
||||
print(f"mdgrep: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = SearchEngine(blocks).search(
|
||||
query,
|
||||
limit=args.max_count or None,
|
||||
time_budget=args.timeout,
|
||||
snippet_chars=args.max_chars or None,
|
||||
context=args.context,
|
||||
)
|
||||
if result.truncated:
|
||||
print("mdgrep: time limit exceeded, results are incomplete", file=sys.stderr)
|
||||
|
||||
if args.json:
|
||||
data = result.to_dict()
|
||||
for r in data["results"]:
|
||||
r["block"]["meta"].pop("markdown", None)
|
||||
json.dump(data, sys.stdout, ensure_ascii=False, indent=2)
|
||||
print()
|
||||
elif args.count:
|
||||
print(result.total_blocks)
|
||||
elif args.files_with_matches:
|
||||
seen = []
|
||||
for g in result.groups:
|
||||
if g.block.source not in seen:
|
||||
seen.append(g.block.source)
|
||||
print(f"{c['file']}{g.block.source}{c['reset']}")
|
||||
else:
|
||||
for g in result.groups:
|
||||
print(f"{c['file']}{g.block.source}{c['reset']}:{c['line']}{g.block.line}{c['reset']}")
|
||||
if g.block.title:
|
||||
title = "".join(f"{c['hit']}{s['t']}{c['reset']}" if s["hit"] else s["t"]
|
||||
for s in g.title_segments)
|
||||
print(f" {title}")
|
||||
text = "".join(f"{c['hit']}{s['t']}{c['reset']}" if s["hit"] else s["t"] for s in g.segments)
|
||||
for line in text.split("\n"):
|
||||
print(f" {line}" if line else "")
|
||||
print()
|
||||
|
||||
if result.truncated:
|
||||
return 2
|
||||
return 0 if result.total_blocks else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
229
src/mdgrep/engine.py
Normal file
229
src/mdgrep/engine.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Search engine over in-memory text blocks: filtering, grouping, sorting, paging, time budget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Hashable, Iterable
|
||||
|
||||
from .query import Query, QueryTimeout, compile_query
|
||||
from .snippets import segments
|
||||
|
||||
__all__ = ["Block", "SearchEngine", "SearchResult", "Group"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Block:
|
||||
"""One searchable unit ("box" in the result list).
|
||||
|
||||
`title` and `text` are both searched and shown (title as heading, text as snippet), so
|
||||
loaders should not repeat the title inside the text. `meta` holds arbitrary application
|
||||
data (e.g. language, date, URL) and is passed through to results unchanged.
|
||||
"""
|
||||
id: str
|
||||
text: str
|
||||
title: str = ""
|
||||
anchor: str = ""
|
||||
source: str = ""
|
||||
line: int = 0
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {"id": self.id, "title": self.title, "anchor": self.anchor,
|
||||
"source": self.source, "line": self.line, "meta": self.meta}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Group:
|
||||
"""A primary block with its highlighted snippet plus further blocks of the same group."""
|
||||
key: Hashable
|
||||
block: Block
|
||||
title_segments: list[dict]
|
||||
segments: list[dict]
|
||||
hits: int
|
||||
also: list[Block]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
data = self.block.summary()
|
||||
data["title_segments"] = self.title_segments
|
||||
data["segments"] = self.segments
|
||||
data["hits"] = self.hits
|
||||
return {"block": data, "also": [b.summary() for b in self.also]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
query: str
|
||||
groups: list[Group]
|
||||
total_groups: int
|
||||
total_blocks: int
|
||||
offset: int
|
||||
limit: int
|
||||
truncated: bool
|
||||
elapsed_ms: float
|
||||
|
||||
@property
|
||||
def next_offset(self) -> int | None:
|
||||
nxt = self.offset + len(self.groups)
|
||||
return nxt if nxt < self.total_groups else None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""JSON contract shared by the Web API, the CLI (--json) and the widget."""
|
||||
return {
|
||||
"query": self.query,
|
||||
"total_groups": self.total_groups,
|
||||
"total_blocks": self.total_blocks,
|
||||
"offset": self.offset,
|
||||
"limit": self.limit,
|
||||
"next_offset": self.next_offset,
|
||||
"truncated": self.truncated,
|
||||
"elapsed_ms": round(self.elapsed_ms, 1),
|
||||
"results": [g.to_dict() for g in self.groups],
|
||||
}
|
||||
|
||||
|
||||
BlockSource = Iterable[Block] | Callable[[], Iterable[Block]]
|
||||
|
||||
|
||||
class SearchEngine:
|
||||
"""Holds blocks in memory and searches them.
|
||||
|
||||
`source` is either an iterable of blocks or a callable returning one. With a callable and
|
||||
`reload_check` (a callable returning any comparable token, e.g. the newest file mtime),
|
||||
the blocks are reloaded automatically when the token changes; the check runs at most
|
||||
every `reload_interval` seconds.
|
||||
"""
|
||||
|
||||
def __init__(self, source: BlockSource, *, reload_check: Callable[[], Any] | None = None,
|
||||
reload_interval: float = 60.0):
|
||||
self._loader = source if callable(source) else None
|
||||
self._reload_check = reload_check
|
||||
self._reload_interval = reload_interval
|
||||
self._lock = threading.Lock()
|
||||
self._token = reload_check() if reload_check else None
|
||||
self._checked_at = time.monotonic()
|
||||
self._blocks: list[Block] = list(source() if callable(source) else source)
|
||||
|
||||
@property
|
||||
def blocks(self) -> list[Block]:
|
||||
self._maybe_reload()
|
||||
return self._blocks
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload blocks from the loader (no-op for a static block list)."""
|
||||
if self._loader is None:
|
||||
return
|
||||
blocks = list(self._loader())
|
||||
with self._lock:
|
||||
self._blocks = blocks
|
||||
self._token = self._reload_check() if self._reload_check else None
|
||||
self._checked_at = time.monotonic()
|
||||
|
||||
def _maybe_reload(self) -> None:
|
||||
if self._loader is None or self._reload_check is None:
|
||||
return
|
||||
if time.monotonic() - self._checked_at < self._reload_interval:
|
||||
return
|
||||
self._checked_at = time.monotonic()
|
||||
token = self._reload_check()
|
||||
if token != self._token:
|
||||
self.reload()
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str | Query,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
predicate: Callable[[Block], bool] | None = None,
|
||||
group_by: Callable[[Block], Hashable | None] | None = None,
|
||||
sort_key: Callable[[Block], Any] | None = None,
|
||||
reverse: bool = False,
|
||||
offset: int = 0,
|
||||
limit: int | None = 30,
|
||||
time_budget: float | None = 1.5,
|
||||
snippet_chars: int | None = 700,
|
||||
context: int = 120,
|
||||
) -> SearchResult:
|
||||
"""Search all blocks.
|
||||
|
||||
filters {meta_key: value or list of values}; blocks must match all keys.
|
||||
predicate extra filter function on blocks.
|
||||
group_by returns a group key per block (None = block forms its own group). Each group
|
||||
is shown once; its first block after sorting is the primary one.
|
||||
sort_key sort matching blocks (default: original order); `reverse` for descending.
|
||||
limit groups per page (None = all). `offset` counts groups.
|
||||
time_budget seconds for the whole search; on overrun the result is partial and
|
||||
`truncated` is True.
|
||||
snippet_chars shorten long texts to windows around the hits (None = full text).
|
||||
"""
|
||||
started = time.monotonic()
|
||||
q = query if isinstance(query, Query) else compile_query(query)
|
||||
deadline = started + time_budget if time_budget else None
|
||||
candidates = self._filter(self.blocks, filters, predicate)
|
||||
|
||||
matched: list[Block] = []
|
||||
truncated = False
|
||||
for block in candidates:
|
||||
try:
|
||||
if q.matches(f"{block.title}\n{block.text}" if block.title else block.text,
|
||||
deadline=deadline):
|
||||
matched.append(block)
|
||||
except QueryTimeout:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
if sort_key is not None:
|
||||
matched.sort(key=sort_key, reverse=reverse)
|
||||
elif reverse:
|
||||
matched.reverse()
|
||||
|
||||
grouped: dict[Hashable, list[Block]] = {}
|
||||
for i, block in enumerate(matched):
|
||||
key = group_by(block) if group_by else None
|
||||
grouped.setdefault(key if key is not None else ("__block__", i), []).append(block)
|
||||
group_items = list(grouped.items())
|
||||
|
||||
page = group_items[offset:offset + limit] if limit is not None else group_items[offset:]
|
||||
groups = []
|
||||
for key, members in page:
|
||||
primary = members[0]
|
||||
try:
|
||||
spans = q.spans(primary.text, deadline=deadline)
|
||||
title_spans = q.spans(primary.title, deadline=deadline) if primary.title else []
|
||||
except QueryTimeout:
|
||||
spans, title_spans, truncated = [], [], True
|
||||
groups.append(Group(
|
||||
key=key,
|
||||
block=primary,
|
||||
title_segments=segments(primary.title, title_spans),
|
||||
segments=segments(primary.text, spans, max_chars=snippet_chars, context=context),
|
||||
hits=len(spans),
|
||||
also=members[1:],
|
||||
))
|
||||
|
||||
return SearchResult(
|
||||
query=q.text,
|
||||
groups=groups,
|
||||
total_groups=len(group_items),
|
||||
total_blocks=len(matched),
|
||||
offset=offset,
|
||||
limit=limit if limit is not None else len(group_items),
|
||||
truncated=truncated,
|
||||
elapsed_ms=(time.monotonic() - started) * 1000,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _filter(blocks, filters, predicate):
|
||||
conditions = []
|
||||
for key, value in (filters or {}).items():
|
||||
if value is None or value == "" or value == []:
|
||||
continue
|
||||
allowed = set(value) if isinstance(value, (list, tuple, set, frozenset)) else {value}
|
||||
conditions.append((key, allowed))
|
||||
for block in blocks:
|
||||
if any(block.meta.get(k) not in allowed for k, allowed in conditions):
|
||||
continue
|
||||
if predicate is not None and not predicate(block):
|
||||
continue
|
||||
yield block
|
||||
240
src/mdgrep/markdown.py
Normal file
240
src/mdgrep/markdown.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""Split Markdown files into searchable blocks.
|
||||
|
||||
Split modes:
|
||||
file one block per file
|
||||
heading one block per section (heading + content up to the next heading)
|
||||
list one block per top-level list item (with its indented continuation lines);
|
||||
text outside lists becomes paragraph blocks
|
||||
paragraph one block per blank-line separated paragraph (list items count as paragraphs)
|
||||
|
||||
Every block gets `meta["section"]` (nearest preceding heading text), `meta["kind"]`
|
||||
("file", "section", "item" or "paragraph") and `meta["markdown"]` (the raw source).
|
||||
The searchable `text` is plain text with Markdown syntax removed. In `file` and `heading`
|
||||
mode the heading becomes the block title (and is removed from the text); list items and
|
||||
paragraphs have no title.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from .engine import Block
|
||||
|
||||
__all__ = ["load_markdown", "split_markdown", "markdown_to_text", "slugify", "SPLIT_MODES"]
|
||||
|
||||
SPLIT_MODES = ("file", "heading", "list", "paragraph")
|
||||
|
||||
_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
||||
_FENCE = re.compile(r"^\s*(```|~~~)")
|
||||
_LIST_ITEM = re.compile(r"^([-*+]|\d{1,9}[.)])\s+")
|
||||
_HR = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
|
||||
|
||||
|
||||
def load_markdown(paths: str | Path | Iterable[str | Path], *, split: str = "paragraph",
|
||||
meta: Callable[[Path], dict] | None = None, encoding: str = "utf-8",
|
||||
pattern: str = "*.md", relative_to: str | Path | None = None) -> list[Block]:
|
||||
"""Load Markdown files (directories are searched recursively for `pattern`).
|
||||
|
||||
`meta(path)` may return a dict merged into every block's meta of that file.
|
||||
`relative_to` makes `Block.source`/`Block.id` relative to that directory (recommended for
|
||||
web use, so no server paths are exposed).
|
||||
"""
|
||||
base = Path(relative_to).resolve() if relative_to is not None else None
|
||||
blocks: list[Block] = []
|
||||
for path in _expand(paths, pattern):
|
||||
text = path.read_text(encoding=encoding)
|
||||
extra = meta(path) if meta else {}
|
||||
source = path.resolve().relative_to(base).as_posix() if base else str(path)
|
||||
blocks.extend(split_markdown(text, source=source, split=split, meta=extra))
|
||||
return blocks
|
||||
|
||||
|
||||
def _expand(paths, pattern):
|
||||
if isinstance(paths, (str, Path)):
|
||||
paths = [paths]
|
||||
files = []
|
||||
for p in map(Path, paths):
|
||||
if p.is_dir():
|
||||
files.extend(sorted(p.rglob(pattern)))
|
||||
else:
|
||||
files.append(p)
|
||||
return files
|
||||
|
||||
|
||||
def split_markdown(text: str, *, source: str = "", split: str = "paragraph",
|
||||
meta: dict | None = None) -> list[Block]:
|
||||
"""Split one Markdown document into blocks (see module docstring for modes)."""
|
||||
if split not in SPLIT_MODES:
|
||||
raise ValueError(f"split must be one of {SPLIT_MODES}, got {split!r}")
|
||||
meta = meta or {}
|
||||
lines = text.splitlines()
|
||||
raw_blocks = {
|
||||
"file": _split_file,
|
||||
"heading": _split_heading,
|
||||
"list": _split_list,
|
||||
"paragraph": _split_paragraph,
|
||||
}[split](lines)
|
||||
|
||||
blocks = []
|
||||
for start, end, kind, section, title, anchor in raw_blocks:
|
||||
raw = "\n".join(lines[start:end]).strip("\n")
|
||||
plain = markdown_to_text(raw)
|
||||
if title and plain.startswith(title):
|
||||
plain = plain[len(title):].lstrip("\n")
|
||||
if not plain.strip() and not title:
|
||||
continue
|
||||
block_meta = {**meta, "section": section, "kind": kind, "markdown": raw}
|
||||
blocks.append(Block(id=f"{source}:{start + 1}", text=plain, title=title, anchor=anchor,
|
||||
source=source, line=start + 1, meta=block_meta))
|
||||
return blocks
|
||||
|
||||
|
||||
# ── Line classification ──────────────────────────────────────────────────
|
||||
|
||||
def _scan(lines):
|
||||
"""Yield (index, line, in_fence, heading_match) for every line."""
|
||||
in_fence = False
|
||||
for i, line in enumerate(lines):
|
||||
if _FENCE.match(line):
|
||||
yield i, line, True, None
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
heading = None if in_fence else _HEADING.match(line)
|
||||
yield i, line, in_fence, heading
|
||||
|
||||
|
||||
def _split_file(lines):
|
||||
title = ""
|
||||
for _, _, in_fence, heading in _scan(lines):
|
||||
if heading:
|
||||
title = heading.group(2)
|
||||
break
|
||||
return [(0, len(lines), "file", "", title, "")]
|
||||
|
||||
|
||||
def _split_heading(lines):
|
||||
out = []
|
||||
start, section, title, anchor = 0, "", "", ""
|
||||
for i, _, _, heading in _scan(lines):
|
||||
if heading:
|
||||
if i > start:
|
||||
out.append((start, i, "section", section, title, anchor))
|
||||
start, section = i, heading.group(2)
|
||||
title, anchor = section, slugify(section)
|
||||
out.append((start, len(lines), "section", section, title, anchor))
|
||||
return out
|
||||
|
||||
|
||||
def _split_list(lines):
|
||||
out = []
|
||||
section = ""
|
||||
cur = None # [start, kind]
|
||||
last_content = -1 # last non-blank line of the current block
|
||||
|
||||
def close(end):
|
||||
nonlocal cur
|
||||
if cur is not None:
|
||||
out.append((cur[0], end, cur[1], section, "", ""))
|
||||
cur = None
|
||||
|
||||
prev_blank = True
|
||||
for i, line, in_fence, heading in _scan(lines):
|
||||
if heading:
|
||||
close(last_content + 1)
|
||||
section = heading.group(2)
|
||||
prev_blank = True
|
||||
continue
|
||||
if not line.strip() and not in_fence:
|
||||
if cur is not None and cur[1] == "paragraph":
|
||||
close(last_content + 1)
|
||||
prev_blank = True
|
||||
continue
|
||||
if in_fence:
|
||||
if cur is None:
|
||||
cur = [i, "paragraph"]
|
||||
elif _LIST_ITEM.match(line):
|
||||
close(last_content + 1)
|
||||
cur = [i, "item"]
|
||||
elif _HR.match(line):
|
||||
close(last_content + 1)
|
||||
prev_blank = True
|
||||
continue
|
||||
elif cur is not None and cur[1] == "item" and (line[:1] in (" ", "\t") or not prev_blank):
|
||||
pass # indented or lazy continuation of the item
|
||||
elif cur is None or cur[1] == "item":
|
||||
close(last_content + 1)
|
||||
cur = [i, "paragraph"]
|
||||
last_content = i
|
||||
prev_blank = False
|
||||
close(last_content + 1)
|
||||
return out
|
||||
|
||||
|
||||
def _split_paragraph(lines):
|
||||
out = []
|
||||
section = ""
|
||||
start = None
|
||||
for i, line, in_fence, heading in _scan(lines):
|
||||
if heading:
|
||||
if start is not None:
|
||||
out.append((start, i, "paragraph", section, "", ""))
|
||||
start = None
|
||||
section = heading.group(2)
|
||||
continue
|
||||
if not line.strip() and not in_fence:
|
||||
if start is not None:
|
||||
out.append((start, i, "paragraph", section, "", ""))
|
||||
start = None
|
||||
continue
|
||||
if _HR.match(line) and not in_fence:
|
||||
continue
|
||||
if start is None:
|
||||
start = i
|
||||
if start is not None:
|
||||
out.append((start, len(lines), "paragraph", section, "", ""))
|
||||
return out
|
||||
|
||||
|
||||
# ── Markdown → plain text ────────────────────────────────────────────────
|
||||
|
||||
_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
|
||||
_LINK = re.compile(r"\[([^\]]+)\]\((?:[^()\s]|\([^()\s]*\))+(?:\s+\"[^\"]*\")?\)")
|
||||
_REF_LINK = re.compile(r"\[([^\]]+)\]\[[^\]]*\]")
|
||||
_AUTOLINK = re.compile(r"<(https?://[^>]+)>")
|
||||
_BOLD = re.compile(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1")
|
||||
_ITALIC_STAR = re.compile(r"(?<!\*)\*(?=\S)([^*]+?)(?<=\S)\*(?!\*)")
|
||||
_ITALIC_UNDER = re.compile(r"(?<![\w_])_(?=\S)([^_]+?)(?<=\S)_(?![\w_])")
|
||||
_STRIKE = re.compile(r"~~(.+?)~~")
|
||||
_CODE = re.compile(r"`([^`]*)`")
|
||||
_LINE_MARKER = re.compile(r"^\s*(?:#{1,6}\s+|>\s?|(?:[-*+]|\d{1,9}[.)])\s+)")
|
||||
|
||||
|
||||
def markdown_to_text(md: str) -> str:
|
||||
"""Remove Markdown syntax, keep line structure (blank lines collapsed)."""
|
||||
out_lines = []
|
||||
for line in md.splitlines():
|
||||
if _FENCE.match(line) or _HR.match(line):
|
||||
continue
|
||||
line = _LINE_MARKER.sub("", line).strip()
|
||||
line = _IMAGE.sub(r"\1", line)
|
||||
line = _LINK.sub(r"\1", line)
|
||||
line = _REF_LINK.sub(r"\1", line)
|
||||
line = _AUTOLINK.sub(r"\1", line)
|
||||
line = _CODE.sub(r"\1", line)
|
||||
line = _BOLD.sub(r"\2", line)
|
||||
line = _ITALIC_STAR.sub(r"\1", line)
|
||||
line = _ITALIC_UNDER.sub(r"\1", line)
|
||||
line = _STRIKE.sub(r"\1", line)
|
||||
if line or (out_lines and out_lines[-1]):
|
||||
out_lines.append(line)
|
||||
return "\n".join(out_lines).strip()
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""GitHub-style heading anchor."""
|
||||
text = markdown_to_text(text).lower()
|
||||
text = re.sub(r"[^\w\- ]", "", text)
|
||||
return re.sub(r" ", "-", text.strip())
|
||||
|
||||
328
src/mdgrep/query.py
Normal file
328
src/mdgrep/query.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
"""Query language: substrings, phrases, regular expressions, AND/OR/NOT and parentheses.
|
||||
|
||||
Syntax (all matching is case-insensitive):
|
||||
|
||||
word substring match ("rust" also matches "Rustacean")
|
||||
"a phrase" exact phrase, any whitespace between the words
|
||||
/regex/ regular expression (write \\/ for a literal slash)
|
||||
a b, a AND b both (also: UND, &, &&)
|
||||
a OR b either (also: ODER, |, ||)
|
||||
NOT a, -a, !a negation (also: NICHT)
|
||||
( ... ) grouping
|
||||
|
||||
Precedence: NOT binds tighter than AND, AND tighter than OR.
|
||||
Operator keywords are only recognized in upper case; "and" is a normal search word.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import regex
|
||||
|
||||
__all__ = [
|
||||
"Query",
|
||||
"QuerySyntaxError",
|
||||
"QueryTimeout",
|
||||
"compile_query",
|
||||
]
|
||||
|
||||
_FLAGS = regex.IGNORECASE | regex.UNICODE
|
||||
|
||||
_KEYWORDS = {
|
||||
"AND": "AND", "UND": "AND",
|
||||
"OR": "OR", "ODER": "OR",
|
||||
"NOT": "NOT", "NICHT": "NOT",
|
||||
}
|
||||
|
||||
|
||||
class QuerySyntaxError(ValueError):
|
||||
"""Invalid query. `code` is a stable identifier, `position` a 0-based offset into the query."""
|
||||
|
||||
def __init__(self, message: str, position: int, code: str):
|
||||
super().__init__(f"{message} (at position {position})")
|
||||
self.message = message
|
||||
self.position = position
|
||||
self.code = code
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"code": self.code, "message": self.message, "position": self.position}
|
||||
|
||||
|
||||
class QueryTimeout(TimeoutError):
|
||||
"""Matching exceeded the time limit (e.g. a catastrophic regular expression)."""
|
||||
|
||||
|
||||
# ── Tokens ────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class _Token:
|
||||
kind: str # LPAREN RPAREN AND OR NOT TERM
|
||||
pos: int
|
||||
value: str = ""
|
||||
term_kind: str = "" # word | phrase | regex
|
||||
|
||||
|
||||
def _tokenize(text: str) -> list[_Token]:
|
||||
tokens: list[_Token] = []
|
||||
i, n = 0, len(text)
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if c.isspace():
|
||||
i += 1
|
||||
elif c in "()":
|
||||
tokens.append(_Token("LPAREN" if c == "(" else "RPAREN", i))
|
||||
i += 1
|
||||
elif c in "&|":
|
||||
kind = "AND" if c == "&" else "OR"
|
||||
tokens.append(_Token(kind, i))
|
||||
i += 2 if text[i:i + 2] == c * 2 else 1
|
||||
elif c == "!" or (c == "-" and i + 1 < n and not text[i + 1].isspace()):
|
||||
tokens.append(_Token("NOT", i))
|
||||
i += 1
|
||||
elif c == '"':
|
||||
j = i + 1
|
||||
buf = []
|
||||
while j < n and text[j] != '"':
|
||||
if text[j] == "\\" and j + 1 < n:
|
||||
j += 1
|
||||
buf.append(text[j])
|
||||
j += 1
|
||||
if j >= n:
|
||||
raise QuerySyntaxError("Unterminated phrase", i, "unterminated_phrase")
|
||||
tokens.append(_Token("TERM", i, "".join(buf), "phrase"))
|
||||
i = j + 1
|
||||
elif c == "/":
|
||||
j = i + 1
|
||||
buf = []
|
||||
while j < n and text[j] != "/":
|
||||
if text[j] == "\\" and j + 1 < n:
|
||||
if text[j + 1] == "/":
|
||||
buf.append("/")
|
||||
else:
|
||||
buf.append(text[j:j + 2])
|
||||
j += 2
|
||||
continue
|
||||
buf.append(text[j])
|
||||
j += 1
|
||||
if j >= n:
|
||||
raise QuerySyntaxError("Unterminated regular expression", i, "unterminated_regex")
|
||||
tokens.append(_Token("TERM", i, "".join(buf), "regex"))
|
||||
i = j + 1
|
||||
else:
|
||||
j = i
|
||||
while j < n and not text[j].isspace() and text[j] not in '()"':
|
||||
j += 1
|
||||
word = text[i:j]
|
||||
if word in _KEYWORDS:
|
||||
tokens.append(_Token(_KEYWORDS[word], i))
|
||||
else:
|
||||
tokens.append(_Token("TERM", i, word, "word"))
|
||||
i = j
|
||||
return tokens
|
||||
|
||||
|
||||
# ── AST ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Term:
|
||||
kind: str # word | phrase | regex
|
||||
source: str
|
||||
pos: int
|
||||
pattern: regex.Pattern = field(repr=False)
|
||||
|
||||
def evaluate(self, text: str, deadline: float | None) -> bool:
|
||||
return self.pattern.search(text, timeout=_remaining(deadline)) is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Not:
|
||||
child: object
|
||||
|
||||
def evaluate(self, text, deadline):
|
||||
return not self.child.evaluate(text, deadline)
|
||||
|
||||
|
||||
@dataclass
|
||||
class And:
|
||||
children: list
|
||||
|
||||
def evaluate(self, text, deadline):
|
||||
return all(c.evaluate(text, deadline) for c in self.children)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Or:
|
||||
children: list
|
||||
|
||||
def evaluate(self, text, deadline):
|
||||
return any(c.evaluate(text, deadline) for c in self.children)
|
||||
|
||||
|
||||
def _remaining(deadline: float | None) -> float | None:
|
||||
if deadline is None:
|
||||
return None
|
||||
left = deadline - time.monotonic()
|
||||
if left <= 0:
|
||||
raise QueryTimeout("Search time limit exceeded")
|
||||
return left
|
||||
|
||||
|
||||
def _compile_term(token: _Token) -> Term:
|
||||
if token.term_kind == "regex":
|
||||
if not token.value:
|
||||
raise QuerySyntaxError("Empty regular expression", token.pos, "invalid_regex")
|
||||
source = token.value
|
||||
elif token.term_kind == "phrase":
|
||||
words = token.value.split()
|
||||
if not words:
|
||||
raise QuerySyntaxError("Empty phrase", token.pos, "empty_phrase")
|
||||
source = r"\s+".join(regex.escape(w) for w in words)
|
||||
else:
|
||||
source = regex.escape(token.value)
|
||||
try:
|
||||
pattern = regex.compile(source, _FLAGS)
|
||||
except regex.error as e:
|
||||
raise QuerySyntaxError(f"Invalid regular expression: {e.msg}", token.pos, "invalid_regex") from None
|
||||
return Term(token.term_kind, token.value, token.pos, pattern)
|
||||
|
||||
|
||||
# ── Parser (recursive descent) ────────────────────────────────────────────
|
||||
|
||||
class _Parser:
|
||||
def __init__(self, tokens: list[_Token], length: int):
|
||||
self.tokens = tokens
|
||||
self.i = 0
|
||||
self.length = length
|
||||
self.terms: list[tuple[Term, bool]] = [] # (term, negated)
|
||||
|
||||
def peek(self) -> _Token | None:
|
||||
return self.tokens[self.i] if self.i < len(self.tokens) else None
|
||||
|
||||
def take(self) -> _Token:
|
||||
tok = self.tokens[self.i]
|
||||
self.i += 1
|
||||
return tok
|
||||
|
||||
def end_pos(self) -> int:
|
||||
return self.length
|
||||
|
||||
def parse(self):
|
||||
if not self.tokens:
|
||||
raise QuerySyntaxError("Empty query", 0, "empty_query")
|
||||
node = self.parse_or(negated=False)
|
||||
tok = self.peek()
|
||||
if tok is not None:
|
||||
if tok.kind == "RPAREN":
|
||||
raise QuerySyntaxError("Unexpected closing parenthesis", tok.pos, "unexpected_paren")
|
||||
raise QuerySyntaxError("Unexpected operator", tok.pos, "unexpected_token")
|
||||
return node
|
||||
|
||||
def parse_or(self, negated):
|
||||
children = [self.parse_and(negated)]
|
||||
while (tok := self.peek()) is not None and tok.kind == "OR":
|
||||
self.take()
|
||||
children.append(self.parse_and(negated))
|
||||
return children[0] if len(children) == 1 else Or(children)
|
||||
|
||||
def parse_and(self, negated):
|
||||
children = [self.parse_not(negated)]
|
||||
while (tok := self.peek()) is not None and tok.kind in ("AND", "NOT", "TERM", "LPAREN"):
|
||||
if tok.kind == "AND":
|
||||
self.take()
|
||||
children.append(self.parse_not(negated))
|
||||
return children[0] if len(children) == 1 else And(children)
|
||||
|
||||
def parse_not(self, negated):
|
||||
tok = self.peek()
|
||||
if tok is not None and tok.kind == "NOT":
|
||||
self.take()
|
||||
return Not(self.parse_not(not negated))
|
||||
return self.parse_primary(negated)
|
||||
|
||||
def parse_primary(self, negated):
|
||||
tok = self.peek()
|
||||
if tok is None:
|
||||
raise QuerySyntaxError("Search term expected", self.end_pos(), "missing_operand")
|
||||
if tok.kind == "TERM":
|
||||
self.take()
|
||||
term = _compile_term(tok)
|
||||
self.terms.append((term, negated))
|
||||
return term
|
||||
if tok.kind == "LPAREN":
|
||||
self.take()
|
||||
if (nxt := self.peek()) is not None and nxt.kind == "RPAREN":
|
||||
raise QuerySyntaxError("Empty parentheses", tok.pos, "empty_group")
|
||||
node = self.parse_or(negated)
|
||||
close = self.peek()
|
||||
if close is None or close.kind != "RPAREN":
|
||||
raise QuerySyntaxError("Missing closing parenthesis", tok.pos, "unclosed_paren")
|
||||
self.take()
|
||||
return node
|
||||
if tok.kind == "RPAREN":
|
||||
raise QuerySyntaxError("Unexpected closing parenthesis", tok.pos, "unexpected_paren")
|
||||
raise QuerySyntaxError("Search term expected", tok.pos, "missing_operand")
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
class Query:
|
||||
"""A compiled query. Use `matches()` to test a text and `spans()` for highlighting."""
|
||||
|
||||
def __init__(self, text: str, root, terms: list[tuple[Term, bool]]):
|
||||
self.text = text
|
||||
self._root = root
|
||||
self._terms = terms
|
||||
|
||||
@property
|
||||
def positive_terms(self) -> list[Term]:
|
||||
"""Terms that contribute to a match (not under an odd number of NOTs)."""
|
||||
return [t for t, negated in self._terms if not negated]
|
||||
|
||||
def matches(self, text: str, timeout: float | None = None, *, deadline: float | None = None) -> bool:
|
||||
"""True if `text` satisfies the query. Raises QueryTimeout when the time limit is exceeded."""
|
||||
if deadline is None and timeout is not None:
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
return self._root.evaluate(text, deadline)
|
||||
except TimeoutError as e:
|
||||
raise QueryTimeout(str(e) or "Search time limit exceeded") from None
|
||||
|
||||
def spans(self, text: str, timeout: float | None = None, *, deadline: float | None = None) -> list[tuple[int, int]]:
|
||||
"""Sorted, merged (start, end) ranges of all positive term matches in `text`."""
|
||||
if deadline is None and timeout is not None:
|
||||
deadline = time.monotonic() + timeout
|
||||
found = []
|
||||
try:
|
||||
for term in self.positive_terms:
|
||||
for m in term.pattern.finditer(text, timeout=_remaining(deadline)):
|
||||
if m.end() > m.start():
|
||||
found.append((m.start(), m.end()))
|
||||
except TimeoutError as e:
|
||||
raise QueryTimeout(str(e) or "Search time limit exceeded") from None
|
||||
return _merge(found)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Query({self.text!r})"
|
||||
|
||||
|
||||
def _merge(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
merged: list[list[int]] = []
|
||||
for start, end in sorted(spans):
|
||||
if merged and start <= merged[-1][1]:
|
||||
merged[-1][1] = max(merged[-1][1], end)
|
||||
else:
|
||||
merged.append([start, end])
|
||||
return [(s, e) for s, e in merged]
|
||||
|
||||
|
||||
def compile_query(text: str, *, max_length: int = 500, max_terms: int = 50) -> Query:
|
||||
"""Parse and compile a query string. Raises QuerySyntaxError on invalid input."""
|
||||
if len(text) > max_length:
|
||||
raise QuerySyntaxError(f"Query longer than {max_length} characters", max_length, "too_long")
|
||||
parser = _Parser(_tokenize(text), len(text))
|
||||
root = parser.parse()
|
||||
if len(parser.terms) > max_terms:
|
||||
raise QuerySyntaxError(f"More than {max_terms} search terms", 0, "too_many_terms")
|
||||
return Query(text, root, parser.terms)
|
||||
93
src/mdgrep/snippets.py
Normal file
93
src/mdgrep/snippets.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Turn a text plus match spans into display segments, shortened around the hits if needed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["segments", "ELLIPSIS"]
|
||||
|
||||
ELLIPSIS = " … "
|
||||
|
||||
|
||||
def segments(text: str, spans: list[tuple[int, int]], *, max_chars: int | None = None,
|
||||
context: int = 120) -> list[dict]:
|
||||
"""Split `text` into [{"t": str, "hit": bool}, ...].
|
||||
|
||||
If `max_chars` is set and the text is longer, only windows of `context` characters around
|
||||
the hits are kept (joined by ELLIPSIS segments). Without hits the beginning of the text is shown.
|
||||
"""
|
||||
if max_chars is None or len(text) <= max_chars:
|
||||
return _split(text, spans, 0, len(text))
|
||||
|
||||
windows = _windows(text, spans, context, max_chars)
|
||||
out: list[dict] = []
|
||||
for i, (start, end) in enumerate(windows):
|
||||
if start > 0 or i > 0:
|
||||
out.append({"t": ELLIPSIS, "hit": False})
|
||||
out.extend(_split(text, spans, start, end))
|
||||
if windows and windows[-1][1] < len(text):
|
||||
out.append({"t": ELLIPSIS, "hit": False})
|
||||
return out
|
||||
|
||||
|
||||
def _windows(text, spans, context, max_chars):
|
||||
if not spans:
|
||||
return [(0, _snap_end(text, max_chars))]
|
||||
windows: list[list[int]] = []
|
||||
budget = max_chars
|
||||
for start, end in spans:
|
||||
w_start = _snap_start(text, max(0, start - context))
|
||||
w_end = _snap_end(text, min(len(text), end + context))
|
||||
if windows and w_start <= windows[-1][1]:
|
||||
grow = w_end - windows[-1][1]
|
||||
if grow > 0:
|
||||
if grow > budget:
|
||||
break
|
||||
windows[-1][1] = w_end
|
||||
budget -= grow
|
||||
continue
|
||||
size = w_end - w_start
|
||||
if size > budget:
|
||||
if not windows: # always show the first hit
|
||||
windows.append([w_start, w_end])
|
||||
break
|
||||
windows.append([w_start, w_end])
|
||||
budget -= size
|
||||
return [(s, e) for s, e in windows]
|
||||
|
||||
|
||||
def _snap_start(text, pos):
|
||||
"""Move a window start back to the beginning of a word."""
|
||||
if pos <= 0:
|
||||
return 0
|
||||
limit = max(0, pos - 20)
|
||||
i = pos
|
||||
while i > limit and not text[i - 1].isspace():
|
||||
i -= 1
|
||||
return i if i > limit else pos
|
||||
|
||||
|
||||
def _snap_end(text, pos):
|
||||
"""Move a window end forward to the end of a word."""
|
||||
n = len(text)
|
||||
if pos >= n:
|
||||
return n
|
||||
limit = min(n, pos + 20)
|
||||
i = pos
|
||||
while i < limit and not text[i].isspace():
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def _split(text, spans, start, end):
|
||||
out = []
|
||||
pos = start
|
||||
for s, e in spans:
|
||||
if e <= start or s >= end:
|
||||
continue
|
||||
s, e = max(s, start), min(e, end)
|
||||
if s > pos:
|
||||
out.append({"t": text[pos:s], "hit": False})
|
||||
out.append({"t": text[s:e], "hit": True})
|
||||
pos = e
|
||||
if pos < end:
|
||||
out.append({"t": text[pos:end], "hit": False})
|
||||
return out
|
||||
79
src/mdgrep/static/mdgrep-widget.css
Normal file
79
src/mdgrep/static/mdgrep-widget.css
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/* mdgrep search widget v0.1.0 – MIT License
|
||||
*
|
||||
* Theming: set these custom properties on :root or any ancestor
|
||||
* --mdgrep-text --mdgrep-muted --mdgrep-surface --mdgrep-border
|
||||
* --mdgrep-accent --mdgrep-mark-bg --mdgrep-mark-text --mdgrep-error --mdgrep-radius
|
||||
*/
|
||||
|
||||
.mdgrep { color: var(--mdgrep-text, #1a1d2e); }
|
||||
.mdgrep [hidden] { display: none !important; }
|
||||
|
||||
.mdgrep-form { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
|
||||
.mdgrep-input,
|
||||
.mdgrep-filter {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--mdgrep-surface, #fff);
|
||||
border: 1px solid var(--mdgrep-border, #d5d8e3);
|
||||
border-radius: var(--mdgrep-radius, 0.5rem);
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.mdgrep-input { flex: 1 1 16rem; min-width: 0; }
|
||||
.mdgrep-filter { flex: 0 0 auto; cursor: pointer; }
|
||||
.mdgrep-input:focus,
|
||||
.mdgrep-filter:focus { outline: 2px solid var(--mdgrep-accent, #4a7cff); outline-offset: 1px; }
|
||||
|
||||
.mdgrep-help { margin: 0.75rem 0 0; font-size: 0.875em; color: var(--mdgrep-muted, #6b7394); }
|
||||
.mdgrep-help summary { cursor: pointer; width: max-content; }
|
||||
.mdgrep-help table { border-collapse: collapse; margin: 0.5rem 0; background: none; border: none; }
|
||||
.mdgrep-help td { border: none; padding: 0.15rem 1rem 0.15rem 0; vertical-align: top; }
|
||||
.mdgrep-help code { white-space: nowrap; }
|
||||
|
||||
.mdgrep-status { margin: 0.75rem 0; min-height: 1.4em; font-size: 0.875em; color: var(--mdgrep-muted, #6b7394); }
|
||||
.mdgrep-status-error { color: var(--mdgrep-error, #c0392b); }
|
||||
.mdgrep-status-warn { color: var(--mdgrep-error, #c0392b); }
|
||||
.mdgrep-busy .mdgrep-results { opacity: 0.6; }
|
||||
|
||||
.mdgrep-results { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
.mdgrep-card {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--mdgrep-surface, #fff);
|
||||
border: 1px solid var(--mdgrep-border, #d5d8e3);
|
||||
border-radius: var(--mdgrep-radius, 0.5rem);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mdgrep-card:hover { border-color: var(--mdgrep-accent, #4a7cff); }
|
||||
|
||||
.mdgrep-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem;
|
||||
font-size: 0.8em; color: var(--mdgrep-muted, #6b7394); }
|
||||
.mdgrep-badge { padding: 0 0.45rem; border: 1px solid var(--mdgrep-border, #d5d8e3);
|
||||
border-radius: 999px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.mdgrep-date { font-style: italic; }
|
||||
|
||||
.mdgrep-title { display: block; font-weight: 700; margin-bottom: 0.3rem; text-decoration: none; }
|
||||
.mdgrep-snippet { display: block; white-space: pre-line; color: inherit; text-decoration: none; font-size: 0.925em; }
|
||||
a.mdgrep-snippet:visited { color: inherit; }
|
||||
.mdgrep-card a:hover { text-decoration: none; }
|
||||
|
||||
.mdgrep mark { background: var(--mdgrep-mark-bg, #ffe58a); color: var(--mdgrep-mark-text, inherit);
|
||||
border-radius: 2px; padding: 0 1px; }
|
||||
|
||||
.mdgrep-also { margin-top: 0.5rem; font-size: 0.8em; color: var(--mdgrep-muted, #6b7394); }
|
||||
.mdgrep-also summary { cursor: pointer; width: max-content; }
|
||||
.mdgrep-also ul { margin: 0.35rem 0 0; padding-left: 1.25rem; }
|
||||
|
||||
.mdgrep-more {
|
||||
display: block;
|
||||
margin: 1rem auto 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--mdgrep-surface, #fff);
|
||||
border: 1px solid var(--mdgrep-border, #d5d8e3);
|
||||
border-radius: var(--mdgrep-radius, 0.5rem);
|
||||
padding: 0.4rem 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mdgrep-more:hover { border-color: var(--mdgrep-accent, #4a7cff); }
|
||||
399
src/mdgrep/static/mdgrep-widget.js
Normal file
399
src/mdgrep/static/mdgrep-widget.js
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
/*! mdgrep search widget v0.1.0 – MIT License
|
||||
*
|
||||
* Usage:
|
||||
* <link rel="stylesheet" href="mdgrep-widget.css">
|
||||
* <div id="search"></div>
|
||||
* <script src="mdgrep-widget.js"></script>
|
||||
* <script>
|
||||
* MdGrep.mount(document.getElementById('search'), {
|
||||
* endpoint: 'api/search',
|
||||
* labels: 'de',
|
||||
* filters: [{ name: 'lang', label: 'Sprache',
|
||||
* options: [{ value: '', label: 'Alle' }, { value: 'de', label: 'Deutsch' }] }],
|
||||
* });
|
||||
* </script>
|
||||
*
|
||||
* Or without script: <div data-mdgrep data-endpoint="api/search" data-labels="de"></div>
|
||||
* (filters as JSON in data-filters).
|
||||
*
|
||||
* Expects the JSON contract of mdgrep's Web API (SearchResult.to_dict()).
|
||||
* All result text is inserted with textContent – no HTML from the server is interpreted.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var LABELS = {
|
||||
en: {
|
||||
placeholder: 'Search… e.g. (rust OR go) AND NOT crypto',
|
||||
inputLabel: 'Search query',
|
||||
help: 'Search syntax',
|
||||
helpRows: [
|
||||
['word', 'part of a word, upper/lower case ignored'],
|
||||
['"exact phrase"', 'words in this order'],
|
||||
['/regex/', 'regular expression, e.g. /llms?\\b/'],
|
||||
['a b a AND b', 'both terms (also &)'],
|
||||
['a OR b', 'at least one term (also |)'],
|
||||
['NOT a -a !a', 'term must not occur'],
|
||||
['( … )', 'grouping, e.g. (a OR b) AND c'],
|
||||
],
|
||||
minChars: function (n) { return 'Enter at least ' + n + ' characters.'; },
|
||||
searching: 'Searching…',
|
||||
none: 'No matches.',
|
||||
count: function (groups, blocks, ms) {
|
||||
return groups + (groups === 1 ? ' result' : ' results') +
|
||||
(blocks !== groups ? ' (' + blocks + ' matches)' : '') + ' · ' + Math.round(ms) + ' ms';
|
||||
},
|
||||
truncated: 'Time limit reached – results are incomplete.',
|
||||
more: 'Load more',
|
||||
alsoIn: function (n) { return 'also in ' + n + (n === 1 ? ' other place' : ' other places'); },
|
||||
errorAt: function (msg, pos) { return msg + ' (position ' + (pos + 1) + ')'; },
|
||||
rateLimited: 'Too many requests – please wait a moment.',
|
||||
failed: 'Search failed. Please try again later.',
|
||||
errors: {
|
||||
empty_query: 'Empty query',
|
||||
empty_phrase: 'Empty phrase',
|
||||
unterminated_phrase: 'Closing quote missing',
|
||||
unterminated_regex: 'Closing slash of the regular expression missing',
|
||||
invalid_regex: 'Invalid regular expression',
|
||||
missing_operand: 'Search term expected',
|
||||
unexpected_paren: 'Unexpected closing parenthesis',
|
||||
unexpected_token: 'Unexpected operator',
|
||||
unclosed_paren: 'Closing parenthesis missing',
|
||||
empty_group: 'Empty parentheses',
|
||||
too_long: 'Query too long',
|
||||
too_many_terms: 'Too many search terms',
|
||||
},
|
||||
},
|
||||
de: {
|
||||
placeholder: 'Suchen… z. B. (rust ODER go) UND NICHT crypto',
|
||||
inputLabel: 'Suchbegriff',
|
||||
help: 'Suchsyntax',
|
||||
helpRows: [
|
||||
['wort', 'Wortbestandteil, Groß-/Kleinschreibung egal'],
|
||||
['"genaue phrase"', 'Wörter in dieser Reihenfolge'],
|
||||
['/regex/', 'regulärer Ausdruck, z. B. /llms?\\b/'],
|
||||
['a b a UND b', 'beide Begriffe (auch AND, &)'],
|
||||
['a ODER b', 'mindestens ein Begriff (auch OR, |)'],
|
||||
['NICHT a -a !a', 'Begriff darf nicht vorkommen (auch NOT)'],
|
||||
['( … )', 'Klammern, z. B. (a ODER b) UND c'],
|
||||
],
|
||||
minChars: function (n) { return 'Bitte mindestens ' + n + ' Zeichen eingeben.'; },
|
||||
searching: 'Suche läuft…',
|
||||
none: 'Keine Treffer.',
|
||||
count: function (groups, blocks, ms) {
|
||||
return groups + (groups === 1 ? ' Ergebnis' : ' Ergebnisse') +
|
||||
(blocks !== groups ? ' (' + blocks + ' Fundstellen)' : '') + ' · ' + Math.round(ms) + ' ms';
|
||||
},
|
||||
truncated: 'Zeitlimit erreicht – die Ergebnisse sind unvollständig.',
|
||||
more: 'Mehr laden',
|
||||
alsoIn: function (n) { return 'auch in ' + n + (n === 1 ? ' weiterem Bericht' : ' weiteren Berichten'); },
|
||||
errorAt: function (msg, pos) { return msg + ' (Position ' + (pos + 1) + ')'; },
|
||||
rateLimited: 'Zu viele Anfragen – bitte einen Moment warten.',
|
||||
failed: 'Die Suche ist fehlgeschlagen. Bitte später erneut versuchen.',
|
||||
errors: {
|
||||
empty_query: 'Leere Suchanfrage',
|
||||
empty_phrase: 'Leere Phrase',
|
||||
unterminated_phrase: 'Schließendes Anführungszeichen fehlt',
|
||||
unterminated_regex: 'Schließender Schrägstrich des regulären Ausdrucks fehlt',
|
||||
invalid_regex: 'Ungültiger regulärer Ausdruck',
|
||||
missing_operand: 'Suchbegriff erwartet',
|
||||
unexpected_paren: 'Unerwartete schließende Klammer',
|
||||
unexpected_token: 'Unerwarteter Operator',
|
||||
unclosed_paren: 'Schließende Klammer fehlt',
|
||||
empty_group: 'Leere Klammern',
|
||||
too_long: 'Suchanfrage zu lang',
|
||||
too_many_terms: 'Zu viele Suchbegriffe',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var DEFAULTS = {
|
||||
endpoint: 'search',
|
||||
filters: [], // [{name, label, options: [{value, label}], value}]
|
||||
labels: 'en', // 'en' | 'de' | object overriding single labels
|
||||
live: true,
|
||||
debounce: 300,
|
||||
minChars: 2,
|
||||
limit: 30,
|
||||
syncUrl: true, // keep q and filters in the page URL (shareable, back button)
|
||||
autofocus: true,
|
||||
help: true,
|
||||
hrefField: 'href', // block.meta[hrefField] is the link target
|
||||
href: null, // function(block) → URL, overrides hrefField
|
||||
dateField: null, // block.meta[dateField] shown in the card header
|
||||
badgeFields: [], // block.meta keys shown as badges
|
||||
formatBadge: null, // function(field, value, block) → text
|
||||
alsoText: null, // function(block) → link text in the "also in" list
|
||||
};
|
||||
|
||||
function el(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function appendSegments(parent, segments) {
|
||||
(segments || []).forEach(function (s) {
|
||||
parent.appendChild(s.hit ? el('mark', null, s.t) : document.createTextNode(s.t));
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLabels(labels) {
|
||||
if (typeof labels === 'string') return LABELS[labels] || LABELS.en;
|
||||
var base = LABELS[(labels && labels.lang) || 'en'] || LABELS.en;
|
||||
var out = {};
|
||||
Object.keys(base).forEach(function (k) { out[k] = base[k]; });
|
||||
Object.keys(labels || {}).forEach(function (k) { out[k] = labels[k]; });
|
||||
return out;
|
||||
}
|
||||
|
||||
function mount(root, options) {
|
||||
var o = {};
|
||||
Object.keys(DEFAULTS).forEach(function (k) { o[k] = DEFAULTS[k]; });
|
||||
Object.keys(options || {}).forEach(function (k) { o[k] = options[k]; });
|
||||
var L = resolveLabels(o.labels);
|
||||
|
||||
// ── DOM ──────────────────────────────────────────────────────────────
|
||||
root.classList.add('mdgrep');
|
||||
root.textContent = '';
|
||||
var form = el('form', 'mdgrep-form');
|
||||
form.setAttribute('role', 'search');
|
||||
var input = el('input', 'mdgrep-input');
|
||||
input.type = 'search';
|
||||
input.name = 'q';
|
||||
input.placeholder = L.placeholder;
|
||||
input.setAttribute('aria-label', L.inputLabel);
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
form.appendChild(input);
|
||||
|
||||
var selects = {};
|
||||
o.filters.forEach(function (f) {
|
||||
var sel = el('select', 'mdgrep-filter');
|
||||
sel.name = f.name;
|
||||
sel.setAttribute('aria-label', f.label || f.name);
|
||||
(f.options || []).forEach(function (opt) {
|
||||
var option = el('option', null, opt.label);
|
||||
option.value = opt.value;
|
||||
sel.appendChild(option);
|
||||
});
|
||||
if (f.value != null) sel.value = f.value;
|
||||
selects[f.name] = sel;
|
||||
form.appendChild(sel);
|
||||
});
|
||||
root.appendChild(form);
|
||||
|
||||
if (o.help) {
|
||||
var help = el('details', 'mdgrep-help');
|
||||
help.appendChild(el('summary', null, L.help));
|
||||
var table = el('table');
|
||||
L.helpRows.forEach(function (row) {
|
||||
var tr = el('tr');
|
||||
var code = el('td');
|
||||
code.appendChild(el('code', null, row[0]));
|
||||
tr.appendChild(code);
|
||||
tr.appendChild(el('td', null, row[1]));
|
||||
table.appendChild(tr);
|
||||
});
|
||||
help.appendChild(table);
|
||||
root.appendChild(help);
|
||||
}
|
||||
|
||||
var status = el('div', 'mdgrep-status');
|
||||
status.setAttribute('aria-live', 'polite');
|
||||
root.appendChild(status);
|
||||
var list = el('ol', 'mdgrep-results');
|
||||
root.appendChild(list);
|
||||
var more = el('button', 'mdgrep-more', L.more);
|
||||
more.type = 'button';
|
||||
more.hidden = true;
|
||||
root.appendChild(more);
|
||||
|
||||
// ── State from URL ───────────────────────────────────────────────────
|
||||
if (o.syncUrl) {
|
||||
var params = new URLSearchParams(global.location.search);
|
||||
if (params.has('q')) input.value = params.get('q');
|
||||
Object.keys(selects).forEach(function (name) {
|
||||
if (params.has(name)) selects[name].value = params.get(name);
|
||||
});
|
||||
}
|
||||
|
||||
var controller = null;
|
||||
var timer = null;
|
||||
var nextOffset = null;
|
||||
|
||||
function filterValues() {
|
||||
var out = {};
|
||||
Object.keys(selects).forEach(function (name) { out[name] = selects[name].value; });
|
||||
return out;
|
||||
}
|
||||
|
||||
function updateUrl(q) {
|
||||
if (!o.syncUrl) return;
|
||||
var url = new URL(global.location.href);
|
||||
if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');
|
||||
var values = filterValues();
|
||||
Object.keys(values).forEach(function (name) {
|
||||
if (values[name]) url.searchParams.set(name, values[name]); else url.searchParams.delete(name);
|
||||
});
|
||||
global.history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
status.textContent = text || '';
|
||||
status.className = 'mdgrep-status' + (kind ? ' mdgrep-status-' + kind : '');
|
||||
}
|
||||
|
||||
function hrefOf(block) {
|
||||
if (o.href) return o.href(block);
|
||||
var meta = block.meta || {};
|
||||
if (meta[o.hrefField]) return meta[o.hrefField];
|
||||
return block.anchor ? '#' + block.anchor : null;
|
||||
}
|
||||
|
||||
function link(block, className) {
|
||||
var href = hrefOf(block);
|
||||
var a = el(href ? 'a' : 'div', className);
|
||||
if (href) a.href = href;
|
||||
return a;
|
||||
}
|
||||
|
||||
function renderGroup(r) {
|
||||
var b = r.block;
|
||||
var meta = b.meta || {};
|
||||
var li = el('li', 'mdgrep-card');
|
||||
|
||||
var head = el('div', 'mdgrep-card-head');
|
||||
o.badgeFields.forEach(function (field) {
|
||||
if (meta[field] == null || meta[field] === '') return;
|
||||
var text = o.formatBadge ? o.formatBadge(field, meta[field], b) : String(meta[field]);
|
||||
if (text) head.appendChild(el('span', 'mdgrep-badge mdgrep-badge-' + field, text));
|
||||
});
|
||||
if (o.dateField && meta[o.dateField]) head.appendChild(el('span', 'mdgrep-date', meta[o.dateField]));
|
||||
if (head.childNodes.length) li.appendChild(head);
|
||||
|
||||
if (b.title) {
|
||||
var title = link(b, 'mdgrep-title');
|
||||
appendSegments(title, b.title_segments);
|
||||
li.appendChild(title);
|
||||
}
|
||||
var snippet = link(b, 'mdgrep-snippet');
|
||||
appendSegments(snippet, b.segments);
|
||||
li.appendChild(snippet);
|
||||
|
||||
if (r.also && r.also.length) {
|
||||
var details = el('details', 'mdgrep-also');
|
||||
details.appendChild(el('summary', null, L.alsoIn(r.also.length)));
|
||||
var ul = el('ul');
|
||||
r.also.forEach(function (other) {
|
||||
var item = el('li');
|
||||
var a = link(other, null);
|
||||
var m = other.meta || {};
|
||||
a.textContent = o.alsoText ? o.alsoText(other) : (o.dateField && m[o.dateField]) || other.title || other.id;
|
||||
item.appendChild(a);
|
||||
ul.appendChild(item);
|
||||
});
|
||||
details.appendChild(ul);
|
||||
li.appendChild(details);
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
function search(append) {
|
||||
var q = input.value.trim();
|
||||
if (controller) controller.abort();
|
||||
clearTimeout(timer);
|
||||
updateUrl(q);
|
||||
if (!append) { list.textContent = ''; more.hidden = true; nextOffset = null; }
|
||||
if (q.length < o.minChars) {
|
||||
setStatus(q ? L.minChars(o.minChars) : '');
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
var url = new URL(o.endpoint, global.location.href);
|
||||
url.searchParams.set('q', q);
|
||||
var values = filterValues();
|
||||
Object.keys(values).forEach(function (name) { if (values[name]) url.searchParams.set(name, values[name]); });
|
||||
url.searchParams.set('limit', o.limit);
|
||||
if (append && nextOffset) url.searchParams.set('offset', nextOffset);
|
||||
|
||||
controller = new AbortController();
|
||||
var current = controller;
|
||||
if (!append) setStatus(L.searching, 'busy');
|
||||
root.classList.add('mdgrep-busy');
|
||||
|
||||
return fetch(url, { signal: current.signal, headers: { Accept: 'application/json' } })
|
||||
.then(function (resp) {
|
||||
if (resp.status === 429) throw { userMessage: L.rateLimited };
|
||||
return resp.json().then(function (data) {
|
||||
if (!resp.ok) {
|
||||
var err = data && data.error;
|
||||
if (!err) throw { userMessage: L.failed };
|
||||
throw { userMessage: L.errorAt(L.errors[err.code] || err.message, err.position), position: err.position };
|
||||
}
|
||||
return data;
|
||||
}, function () { throw { userMessage: L.failed }; });
|
||||
})
|
||||
.then(function (data) {
|
||||
if (current !== controller) return data;
|
||||
data.results.forEach(function (r) { list.appendChild(renderGroup(r)); });
|
||||
nextOffset = data.next_offset;
|
||||
more.hidden = nextOffset == null;
|
||||
if (!data.total_groups) setStatus(L.none);
|
||||
else setStatus(L.count(data.total_groups, data.total_blocks, data.elapsed_ms) +
|
||||
(data.truncated ? ' · ' + L.truncated : ''), data.truncated ? 'warn' : null);
|
||||
root.dispatchEvent(new CustomEvent('mdgrep:results', { detail: data }));
|
||||
return data;
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (e && e.name === 'AbortError') return null;
|
||||
setStatus((e && e.userMessage) || L.failed, 'error');
|
||||
return null;
|
||||
})
|
||||
.then(function (data) {
|
||||
if (current === controller) root.classList.remove('mdgrep-busy');
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
if (!o.live) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () { search(false); }, o.debounce);
|
||||
});
|
||||
form.addEventListener('submit', function (e) { e.preventDefault(); search(false); });
|
||||
Object.keys(selects).forEach(function (name) {
|
||||
selects[name].addEventListener('change', function () { search(false); });
|
||||
});
|
||||
more.addEventListener('click', function () { search(true); });
|
||||
|
||||
if (o.autofocus) input.focus();
|
||||
if (input.value.trim()) search(false);
|
||||
|
||||
return {
|
||||
search: function () { return search(false); },
|
||||
setQuery: function (q) { input.value = q; return search(false); },
|
||||
input: input,
|
||||
};
|
||||
}
|
||||
|
||||
function autoMount() {
|
||||
var nodes = document.querySelectorAll('[data-mdgrep]:not(.mdgrep)');
|
||||
Array.prototype.forEach.call(nodes, function (node) {
|
||||
var d = node.dataset;
|
||||
mount(node, {
|
||||
endpoint: d.endpoint || DEFAULTS.endpoint,
|
||||
labels: d.labels || DEFAULTS.labels,
|
||||
filters: d.filters ? JSON.parse(d.filters) : [],
|
||||
dateField: d.dateField || null,
|
||||
badgeFields: d.badgeFields ? d.badgeFields.split(',') : [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
global.MdGrep = { mount: mount, labels: LABELS, version: '0.1.0' };
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount);
|
||||
else autoMount();
|
||||
})(window);
|
||||
161
src/mdgrep/web.py
Normal file
161
src/mdgrep/web.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Flask integration: a JSON search endpoint plus the widget's static files.
|
||||
|
||||
from mdgrep import SearchEngine, load_markdown
|
||||
from mdgrep.web import create_app
|
||||
|
||||
engine = SearchEngine(lambda: load_markdown("docs", split="list"))
|
||||
app = create_app(engine, filters=["lang"]) # GET /search?q=...&lang=...
|
||||
|
||||
Or mount into an existing app:
|
||||
|
||||
app.register_blueprint(create_blueprint(engine), url_prefix="/api")
|
||||
|
||||
Requires the optional dependency: pip install "mdgrep[web]".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Hashable, Iterable
|
||||
|
||||
from flask import Blueprint, Flask, jsonify, request, send_from_directory
|
||||
|
||||
from .engine import Block, SearchEngine
|
||||
from .markdown import SPLIT_MODES, load_markdown
|
||||
from .query import QuerySyntaxError
|
||||
|
||||
__all__ = ["create_blueprint", "create_app", "STATIC_DIR"]
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
def create_blueprint(
|
||||
engine: SearchEngine,
|
||||
*,
|
||||
filters: Iterable[str] = (),
|
||||
group_by: Callable[[Block], Hashable | None] | None = None,
|
||||
sort_key: Callable[[Block], Any] | None = None,
|
||||
reverse: bool = False,
|
||||
default_limit: int = 30,
|
||||
max_limit: int = 100,
|
||||
time_budget: float = 1.5,
|
||||
snippet_chars: int | None = 700,
|
||||
context: int = 120,
|
||||
max_query_length: int = 300,
|
||||
hide_meta: Iterable[str] = ("markdown",),
|
||||
name: str = "mdgrep",
|
||||
) -> Blueprint:
|
||||
"""Blueprint with `GET /search` and the widget assets under `/mdgrep-static/`.
|
||||
|
||||
Query parameters: q, offset, limit and one parameter per name in `filters`
|
||||
(empty or "all" = no restriction, comma-separated = several values).
|
||||
Errors: HTTP 400 with {"error": {"code", "message", "position"}}.
|
||||
"""
|
||||
bp = Blueprint(name, __name__, static_folder=str(STATIC_DIR), static_url_path="/mdgrep-static")
|
||||
filter_names = list(filters)
|
||||
hidden = set(hide_meta)
|
||||
|
||||
@bp.get("/search")
|
||||
def search():
|
||||
q = request.args.get("q", "").strip()
|
||||
offset = _int_arg("offset", 0, 0, 10**9)
|
||||
limit = _int_arg("limit", default_limit, 1, max_limit)
|
||||
if not q:
|
||||
return jsonify(_empty(offset, limit))
|
||||
if len(q) > max_query_length:
|
||||
return _error("too_long", f"Query longer than {max_query_length} characters", max_query_length)
|
||||
|
||||
selected = {}
|
||||
for fname in filter_names:
|
||||
value = request.args.get(fname, "").strip()
|
||||
if value and value != "all":
|
||||
selected[fname] = value.split(",") if "," in value else value
|
||||
|
||||
try:
|
||||
result = engine.search(q, filters=selected, group_by=group_by, sort_key=sort_key,
|
||||
reverse=reverse, offset=offset, limit=limit,
|
||||
time_budget=time_budget, snippet_chars=snippet_chars,
|
||||
context=context)
|
||||
except QuerySyntaxError as e:
|
||||
return _error(e.code, e.message, e.position)
|
||||
|
||||
data = result.to_dict()
|
||||
if hidden:
|
||||
for r in data["results"]:
|
||||
for b in [r["block"], *r["also"]]:
|
||||
for key in hidden:
|
||||
b["meta"].pop(key, None)
|
||||
return jsonify(data)
|
||||
|
||||
return bp
|
||||
|
||||
|
||||
def create_app(engine: SearchEngine, *, url_prefix: str = "", static_dir: str | Path | None = None,
|
||||
**blueprint_options) -> Flask:
|
||||
"""Standalone Flask app. With `static_dir`, files from that directory are served at `/`
|
||||
(handy for local testing of a static site together with its search API)."""
|
||||
app = Flask(__name__, static_folder=None)
|
||||
app.json.ensure_ascii = False
|
||||
app.register_blueprint(create_blueprint(engine, **blueprint_options), url_prefix=url_prefix or None)
|
||||
|
||||
if static_dir is not None:
|
||||
root = Path(static_dir).resolve()
|
||||
|
||||
@app.get("/", defaults={"path": "index.html"})
|
||||
@app.get("/<path:path>")
|
||||
def static_files(path):
|
||||
if (root / path).is_dir():
|
||||
path = f"{path.rstrip('/')}/index.html"
|
||||
return send_from_directory(root, path)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _int_arg(name, default, lo, hi):
|
||||
try:
|
||||
value = int(request.args.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
def _empty(offset, limit):
|
||||
return {"query": "", "total_groups": 0, "total_blocks": 0, "offset": offset, "limit": limit,
|
||||
"next_offset": None, "truncated": False, "elapsed_ms": 0, "results": []}
|
||||
|
||||
|
||||
def _error(code, message, position):
|
||||
return jsonify({"error": {"code": code, "message": message, "position": position}}), 400
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""Development server: python -m mdgrep.web --dir docs [--static site] [--port 8000]"""
|
||||
p = argparse.ArgumentParser(prog="python -m mdgrep.web", description="mdgrep development server")
|
||||
p.add_argument("--dir", required=True, help="directory with Markdown files")
|
||||
p.add_argument("--split", choices=SPLIT_MODES, default="paragraph")
|
||||
p.add_argument("--static", help="serve this directory at / (API then lives under --api-prefix)")
|
||||
p.add_argument("--api-prefix", default="/api")
|
||||
p.add_argument("--filters", default="", help="comma-separated meta keys usable as filters")
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=8000)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
md_dir = Path(args.dir)
|
||||
engine = SearchEngine(lambda: load_markdown(md_dir, split=args.split, relative_to=md_dir),
|
||||
reload_check=lambda: _fingerprint(md_dir), reload_interval=5)
|
||||
filters = [f for f in args.filters.split(",") if f]
|
||||
app = create_app(engine, url_prefix=args.api_prefix if args.static else "",
|
||||
static_dir=args.static, filters=filters)
|
||||
print(f"mdgrep: {len(engine.blocks)} blocks from {md_dir}")
|
||||
app.run(host=args.host, port=args.port, debug=False)
|
||||
|
||||
|
||||
def _fingerprint(directory: Path):
|
||||
stats = [p.stat() for p in directory.rglob("*.md")]
|
||||
return len(stats), max((s.st_mtime for s in stats), default=0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
60
tests/test_cli.py
Normal file
60
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mdgrep.cli import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def docs(tmp_path):
|
||||
(tmp_path / "a.md").write_text("# A\n\nRust is fast.\n\nGo is simple.\n", encoding="utf-8")
|
||||
(tmp_path / "b.md").write_text("- rust crypto wallet\n- python typing\n", encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def run(capsys, *args):
|
||||
code = main([*args, "--color", "never"])
|
||||
out, err = capsys.readouterr()
|
||||
return code, out, err
|
||||
|
||||
|
||||
def test_match_output_and_exit_code(capsys, docs):
|
||||
code, out, _ = run(capsys, "rust -crypto", str(docs))
|
||||
assert code == 0
|
||||
assert f"{docs / 'a.md'}:3" in out
|
||||
assert "Rust is fast." in out
|
||||
assert "crypto" not in out
|
||||
|
||||
|
||||
def test_no_match_exit_1(capsys, docs):
|
||||
code, out, _ = run(capsys, "haskell", str(docs))
|
||||
assert code == 1 and out == ""
|
||||
|
||||
|
||||
def test_syntax_error_exit_2_with_caret(capsys, docs):
|
||||
code, _, err = run(capsys, "(rust", str(docs))
|
||||
assert code == 2
|
||||
assert "Missing closing parenthesis" in err and "^" in err
|
||||
|
||||
|
||||
def test_count_and_files(capsys, docs):
|
||||
assert run(capsys, "rust OR python", str(docs))[0] == 0
|
||||
_, out, _ = run(capsys, "rust OR python", str(docs), "--split", "list", "-c")
|
||||
assert out.strip() == "3"
|
||||
_, out, _ = run(capsys, "rust", str(docs), "-l")
|
||||
assert out.split() == [str(docs / "a.md"), str(docs / "b.md")]
|
||||
|
||||
|
||||
def test_json_output(capsys, docs):
|
||||
code, out, _ = run(capsys, "/fa.t/", str(docs), "--json")
|
||||
data = json.loads(out)
|
||||
assert code == 0 and data["total_blocks"] == 1
|
||||
block = data["results"][0]["block"]
|
||||
assert {"t": "fast", "hit": True} in block["segments"]
|
||||
assert "markdown" not in block["meta"]
|
||||
|
||||
|
||||
def test_color_highlight(capsys, docs):
|
||||
main(["rust", str(docs / "a.md"), "--color", "always"])
|
||||
out = capsys.readouterr().out
|
||||
assert "\033[1;31mRust\033[0m" in out
|
||||
116
tests/test_engine.py
Normal file
116
tests/test_engine.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import pytest
|
||||
|
||||
from mdgrep import Block, QuerySyntaxError, SearchEngine
|
||||
|
||||
|
||||
def blk(i, text, **meta):
|
||||
return Block(id=str(i), text=text, title=f"T{i} {text.split()[0]}", meta=meta)
|
||||
|
||||
|
||||
BLOCKS = [
|
||||
blk(1, "rust compiler news", lang="en", date="2026-01-01", url="u1"),
|
||||
blk(2, "rust compiler news again", lang="en", date="2026-02-01", url="u1"),
|
||||
blk(3, "Rust Übersetzer", lang="de", date="2026-01-15", url="u1"),
|
||||
blk(4, "go generics", lang="en", date="2026-03-01", url="u2"),
|
||||
blk(5, "python typing and rust bindings", lang="en", date="2025-12-01", url="u3"),
|
||||
]
|
||||
|
||||
|
||||
def ids(result):
|
||||
return [g.block.id for g in result.groups]
|
||||
|
||||
|
||||
def test_basic_search_original_order():
|
||||
res = SearchEngine(BLOCKS).search("rust")
|
||||
assert ids(res) == ["1", "2", "3", "5"]
|
||||
assert res.total_blocks == 4 and res.total_groups == 4
|
||||
|
||||
|
||||
def test_filters_single_and_multiple_values():
|
||||
eng = SearchEngine(BLOCKS)
|
||||
assert ids(eng.search("rust", filters={"lang": "de"})) == ["3"]
|
||||
assert ids(eng.search("rust", filters={"lang": ["de", "en"]})) == ["1", "2", "3", "5"]
|
||||
assert ids(eng.search("rust", filters={"lang": ""})) == ["1", "2", "3", "5"]
|
||||
|
||||
|
||||
def test_predicate():
|
||||
res = SearchEngine(BLOCKS).search("rust", predicate=lambda b: b.id != "1")
|
||||
assert ids(res) == ["2", "3", "5"]
|
||||
|
||||
|
||||
def test_sort_newest_first():
|
||||
res = SearchEngine(BLOCKS).search("rust", sort_key=lambda b: b.meta["date"], reverse=True)
|
||||
assert ids(res) == ["2", "3", "1", "5"]
|
||||
|
||||
|
||||
def test_grouping_uses_first_block_after_sorting():
|
||||
res = SearchEngine(BLOCKS).search(
|
||||
"rust", sort_key=lambda b: b.meta["date"], reverse=True,
|
||||
group_by=lambda b: (b.meta["lang"], b.meta["url"]))
|
||||
assert ids(res) == ["2", "3", "5"]
|
||||
assert [b.id for b in res.groups[0].also] == ["1"]
|
||||
assert res.total_groups == 3 and res.total_blocks == 4
|
||||
|
||||
|
||||
def test_title_is_searched():
|
||||
eng = SearchEngine([Block("t", "body text", title="Special Title")])
|
||||
res = eng.search("special")
|
||||
assert ids(res) == ["t"]
|
||||
assert res.groups[0].title_segments == [{"t": "Special", "hit": True}, {"t": " Title", "hit": False}]
|
||||
assert res.groups[0].hits == 0 # hits counts snippet matches
|
||||
|
||||
|
||||
def test_paging():
|
||||
eng = SearchEngine(BLOCKS)
|
||||
first = eng.search("rust", limit=2)
|
||||
assert ids(first) == ["1", "2"] and first.next_offset == 2
|
||||
second = eng.search("rust", limit=2, offset=first.next_offset)
|
||||
assert ids(second) == ["3", "5"] and second.next_offset is None
|
||||
|
||||
|
||||
def test_segments_and_title_segments():
|
||||
res = SearchEngine(BLOCKS).search("compiler")
|
||||
d = res.to_dict()["results"][0]["block"]
|
||||
assert {"t": "compiler", "hit": True} in d["segments"]
|
||||
assert d["hits"] == 1
|
||||
assert d["title_segments"] == [{"t": "T1 rust", "hit": False}]
|
||||
|
||||
|
||||
def test_to_dict_contract():
|
||||
d = SearchEngine(BLOCKS).search("go", limit=10).to_dict()
|
||||
assert set(d) == {"query", "total_groups", "total_blocks", "offset", "limit",
|
||||
"next_offset", "truncated", "elapsed_ms", "results"}
|
||||
r = d["results"][0]
|
||||
assert set(r) == {"block", "also"}
|
||||
assert r["block"]["meta"] == {"lang": "en", "date": "2026-03-01", "url": "u2"}
|
||||
|
||||
|
||||
def test_syntax_error_propagates():
|
||||
with pytest.raises(QuerySyntaxError):
|
||||
SearchEngine(BLOCKS).search("(rust")
|
||||
|
||||
|
||||
def test_time_budget_truncates():
|
||||
blocks = [Block(id=str(i), text="a" * 30 + "!") for i in range(50)]
|
||||
res = SearchEngine(blocks).search("/(a|aa)+$/", time_budget=0.3)
|
||||
assert res.truncated
|
||||
assert res.elapsed_ms < 3000
|
||||
|
||||
|
||||
def test_reload_on_token_change():
|
||||
state = {"token": 1, "blocks": [Block("a", "first")]}
|
||||
eng = SearchEngine(lambda: list(state["blocks"]), reload_check=lambda: state["token"],
|
||||
reload_interval=0)
|
||||
assert ids(eng.search("first")) == ["a"]
|
||||
state["blocks"] = [Block("b", "second")]
|
||||
assert ids(eng.search("second")) == [] # token unchanged → no reload
|
||||
state["token"] = 2
|
||||
assert ids(eng.search("second")) == ["b"]
|
||||
|
||||
|
||||
def test_reload_interval_respected():
|
||||
calls = []
|
||||
eng = SearchEngine(lambda: [], reload_check=lambda: calls.append(1) or 1, reload_interval=3600)
|
||||
eng.search("x")
|
||||
eng.search("x")
|
||||
assert len(calls) == 1 # only the initial check
|
||||
119
tests/test_markdown.py
Normal file
119
tests/test_markdown.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import pytest
|
||||
|
||||
from mdgrep.markdown import load_markdown, markdown_to_text, slugify, split_markdown
|
||||
|
||||
DOC = """# Report
|
||||
## Edition
|
||||
|
||||
1. **[Rust 2.0 released](https://example.com/rust)** (100 points by alice)
|
||||
|
||||
The Rust team announced a *major* release with `async` traits.
|
||||
|
||||
2. **[Go generics](https://example.com/go)** (50 points by bob)
|
||||
|
||||
Generics in Go are now __fast__.
|
||||
|
||||
## Insights
|
||||
|
||||
1. **Memory safety wins**
|
||||
|
||||
- **Trend:** Rust adoption grows.
|
||||
- **Why:** fewer bugs.
|
||||
|
||||
2. **Typed languages**
|
||||
lazy continuation line
|
||||
|
||||
A closing paragraph
|
||||
spanning two lines.
|
||||
|
||||
---
|
||||
|
||||
*Footer text*
|
||||
|
||||
```
|
||||
# not a heading
|
||||
- not an item
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def kinds(blocks):
|
||||
return [(b.meta["kind"], b.meta["section"], b.line) for b in blocks]
|
||||
|
||||
|
||||
def test_split_list():
|
||||
blocks = split_markdown(DOC, source="doc.md", split="list")
|
||||
assert kinds(blocks) == [
|
||||
("item", "Edition", 4),
|
||||
("item", "Edition", 8),
|
||||
("item", "Insights", 14),
|
||||
("item", "Insights", 19),
|
||||
("paragraph", "Insights", 22),
|
||||
("paragraph", "Insights", 27),
|
||||
("paragraph", "Insights", 29),
|
||||
]
|
||||
first = blocks[0]
|
||||
assert first.text == ("Rust 2.0 released (100 points by alice)\n\n"
|
||||
"The Rust team announced a major release with async traits.")
|
||||
assert first.title == ""
|
||||
assert first.id == "doc.md:4" and first.source == "doc.md"
|
||||
assert first.meta["markdown"].startswith("1. **[Rust 2.0 released]")
|
||||
assert "Trend: Rust adoption grows." in blocks[2].text
|
||||
assert "lazy continuation line" in blocks[3].text
|
||||
assert "not a heading" in blocks[6].text
|
||||
|
||||
|
||||
def test_split_heading():
|
||||
blocks = split_markdown(DOC, split="heading")
|
||||
assert [(b.title, b.anchor) for b in blocks] == [
|
||||
("Report", "report"), ("Edition", "edition"), ("Insights", "insights")]
|
||||
assert "Go generics" in blocks[1].text
|
||||
assert not blocks[1].text.startswith("Edition")
|
||||
|
||||
|
||||
def test_split_paragraph():
|
||||
blocks = split_markdown(DOC, split="paragraph")
|
||||
texts = [b.text.split("\n")[0] for b in blocks]
|
||||
assert "Rust 2.0 released (100 points by alice)" in texts
|
||||
assert "The Rust team announced a major release with async traits." in texts
|
||||
assert "A closing paragraph" in texts
|
||||
fence = [b for b in blocks if "not a heading" in b.text]
|
||||
assert len(fence) == 1 and "not an item" in fence[0].text
|
||||
|
||||
|
||||
def test_split_file():
|
||||
blocks = split_markdown(DOC, source="x.md", split="file")
|
||||
assert len(blocks) == 1 and blocks[0].title == "Report" and blocks[0].line == 1
|
||||
|
||||
|
||||
def test_invalid_split_mode():
|
||||
with pytest.raises(ValueError):
|
||||
split_markdown("x", split="bogus")
|
||||
|
||||
|
||||
def test_markdown_to_text():
|
||||
md = ("- **Bold** and *italic* and __b2__ and `code` and ~~gone~~\n"
|
||||
" [link](https://x.y/a_(b))  <https://auto.link>\n"
|
||||
"> quote with snake_case_name")
|
||||
assert markdown_to_text(md) == (
|
||||
"Bold and italic and b2 and code and gone\n"
|
||||
"link alt https://auto.link\n"
|
||||
"quote with snake_case_name")
|
||||
|
||||
|
||||
def test_slugify():
|
||||
assert slugify("AI/ML Insights & Trends") == "aiml-insights--trends"
|
||||
assert slugify("Über **uns**") == "über-uns"
|
||||
|
||||
|
||||
def test_load_markdown_directory_and_meta(tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "a.md").write_text("first para\n\nsecond para\n", encoding="utf-8")
|
||||
(tmp_path / "sub" / "b.md").write_text("- item one\n- item two\n", encoding="utf-8")
|
||||
(tmp_path / "ignore.txt").write_text("nope", encoding="utf-8")
|
||||
blocks = load_markdown(tmp_path, split="list", meta=lambda p: {"file": p.name})
|
||||
assert [(b.meta["file"], b.text) for b in blocks] == [
|
||||
("a.md", "first para"), ("a.md", "second para"),
|
||||
("b.md", "item one"), ("b.md", "item two")]
|
||||
rel = load_markdown(tmp_path, relative_to=tmp_path)
|
||||
assert [b.id for b in rel] == ["a.md:1", "a.md:3", "sub/b.md:1"]
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue