treesearchlib 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- treesearch/__init__.py +53 -0
- treesearch/__main__.py +6 -0
- treesearch/_bin/pst-extract.exe +0 -0
- treesearch/cli.py +554 -0
- treesearch/config.py +206 -0
- treesearch/fts.py +2293 -0
- treesearch/heuristics.py +425 -0
- treesearch/indexer.py +2038 -0
- treesearch/parsers/__init__.py +62 -0
- treesearch/parsers/anydoc_parser.py +193 -0
- treesearch/parsers/ast_parser.py +136 -0
- treesearch/parsers/docx_parser.py +304 -0
- treesearch/parsers/email_html_md.py +60 -0
- treesearch/parsers/excel_parser.py +218 -0
- treesearch/parsers/html_parser.py +172 -0
- treesearch/parsers/image_metadata.py +345 -0
- treesearch/parsers/image_parser.py +59 -0
- treesearch/parsers/image_store.py +182 -0
- treesearch/parsers/markitdown_parser.py +258 -0
- treesearch/parsers/mhtml_parser.py +108 -0
- treesearch/parsers/pdf_parser.py +409 -0
- treesearch/parsers/pst_attachment_store.py +156 -0
- treesearch/parsers/pst_parser.py +733 -0
- treesearch/parsers/registry.py +405 -0
- treesearch/parsers/treesitter_parser.py +433 -0
- treesearch/pathutil.py +227 -0
- treesearch/py.typed +0 -0
- treesearch/ripgrep.py +159 -0
- treesearch/search.py +935 -0
- treesearch/tokenizer.py +176 -0
- treesearch/tree.py +393 -0
- treesearch/tree_searcher.py +1006 -0
- treesearch/treesearch.py +574 -0
- treesearch/watch.py +305 -0
- treesearchlib-1.1.0.dist-info/METADATA +124 -0
- treesearchlib-1.1.0.dist-info/RECORD +39 -0
- treesearchlib-1.1.0.dist-info/WHEEL +5 -0
- treesearchlib-1.1.0.dist-info/entry_points.txt +2 -0
- treesearchlib-1.1.0.dist-info/top_level.txt +1 -0
treesearch/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@author:XuMing(xuming624@qq.com)
|
|
4
|
+
@description: TreeSearch - Structure-aware document retrieval via tree-structured indexing.
|
|
5
|
+
|
|
6
|
+
No vector embeddings. No chunk splitting. FTS5 keyword matching over document trees.
|
|
7
|
+
|
|
8
|
+
Quick Start::
|
|
9
|
+
|
|
10
|
+
from treesearch import TreeSearch
|
|
11
|
+
|
|
12
|
+
# Lazy indexing -- auto-builds index on first search
|
|
13
|
+
ts = TreeSearch("./docs/")
|
|
14
|
+
results = ts.search("How to configure voice calls?")
|
|
15
|
+
"""
|
|
16
|
+
__version__ = "1.1.0"
|
|
17
|
+
|
|
18
|
+
# ============================================================================
|
|
19
|
+
# Public API
|
|
20
|
+
# ============================================================================
|
|
21
|
+
|
|
22
|
+
# -- Primary: the only class most users need --
|
|
23
|
+
from treesearch.treesearch import TreeSearch
|
|
24
|
+
|
|
25
|
+
# -- Core --
|
|
26
|
+
from treesearch.indexer import build_index, md_to_tree, text_to_tree, IndexStats
|
|
27
|
+
from treesearch.search import search, search_sync, GrepFilter
|
|
28
|
+
from treesearch.tree import Document, load_index, load_documents, save_index, flatten_tree, print_toc
|
|
29
|
+
from treesearch.config import (
|
|
30
|
+
TreeSearchConfig, get_config, set_config, reset_config,
|
|
31
|
+
INDEX_SCHEMA_VERSION,
|
|
32
|
+
)
|
|
33
|
+
from treesearch.fts import FTS5Index
|
|
34
|
+
from treesearch.tree_searcher import TreeSearcher, PathResult
|
|
35
|
+
from treesearch.heuristics import build_query_plan, QueryPlan
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
# Primary
|
|
39
|
+
"TreeSearch",
|
|
40
|
+
# Indexing
|
|
41
|
+
"build_index", "md_to_tree", "text_to_tree", "IndexStats",
|
|
42
|
+
# Search
|
|
43
|
+
"search", "search_sync", "GrepFilter",
|
|
44
|
+
# Tree Search
|
|
45
|
+
"TreeSearcher", "PathResult", "build_query_plan", "QueryPlan",
|
|
46
|
+
# Document & tree
|
|
47
|
+
"Document", "load_index", "load_documents", "save_index", "flatten_tree", "print_toc",
|
|
48
|
+
# Config
|
|
49
|
+
"TreeSearchConfig", "get_config", "set_config", "reset_config",
|
|
50
|
+
"INDEX_SCHEMA_VERSION",
|
|
51
|
+
# FTS5
|
|
52
|
+
"FTS5Index",
|
|
53
|
+
]
|
treesearch/__main__.py
ADDED
|
Binary file
|
treesearch/cli.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@author:XuMing(xuming624@qq.com)
|
|
4
|
+
@description: CLI entry point for TreeSearch.
|
|
5
|
+
|
|
6
|
+
Default usage (lazy index + search):
|
|
7
|
+
treesearch "How does auth work?" src/ docs/*.md
|
|
8
|
+
treesearch "FTS5 search" treesearch/
|
|
9
|
+
|
|
10
|
+
Advanced subcommands:
|
|
11
|
+
treesearch index --paths src/ docs/ --force
|
|
12
|
+
treesearch search --db ./indexes/index.db --query "auth"
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _configure_cli_logging(level: int) -> None:
|
|
26
|
+
"""CLI 日志走标准 logging(输出 stderr),不落文件、不依赖宿主日志设施。
|
|
27
|
+
|
|
28
|
+
解析链路三方库(pdfminer/PIL 等)DEBUG 刷屏,统一压到 WARNING。
|
|
29
|
+
"""
|
|
30
|
+
logging.basicConfig(
|
|
31
|
+
level=level,
|
|
32
|
+
format="%(asctime)s | %(levelname)s | %(message)s",
|
|
33
|
+
)
|
|
34
|
+
for _name in ("pdfminer", "pdfplumber", "markitdown", "urllib3", "PIL"):
|
|
35
|
+
logging.getLogger(_name).setLevel(logging.WARNING)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _DefaultArgumentParser(argparse.ArgumentParser):
|
|
39
|
+
"""Default parser with small normalization for explicit query flags."""
|
|
40
|
+
|
|
41
|
+
def parse_args(self, args=None, namespace=None):
|
|
42
|
+
parsed = super().parse_args(args, namespace)
|
|
43
|
+
if getattr(parsed, "fts_expression", None) and parsed.query:
|
|
44
|
+
parsed.paths = [parsed.query, *parsed.paths]
|
|
45
|
+
parsed.query = None
|
|
46
|
+
return parsed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Default command: lazy search (the simplest way to use TreeSearch)
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _run_default(args) -> None:
|
|
54
|
+
"""Lazy index + search: the simplest workflow."""
|
|
55
|
+
from treesearch.treesearch import TreeSearch
|
|
56
|
+
|
|
57
|
+
paths = args.paths
|
|
58
|
+
query = args.query
|
|
59
|
+
fts_expression = args.fts_expression
|
|
60
|
+
db_path = args.db or "./index.db"
|
|
61
|
+
max_nodes = args.max_nodes
|
|
62
|
+
search_mode = args.search_mode
|
|
63
|
+
show_path = args.show_path
|
|
64
|
+
regex = args.regex
|
|
65
|
+
|
|
66
|
+
if regex and fts_expression is not None:
|
|
67
|
+
print("Error: --regex and --fts-expression cannot be used together.", file=sys.stderr)
|
|
68
|
+
sys.exit(2)
|
|
69
|
+
|
|
70
|
+
if not paths:
|
|
71
|
+
print("Error: no paths specified. Usage: treesearch \"query\" path1 [path2 ...]",
|
|
72
|
+
file=sys.stderr)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
if query is None and fts_expression is None:
|
|
75
|
+
print("Error: query is required unless --fts-expression is provided.", file=sys.stderr)
|
|
76
|
+
sys.exit(2)
|
|
77
|
+
|
|
78
|
+
start_time = time.time()
|
|
79
|
+
|
|
80
|
+
ts = TreeSearch(*paths, db_path=db_path)
|
|
81
|
+
display_query = fts_expression or query
|
|
82
|
+
try:
|
|
83
|
+
result = ts.search(
|
|
84
|
+
display_query,
|
|
85
|
+
max_nodes_per_doc=max_nodes,
|
|
86
|
+
search_mode=search_mode,
|
|
87
|
+
fts_expression=fts_expression,
|
|
88
|
+
regex=regex,
|
|
89
|
+
)
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
92
|
+
sys.exit(2)
|
|
93
|
+
elapsed = time.time() - start_time
|
|
94
|
+
|
|
95
|
+
if not result["documents"] or not result["flat_nodes"]:
|
|
96
|
+
print(f"No results found for: {display_query}")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
mode = result.get("mode", "flat")
|
|
100
|
+
total_nodes = sum(len(d["nodes"]) for d in result["documents"])
|
|
101
|
+
print(f"Found {total_nodes} result(s) in {len(result['documents'])} doc(s) [{mode} mode] ({elapsed:.1f}s)\n")
|
|
102
|
+
|
|
103
|
+
# Show path results if available and requested
|
|
104
|
+
if show_path and "paths" in result:
|
|
105
|
+
for i, path_info in enumerate(result["paths"], 1):
|
|
106
|
+
score = path_info.get("score", 0)
|
|
107
|
+
doc_name = path_info.get("doc_name", "")
|
|
108
|
+
reasons = path_info.get("reasons", [])
|
|
109
|
+
path_nodes = path_info.get("path", [])
|
|
110
|
+
snippet = path_info.get("snippet", "")
|
|
111
|
+
|
|
112
|
+
print(f"Path {i} ({score:.2f}) {doc_name}")
|
|
113
|
+
for j, pn in enumerate(path_nodes):
|
|
114
|
+
indent = " " * j
|
|
115
|
+
connector = "|-- " if j < len(path_nodes) - 1 else "`-> "
|
|
116
|
+
print(f" {indent}{connector}{pn.get('title', '')}")
|
|
117
|
+
if snippet:
|
|
118
|
+
preview = snippet[:300]
|
|
119
|
+
if len(snippet) > 300:
|
|
120
|
+
preview += "..."
|
|
121
|
+
for line in preview.split("\n"):
|
|
122
|
+
print(f" {line}")
|
|
123
|
+
if reasons:
|
|
124
|
+
print(f" reasons: {'; '.join(reasons[:5])}")
|
|
125
|
+
print()
|
|
126
|
+
|
|
127
|
+
# Show flat node results
|
|
128
|
+
for doc_result in result["documents"]:
|
|
129
|
+
doc_name = doc_result["doc_name"]
|
|
130
|
+
for node in doc_result["nodes"]:
|
|
131
|
+
score = node.get("score", 0)
|
|
132
|
+
title = node.get("title", "")
|
|
133
|
+
line_start = node.get("line_start")
|
|
134
|
+
line_end = node.get("line_end")
|
|
135
|
+
text = node.get("text", "")
|
|
136
|
+
|
|
137
|
+
loc = f" (lines {line_start}-{line_end})" if line_start and line_end else ""
|
|
138
|
+
print(f"[{score:.2f}] {doc_name} > {title}{loc}")
|
|
139
|
+
|
|
140
|
+
if text:
|
|
141
|
+
preview = text[:500]
|
|
142
|
+
if len(text) > 500:
|
|
143
|
+
preview += "..."
|
|
144
|
+
for line in preview.split("\n"):
|
|
145
|
+
print(f" {line}")
|
|
146
|
+
print()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Subcommand: index
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _add_index_args(sub: argparse.ArgumentParser) -> None:
|
|
154
|
+
sub.add_argument("--paths", nargs="+", required=True,
|
|
155
|
+
help="File paths, glob patterns, or directories (e.g. src/ 'docs/*.md')")
|
|
156
|
+
sub.add_argument("-o", "--output_dir", type=str, default="./indexes",
|
|
157
|
+
help="Output directory for database file (default: ./indexes)")
|
|
158
|
+
sub.add_argument("--db", type=str, default="",
|
|
159
|
+
help="Path to SQLite database file (default: {output_dir}/index.db)")
|
|
160
|
+
sub.add_argument("--no-summary", action="store_true", help="Skip node summary generation")
|
|
161
|
+
sub.add_argument("--add-description", action="store_true", help="Generate doc description")
|
|
162
|
+
sub.add_argument("--add-text", action="store_true", help="Include node text in output")
|
|
163
|
+
sub.add_argument("--no-node-id", action="store_true", help="Skip node ID assignment")
|
|
164
|
+
sub.add_argument("--thinning", action="store_true", help="Apply tree thinning")
|
|
165
|
+
sub.add_argument("--thinning-threshold", type=int, default=15000,
|
|
166
|
+
help="Min chars threshold for thinning (default: 15000)")
|
|
167
|
+
sub.add_argument("--summary-threshold", type=int, default=600,
|
|
168
|
+
help="Chars threshold for summary generation (default: 600)")
|
|
169
|
+
sub.add_argument("--max-concurrency", type=int, default=None,
|
|
170
|
+
help="Max concurrent indexing tasks (default: auto based on CPU cores)")
|
|
171
|
+
sub.add_argument("--force", action="store_true",
|
|
172
|
+
help="Force re-index even if files unchanged")
|
|
173
|
+
sub.add_argument("--stats", action="store_true",
|
|
174
|
+
help="Show detailed indexing statistics after completion")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _run_index(args) -> None:
|
|
178
|
+
from treesearch.indexer import build_index
|
|
179
|
+
from treesearch.tree import print_toc
|
|
180
|
+
|
|
181
|
+
start_time = time.time()
|
|
182
|
+
print(f"Indexing {len(args.paths)} path(s)...")
|
|
183
|
+
|
|
184
|
+
results = await build_index(
|
|
185
|
+
paths=args.paths,
|
|
186
|
+
output_dir=args.output_dir,
|
|
187
|
+
db_path=args.db,
|
|
188
|
+
if_add_node_summary=not args.no_summary,
|
|
189
|
+
if_add_doc_description=args.add_description,
|
|
190
|
+
if_add_node_text=args.add_text,
|
|
191
|
+
if_add_node_id=not args.no_node_id,
|
|
192
|
+
if_thinning=args.thinning,
|
|
193
|
+
min_thinning_chars=args.thinning_threshold,
|
|
194
|
+
summary_chars_threshold=args.summary_threshold,
|
|
195
|
+
max_concurrency=args.max_concurrency,
|
|
196
|
+
force=args.force,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
db_path = args.db or os.path.join(args.output_dir, "index.db")
|
|
200
|
+
elapsed = time.time() - start_time
|
|
201
|
+
print(f"\nIndexed {len(results)} file(s) to {db_path} ({elapsed:.1f}s)")
|
|
202
|
+
for doc in results:
|
|
203
|
+
print(f" - {doc.doc_name}")
|
|
204
|
+
print(f" TOC:")
|
|
205
|
+
print_toc(doc.structure)
|
|
206
|
+
|
|
207
|
+
# Display stats if requested
|
|
208
|
+
if args.stats and hasattr(results, 'stats') and results.stats:
|
|
209
|
+
print(f"\n{results.stats.summary()}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Subcommand: search (over pre-built index)
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def _add_search_args(sub: argparse.ArgumentParser) -> None:
|
|
217
|
+
sub.add_argument("--index_dir", type=str, default="./indexes",
|
|
218
|
+
help="Directory containing the database file (default: ./indexes)")
|
|
219
|
+
sub.add_argument("--db", type=str, default="",
|
|
220
|
+
help="Path to SQLite database file (default: {index_dir}/index.db)")
|
|
221
|
+
query_group = sub.add_mutually_exclusive_group(required=True)
|
|
222
|
+
query_group.add_argument(
|
|
223
|
+
"--query",
|
|
224
|
+
type=str,
|
|
225
|
+
help="Search query. Supports auth* (prefix) and *auth* (contains regex)",
|
|
226
|
+
)
|
|
227
|
+
query_group.add_argument(
|
|
228
|
+
"--fts-expression",
|
|
229
|
+
type=str,
|
|
230
|
+
dest="fts_expression",
|
|
231
|
+
help="Raw FTS5 expression, e.g. auth* or \"auth NEAR/5 token\"",
|
|
232
|
+
)
|
|
233
|
+
sub.add_argument("--regex", action="store_true",
|
|
234
|
+
help="Treat --query as a raw regex pattern")
|
|
235
|
+
sub.add_argument("--top-k-docs", type=int, default=3,
|
|
236
|
+
help="Max documents to search (default: 3)")
|
|
237
|
+
sub.add_argument("--max-nodes", type=int, default=5,
|
|
238
|
+
help="Max result nodes per document (default: 5)")
|
|
239
|
+
sub.add_argument("--search-mode", type=str, default="auto",
|
|
240
|
+
choices=["auto", "tree", "flat"],
|
|
241
|
+
help="Search mode: 'auto', 'tree' or 'flat' (default: auto)")
|
|
242
|
+
sub.add_argument("--show-path", action="store_true",
|
|
243
|
+
help="Show path-based results (tree mode only)")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _load_documents_from_dir(index_dir: str, db: str = ""):
|
|
247
|
+
"""Load all documents from a database file."""
|
|
248
|
+
from treesearch.tree import Document, load_documents
|
|
249
|
+
|
|
250
|
+
db_path = db or os.path.join(index_dir, "index.db")
|
|
251
|
+
if not os.path.isfile(db_path):
|
|
252
|
+
print(f"Database file not found: {db_path}", file=sys.stderr)
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
documents = load_documents(db_path)
|
|
255
|
+
if not documents:
|
|
256
|
+
print(f"No documents found in database: {db_path}", file=sys.stderr)
|
|
257
|
+
sys.exit(1)
|
|
258
|
+
return documents
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def _run_search(args) -> None:
|
|
262
|
+
from treesearch.search import search
|
|
263
|
+
|
|
264
|
+
documents = _load_documents_from_dir(args.index_dir, db=args.db)
|
|
265
|
+
print(f"Loaded {len(documents)} document(s)\n")
|
|
266
|
+
|
|
267
|
+
if args.regex and args.fts_expression is not None:
|
|
268
|
+
print("Error: --regex and --fts-expression cannot be used together.", file=sys.stderr)
|
|
269
|
+
sys.exit(2)
|
|
270
|
+
|
|
271
|
+
display_query = args.fts_expression or args.query
|
|
272
|
+
|
|
273
|
+
print(f"Query: {display_query}")
|
|
274
|
+
print("---")
|
|
275
|
+
|
|
276
|
+
start_time = time.time()
|
|
277
|
+
try:
|
|
278
|
+
result = await search(
|
|
279
|
+
query=args.query or args.fts_expression,
|
|
280
|
+
documents=documents,
|
|
281
|
+
top_k_docs=args.top_k_docs,
|
|
282
|
+
max_nodes_per_doc=args.max_nodes,
|
|
283
|
+
search_mode=args.search_mode,
|
|
284
|
+
fts_expression=args.fts_expression,
|
|
285
|
+
regex=args.regex,
|
|
286
|
+
)
|
|
287
|
+
except ValueError as exc:
|
|
288
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
289
|
+
sys.exit(2)
|
|
290
|
+
elapsed = time.time() - start_time
|
|
291
|
+
|
|
292
|
+
if not result["documents"]:
|
|
293
|
+
print("\nNo relevant results found.")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
mode = result.get("mode", "flat")
|
|
297
|
+
total_nodes = sum(len(d["nodes"]) for d in result["documents"])
|
|
298
|
+
print(f"\nFound {total_nodes} result(s) in {len(result['documents'])} doc(s) [{mode} mode] ({elapsed:.1f}s)\n")
|
|
299
|
+
|
|
300
|
+
# Show paths if available
|
|
301
|
+
if args.show_path and "paths" in result:
|
|
302
|
+
for i, path_info in enumerate(result["paths"], 1):
|
|
303
|
+
score = path_info.get("score", 0)
|
|
304
|
+
doc_name = path_info.get("doc_name", "")
|
|
305
|
+
path_nodes = path_info.get("path", [])
|
|
306
|
+
reasons = path_info.get("reasons", [])
|
|
307
|
+
snippet = path_info.get("snippet", "")
|
|
308
|
+
|
|
309
|
+
print(f"Path {i} ({score:.2f}) {doc_name}")
|
|
310
|
+
for j, pn in enumerate(path_nodes):
|
|
311
|
+
indent = " " * j
|
|
312
|
+
connector = "|-- " if j < len(path_nodes) - 1 else "`-> "
|
|
313
|
+
print(f" {indent}{connector}{pn.get('title', '')}")
|
|
314
|
+
if snippet:
|
|
315
|
+
preview = snippet[:300]
|
|
316
|
+
if len(snippet) > 300:
|
|
317
|
+
preview += "..."
|
|
318
|
+
for line in preview.split("\n"):
|
|
319
|
+
print(f" {line}")
|
|
320
|
+
if reasons:
|
|
321
|
+
print(f" reasons: {'; '.join(reasons[:5])}")
|
|
322
|
+
print()
|
|
323
|
+
|
|
324
|
+
for doc_result in result["documents"]:
|
|
325
|
+
doc_name = doc_result["doc_name"]
|
|
326
|
+
for node in doc_result["nodes"]:
|
|
327
|
+
score = node.get("score", 0)
|
|
328
|
+
title = node.get("title", "")
|
|
329
|
+
line_start = node.get("line_start")
|
|
330
|
+
line_end = node.get("line_end")
|
|
331
|
+
text = node.get("text", "")
|
|
332
|
+
|
|
333
|
+
loc = f" (lines {line_start}-{line_end})" if line_start and line_end else ""
|
|
334
|
+
print(f"[{score:.2f}] {doc_name} > {title}{loc}")
|
|
335
|
+
|
|
336
|
+
if text:
|
|
337
|
+
preview = text[:500]
|
|
338
|
+
if len(text) > 500:
|
|
339
|
+
preview += "..."
|
|
340
|
+
for line in preview.split("\n"):
|
|
341
|
+
print(f" {line}")
|
|
342
|
+
print()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
# Main entry point
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
_SUBCOMMANDS = {"index", "search", "verify", "watch"}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# Subcommand: verify (DB consistency check + optional repair)
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def _add_verify_args(sub: argparse.ArgumentParser) -> None:
|
|
357
|
+
sub.add_argument("--db", required=True, type=str,
|
|
358
|
+
help="Path to SQLite database file to verify")
|
|
359
|
+
sub.add_argument("--repair", action="store_true",
|
|
360
|
+
help="Drop orphan rows surfaced by the verify pass")
|
|
361
|
+
sub.add_argument("--drop-missing-files", action="store_true",
|
|
362
|
+
help="(repair only) also delete docs whose source file is gone")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _run_verify(args) -> None:
|
|
366
|
+
from treesearch.fts import FTS5Index
|
|
367
|
+
|
|
368
|
+
if not os.path.isfile(args.db):
|
|
369
|
+
print(f"DB not found: {args.db}", file=sys.stderr)
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
|
|
372
|
+
fts = FTS5Index(db_path=args.db)
|
|
373
|
+
report = fts.verify_index()
|
|
374
|
+
print(f"Index: {args.db}")
|
|
375
|
+
print(f" healthy: {report['healthy']}")
|
|
376
|
+
for k in ("orphan_node_doc_ids", "orphan_fts_doc_ids", "orphan_meta_paths"):
|
|
377
|
+
items = report[k]
|
|
378
|
+
if items:
|
|
379
|
+
print(f" {k}: {len(items)} ({items[:5]}{'...' if len(items) > 5 else ''})")
|
|
380
|
+
missing = report["missing_source_paths"]
|
|
381
|
+
if missing:
|
|
382
|
+
print(f" missing_source_paths: {len(missing)} (first: {missing[0]})")
|
|
383
|
+
|
|
384
|
+
if args.repair:
|
|
385
|
+
removed = fts.repair_index(drop_missing_files=args.drop_missing_files)
|
|
386
|
+
print("Repair summary:")
|
|
387
|
+
for k, v in removed.items():
|
|
388
|
+
print(f" {k}: {v}")
|
|
389
|
+
fts.close()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
# Subcommand: watch (push-based incremental indexing)
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
def _add_watch_args(sub: argparse.ArgumentParser) -> None:
|
|
397
|
+
sub.add_argument("--paths", nargs="+", required=True,
|
|
398
|
+
help="Files or directories to watch (recursive for dirs)")
|
|
399
|
+
sub.add_argument("--db", type=str, default="./index.db",
|
|
400
|
+
help="SQLite database path (default: ./index.db)")
|
|
401
|
+
sub.add_argument("--debounce", type=float, default=0.5,
|
|
402
|
+
help="Coalesce events within this many seconds (default: 0.5)")
|
|
403
|
+
sub.add_argument("--ext", nargs="*", default=None,
|
|
404
|
+
help="Optional extension whitelist, e.g. --ext .md .py")
|
|
405
|
+
sub.add_argument("--poll", type=float, default=None,
|
|
406
|
+
help="Use polling backend with this interval (for NFS/CIFS)")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _run_watch(args) -> None:
|
|
410
|
+
from treesearch.watch import watch
|
|
411
|
+
print(f"Watching {args.paths} → {args.db} (Ctrl-C to stop)")
|
|
412
|
+
watch(
|
|
413
|
+
args.paths,
|
|
414
|
+
db_path=args.db,
|
|
415
|
+
debounce_s=args.debounce,
|
|
416
|
+
extensions=args.ext,
|
|
417
|
+
poll_seconds=args.poll,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _build_default_parser() -> argparse.ArgumentParser:
|
|
422
|
+
"""Parser for default mode: treesearch "query" path1 path2 ..."""
|
|
423
|
+
p = _DefaultArgumentParser(
|
|
424
|
+
prog="treesearch",
|
|
425
|
+
description=(
|
|
426
|
+
"TreeSearch: Structure-aware document retrieval.\n\n"
|
|
427
|
+
"Quick usage:\n"
|
|
428
|
+
' treesearch "search query" src/ docs/\n'
|
|
429
|
+
' treesearch "How does auth work?" project/\n\n'
|
|
430
|
+
"Advanced:\n"
|
|
431
|
+
" treesearch index --paths src/ docs/ --force\n"
|
|
432
|
+
" treesearch search --db ./index.db --query \"auth\"\n"
|
|
433
|
+
),
|
|
434
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
435
|
+
)
|
|
436
|
+
p.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging")
|
|
437
|
+
p.add_argument("query", nargs="?", default=None,
|
|
438
|
+
help="Search query. Supports auth* (prefix) and *auth* (contains regex)")
|
|
439
|
+
p.add_argument("paths", nargs="*", default=[],
|
|
440
|
+
help="Files, directories, or glob patterns to search")
|
|
441
|
+
p.add_argument("--regex", action="store_true",
|
|
442
|
+
help="Treat the positional query as a raw regex pattern")
|
|
443
|
+
p.add_argument("--fts-expression", type=str, default=None, dest="fts_expression",
|
|
444
|
+
help="Raw FTS5 expression, e.g. auth* or \"auth NEAR/5 token\"")
|
|
445
|
+
p.add_argument("--db", type=str, default="",
|
|
446
|
+
help="Path to SQLite database file (default: ./index.db)")
|
|
447
|
+
p.add_argument("--max-nodes", type=int, default=5,
|
|
448
|
+
help="Max result nodes per document (default: 5)")
|
|
449
|
+
p.add_argument("--search-mode", type=str, default="tree",
|
|
450
|
+
choices=["tree", "flat"],
|
|
451
|
+
help="Search mode: 'tree' (Best-First Search) or 'flat' (original FTS5-only). Default: tree")
|
|
452
|
+
p.add_argument("--show-path", action="store_true",
|
|
453
|
+
help="Show path-based results with traversal trace (tree mode only)")
|
|
454
|
+
return p
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _build_index_parser() -> argparse.ArgumentParser:
|
|
458
|
+
"""Parser for: treesearch index --paths ..."""
|
|
459
|
+
p = argparse.ArgumentParser(prog="treesearch index")
|
|
460
|
+
p.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging")
|
|
461
|
+
_add_index_args(p)
|
|
462
|
+
return p
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _build_search_parser() -> argparse.ArgumentParser:
|
|
466
|
+
"""Parser for: treesearch search --query ..."""
|
|
467
|
+
p = argparse.ArgumentParser(prog="treesearch search")
|
|
468
|
+
p.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging")
|
|
469
|
+
_add_search_args(p)
|
|
470
|
+
return p
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _detect_subcommand(argv: list[str]) -> str | None:
|
|
474
|
+
"""Detect if argv contains a subcommand (index/search) as the first non-flag arg."""
|
|
475
|
+
for arg in argv:
|
|
476
|
+
if arg.startswith("-"):
|
|
477
|
+
continue
|
|
478
|
+
if arg in _SUBCOMMANDS:
|
|
479
|
+
return arg
|
|
480
|
+
break # first positional arg is not a subcommand
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def main(argv: list[str] | None = None):
|
|
485
|
+
if argv is None:
|
|
486
|
+
argv = sys.argv[1:]
|
|
487
|
+
|
|
488
|
+
subcmd = _detect_subcommand(argv)
|
|
489
|
+
|
|
490
|
+
if subcmd == "index":
|
|
491
|
+
# Strip the subcommand word from argv
|
|
492
|
+
idx_argv = []
|
|
493
|
+
found = False
|
|
494
|
+
for a in argv:
|
|
495
|
+
if not found and a == "index":
|
|
496
|
+
found = True
|
|
497
|
+
continue
|
|
498
|
+
idx_argv.append(a)
|
|
499
|
+
parser = _build_index_parser()
|
|
500
|
+
args = parser.parse_args(idx_argv)
|
|
501
|
+
level = logging.DEBUG if args.verbose else logging.WARNING
|
|
502
|
+
_configure_cli_logging(level)
|
|
503
|
+
asyncio.run(_run_index(args))
|
|
504
|
+
|
|
505
|
+
elif subcmd == "search":
|
|
506
|
+
sch_argv = []
|
|
507
|
+
found = False
|
|
508
|
+
for a in argv:
|
|
509
|
+
if not found and a == "search":
|
|
510
|
+
found = True
|
|
511
|
+
continue
|
|
512
|
+
sch_argv.append(a)
|
|
513
|
+
parser = _build_search_parser()
|
|
514
|
+
args = parser.parse_args(sch_argv)
|
|
515
|
+
level = logging.DEBUG if args.verbose else logging.WARNING
|
|
516
|
+
_configure_cli_logging(level)
|
|
517
|
+
asyncio.run(_run_search(args))
|
|
518
|
+
|
|
519
|
+
elif subcmd in ("verify", "watch"):
|
|
520
|
+
sub_argv = []
|
|
521
|
+
found = False
|
|
522
|
+
for a in argv:
|
|
523
|
+
if not found and a == subcmd:
|
|
524
|
+
found = True
|
|
525
|
+
continue
|
|
526
|
+
sub_argv.append(a)
|
|
527
|
+
p = argparse.ArgumentParser(prog=f"treesearch {subcmd}")
|
|
528
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
529
|
+
if subcmd == "verify":
|
|
530
|
+
_add_verify_args(p)
|
|
531
|
+
else:
|
|
532
|
+
_add_watch_args(p)
|
|
533
|
+
args = p.parse_args(sub_argv)
|
|
534
|
+
level = logging.INFO if args.verbose else logging.WARNING
|
|
535
|
+
_configure_cli_logging(level)
|
|
536
|
+
if subcmd == "verify":
|
|
537
|
+
_run_verify(args)
|
|
538
|
+
else:
|
|
539
|
+
_run_watch(args)
|
|
540
|
+
|
|
541
|
+
else:
|
|
542
|
+
parser = _build_default_parser()
|
|
543
|
+
args = parser.parse_args(argv)
|
|
544
|
+
level = logging.DEBUG if args.verbose else logging.WARNING
|
|
545
|
+
_configure_cli_logging(level)
|
|
546
|
+
if args.query or args.fts_expression:
|
|
547
|
+
_run_default(args)
|
|
548
|
+
else:
|
|
549
|
+
parser.print_help()
|
|
550
|
+
sys.exit(0)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
if __name__ == "__main__":
|
|
554
|
+
main()
|