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.
Files changed (39) hide show
  1. treesearch/__init__.py +53 -0
  2. treesearch/__main__.py +6 -0
  3. treesearch/_bin/pst-extract.exe +0 -0
  4. treesearch/cli.py +554 -0
  5. treesearch/config.py +206 -0
  6. treesearch/fts.py +2293 -0
  7. treesearch/heuristics.py +425 -0
  8. treesearch/indexer.py +2038 -0
  9. treesearch/parsers/__init__.py +62 -0
  10. treesearch/parsers/anydoc_parser.py +193 -0
  11. treesearch/parsers/ast_parser.py +136 -0
  12. treesearch/parsers/docx_parser.py +304 -0
  13. treesearch/parsers/email_html_md.py +60 -0
  14. treesearch/parsers/excel_parser.py +218 -0
  15. treesearch/parsers/html_parser.py +172 -0
  16. treesearch/parsers/image_metadata.py +345 -0
  17. treesearch/parsers/image_parser.py +59 -0
  18. treesearch/parsers/image_store.py +182 -0
  19. treesearch/parsers/markitdown_parser.py +258 -0
  20. treesearch/parsers/mhtml_parser.py +108 -0
  21. treesearch/parsers/pdf_parser.py +409 -0
  22. treesearch/parsers/pst_attachment_store.py +156 -0
  23. treesearch/parsers/pst_parser.py +733 -0
  24. treesearch/parsers/registry.py +405 -0
  25. treesearch/parsers/treesitter_parser.py +433 -0
  26. treesearch/pathutil.py +227 -0
  27. treesearch/py.typed +0 -0
  28. treesearch/ripgrep.py +159 -0
  29. treesearch/search.py +935 -0
  30. treesearch/tokenizer.py +176 -0
  31. treesearch/tree.py +393 -0
  32. treesearch/tree_searcher.py +1006 -0
  33. treesearch/treesearch.py +574 -0
  34. treesearch/watch.py +305 -0
  35. treesearchlib-1.1.0.dist-info/METADATA +124 -0
  36. treesearchlib-1.1.0.dist-info/RECORD +39 -0
  37. treesearchlib-1.1.0.dist-info/WHEEL +5 -0
  38. treesearchlib-1.1.0.dist-info/entry_points.txt +2 -0
  39. 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
@@ -0,0 +1,6 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Allow `python -m treesearch` to invoke the CLI."""
3
+ from treesearch.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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()