rust-analyzer-db 0.2.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
rust_analyzer/cli.py ADDED
@@ -0,0 +1,663 @@
1
+ #!/usr/bin/env python3
2
+ """rust-analyzer-db: Professional Rust code analysis tool.
3
+
4
+ Scans Rust source with tree-sitter, extracts structs/impls/functions/etc.
5
+ into a SQLite DB, and provides query, search, call-graph, complexity
6
+ analysis, and public-API surface commands.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from .db import RustCodeDB
20
+ from .exceptions import AnalysisError, ParseError
21
+ from .extractor import ExtractedItem, FileExtraction, extract_file, extract_calls
22
+ from . import graph as graphmod
23
+ from .logging import get_logger, setup_logging
24
+
25
+ log = get_logger("cli")
26
+
27
+ DEFAULT_DB = "rust_code.db"
28
+
29
+
30
+ # ------------------------------------------------------------------
31
+ # Helpers
32
+ # ------------------------------------------------------------------
33
+
34
+ def _hash_bytes(data: bytes) -> str:
35
+ return hashlib.sha256(data).hexdigest()
36
+
37
+
38
+ def _store_item(
39
+ db: RustCodeDB,
40
+ file_id: int,
41
+ item: ExtractedItem,
42
+ src: bytes,
43
+ parent_id: int | None = None,
44
+ ) -> int:
45
+ complexity = item.complexity
46
+ item_id = db.insert_item(
47
+ file_id=file_id,
48
+ kind=item.kind,
49
+ name=item.name,
50
+ start_line=item.start_line,
51
+ end_line=item.end_line,
52
+ source=item.source,
53
+ target=item.target,
54
+ trait_name=item.trait_name,
55
+ visibility=item.visibility,
56
+ signature=item.signature,
57
+ doc=item.doc,
58
+ attributes=item.attributes,
59
+ parent_id=parent_id,
60
+ is_pub=item.is_pub,
61
+ is_const_fn=item.is_const_fn,
62
+ is_async=item.is_async,
63
+ is_unsafe=item.is_unsafe,
64
+ cyclomatic_complexity=complexity.cyclomatic if complexity else 1,
65
+ cognitive_complexity=complexity.cognitive if complexity else 0,
66
+ nesting_depth=complexity.nesting_depth if complexity else 0,
67
+ num_branches=complexity.num_branches if complexity else 0,
68
+ num_function_calls=complexity.num_function_calls if complexity else 0,
69
+ lines_of_code=complexity.lines_of_code if complexity else 0,
70
+ )
71
+ # store generic params
72
+ if item.generic_params:
73
+ db.insert_generic_params(
74
+ item_id,
75
+ [(g.name, ", ".join(g.bounds), g.default) for g in item.generic_params],
76
+ )
77
+ # store lifetime params
78
+ if item.lifetime_params:
79
+ db.insert_lifetime_params(
80
+ item_id,
81
+ [(l.name, ", ".join(l.bounds)) for l in item.lifetime_params],
82
+ )
83
+ # extract calls from function/method bodies
84
+ if item.body_node is not None and item.kind in ("function", "method"):
85
+ for edge in extract_calls(item.body_node, src):
86
+ db.insert_call(
87
+ caller_id=item_id,
88
+ callee_name=edge.callee_name,
89
+ line=edge.line,
90
+ receiver=edge.receiver,
91
+ is_method_call=edge.is_method_call,
92
+ )
93
+ for child in item.children:
94
+ _store_item(db, file_id, child, src, parent_id=item_id)
95
+ return item_id
96
+
97
+
98
+ def _output_json(data: Any) -> None:
99
+ """Print data as JSON to stdout."""
100
+ print(json.dumps(data, indent=2, default=str))
101
+
102
+
103
+ def _output_rows(rows: list[Any], fields: list[str] | None = None) -> list[dict[str, Any]]:
104
+ """Convert rows to list of dicts."""
105
+ return [dict(r) for r in rows]
106
+
107
+
108
+ # ------------------------------------------------------------------
109
+ # Commands
110
+ # ------------------------------------------------------------------
111
+
112
+ def cmd_scan(args: argparse.Namespace) -> int:
113
+ """Scan Rust source files and populate the database."""
114
+ root = Path(args.path)
115
+ if not root.exists():
116
+ log.error("Path not found: %s", root)
117
+ return 1
118
+
119
+ rs_files = [root] if root.is_file() else sorted(root.rglob("*.rs"))
120
+ if not rs_files:
121
+ log.info("No .rs files found.")
122
+ return 0
123
+
124
+ skip_dirs = {"target", ".git", "node_modules", ".cargo"}
125
+ rs_files = [
126
+ f for f in rs_files
127
+ if not any(part in skip_dirs for part in f.parts)
128
+ ]
129
+
130
+ db = RustCodeDB(args.db)
131
+ total_items = 0
132
+ total_uses = 0
133
+ total_externs = 0
134
+ scanned = 0
135
+ skipped = 0
136
+ errors = 0
137
+ start_time = time.monotonic()
138
+
139
+ for i, f in enumerate(rs_files):
140
+ if not args.quiet:
141
+ progress = f"[{i+1}/{len(rs_files)}]" if len(rs_files) > 1 else ""
142
+ print(f"\r {progress} Scanning {f}...", end="", flush=True) if len(rs_files) > 10 else None
143
+
144
+ data = f.read_bytes()
145
+ file_hash = _hash_bytes(data)
146
+ abs_path = str(f.resolve())
147
+ if not args.force and db.file_unchanged(abs_path, file_hash):
148
+ skipped += 1
149
+ continue
150
+
151
+ file_id = db.upsert_file(abs_path, f.stat().st_mtime, file_hash)
152
+ try:
153
+ extraction = extract_file(data)
154
+ except ParseError as e:
155
+ log.warning("Parse error in %s: %s", f, e.reason)
156
+ errors += 1
157
+ continue
158
+ except Exception as e:
159
+ log.error("Failed to parse %s: %s", f, e)
160
+ errors += 1
161
+ continue
162
+
163
+ for item in extraction.items:
164
+ _store_item(db, file_id, item, data)
165
+
166
+ db.update_file_lines(file_id, extraction.total_lines)
167
+
168
+ for use in extraction.use_declarations:
169
+ db.insert_use_decl(
170
+ file_id, use.path, use.alias, use.is_glob,
171
+ use.start_line, use.end_line,
172
+ )
173
+ total_uses += 1
174
+
175
+ for ext in extraction.extern_crates:
176
+ db.insert_extern_crate(
177
+ file_id, ext.name, ext.alias, ext.start_line, ext.end_line,
178
+ )
179
+ total_externs += 1
180
+
181
+ db.commit()
182
+ count = _count_items(extraction.items)
183
+ total_items += count
184
+ scanned += 1
185
+ if not args.quiet and len(rs_files) <= 10:
186
+ log.info(" %s -> %d items (%d LOC)", f, count, extraction.total_lines)
187
+
188
+ elapsed = time.monotonic() - start_time
189
+
190
+ if scanned:
191
+ total, resolved = db.resolve_calls()
192
+ log.info("Resolved %d/%d call edges.", resolved, total)
193
+
194
+ if not args.quiet:
195
+ print() # newline after progress
196
+ log.info(
197
+ "Scanned %d file(s), skipped %d unchanged, errors %d, "
198
+ "extracted %d items, %d use decls, %d extern crates. "
199
+ "DB: %s (%.1fs)",
200
+ scanned, skipped, errors, total_items, total_uses, total_externs,
201
+ args.db, elapsed,
202
+ )
203
+ db.close()
204
+ return 0
205
+
206
+
207
+ def _count_items(items: list[ExtractedItem]) -> int:
208
+ """Recursively count items including children."""
209
+ count = len(items)
210
+ for item in items:
211
+ count += _count_items(item.children)
212
+ return count
213
+
214
+
215
+ def _print_row(row: Any, show_source: bool = False) -> None:
216
+ loc = f"{row['file_path']}:{row['start_line']}-{row['end_line']}"
217
+ header = f"[{row['id']}] {row['kind']:<12} {row['name']}"
218
+ if row["target"] and row["kind"] not in ("impl",):
219
+ header += f" (impl for {row['target']})"
220
+ vis = row["visibility"] or ""
221
+ cx = ""
222
+ if row["cyclomatic_complexity"] and row["cyclomatic_complexity"] > 1:
223
+ cx = f" CC={row['cyclomatic_complexity']}"
224
+ print(f"{header} {vis}{cx} {loc}")
225
+ if show_source:
226
+ print("-" * 70)
227
+ print(row["source"])
228
+ print("-" * 70)
229
+
230
+
231
+ def cmd_list(args: argparse.Namespace) -> int:
232
+ """List items with optional filters."""
233
+ db = RustCodeDB(args.db)
234
+ rows = db.list_items(
235
+ kind=args.kind, name=args.name, target=args.target,
236
+ file_like=args.file, limit=args.limit,
237
+ )
238
+ if not rows:
239
+ log.info("No matching items.")
240
+ return 0
241
+
242
+ if args.json:
243
+ _output_json(_output_rows(rows))
244
+ else:
245
+ for row in rows:
246
+ _print_row(row)
247
+ log.info("%d item(s).", len(rows))
248
+ db.close()
249
+ return 0
250
+
251
+
252
+ def cmd_show(args: argparse.Namespace) -> int:
253
+ """Show full source code of matching items."""
254
+ db = RustCodeDB(args.db)
255
+ rows = db.list_items(kind=args.kind, name=args.name, limit=args.limit)
256
+ if not rows:
257
+ log.info("No matching items.")
258
+ return 0
259
+
260
+ if args.json:
261
+ _output_json(_output_rows(rows))
262
+ else:
263
+ for row in rows:
264
+ if row["doc"]:
265
+ print(row["doc"])
266
+ if row["attributes"]:
267
+ print(row["attributes"])
268
+ _print_row(row, show_source=True)
269
+ print()
270
+ db.close()
271
+ return 0
272
+
273
+
274
+ def cmd_methods(args: argparse.Namespace) -> int:
275
+ """List methods on a type or trait."""
276
+ db = RustCodeDB(args.db)
277
+ rows = db.get_methods_of(args.target)
278
+ if not rows:
279
+ log.info("No methods found for '%s'.", args.target)
280
+ return 0
281
+
282
+ if args.json:
283
+ _output_json(_output_rows(rows))
284
+ else:
285
+ for row in rows:
286
+ _print_row(row, show_source=args.full)
287
+ log.info("%d method(s) on types matching '%s'.", len(rows), args.target)
288
+ db.close()
289
+ return 0
290
+
291
+
292
+ def cmd_search(args: argparse.Namespace) -> int:
293
+ """Full-text search over name/signature/doc/source."""
294
+ db = RustCodeDB(args.db)
295
+ try:
296
+ rows = db.search(args.query, kind=args.kind, limit=args.limit)
297
+ except Exception as e:
298
+ log.error("Search error (try quoting the query): %s", e)
299
+ return 1
300
+ if not rows:
301
+ log.info("No matches.")
302
+ return 0
303
+
304
+ if args.json:
305
+ _output_json(_output_rows(rows))
306
+ else:
307
+ for row in rows:
308
+ _print_row(row, show_source=args.full)
309
+ log.info("%d match(es).", len(rows))
310
+ db.close()
311
+ return 0
312
+
313
+
314
+ def cmd_serve(args: argparse.Namespace) -> int:
315
+ """Start the web frontend server."""
316
+ try:
317
+ import uvicorn
318
+ except ImportError:
319
+ log.error("uvicorn is required for the web server. Install with: pip install uvicorn")
320
+ return 1
321
+
322
+ from .web import create_app
323
+
324
+ if not Path(args.db).exists():
325
+ log.error("Database not found: %s. Run 'scan' first.", args.db)
326
+ return 1
327
+
328
+ app = create_app(args.db)
329
+ log.info("Starting web server at http://%s:%d", args.host, args.port)
330
+ log.info("Database: %s", args.db)
331
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info")
332
+ return 0
333
+
334
+
335
+ def cmd_mcp(args: argparse.Namespace) -> int:
336
+ """Start the MCP server over stdio transport."""
337
+ from .mcp_server import run_server
338
+
339
+ if not Path(args.db).exists():
340
+ log.error("Database not found: %s. Run 'scan' first.", args.db)
341
+ return 1
342
+
343
+ log.info("Starting MCP server (db: %s)", args.db)
344
+ run_server(args.db)
345
+ return 0
346
+
347
+
348
+ def cmd_graph(args: argparse.Namespace) -> int:
349
+ """Render a call graph."""
350
+ db = RustCodeDB(args.db)
351
+
352
+ if args.root:
353
+ rows = db.list_items(name=args.root, limit=50)
354
+ rows = [r for r in rows if r["kind"] in ("function", "method")]
355
+ if not rows:
356
+ log.error("No function/method found matching '%s'.", args.root)
357
+ return 1
358
+ root_ids = [r["id"] for r in rows]
359
+ names = ", ".join(f"{r['name']} ({r['file_path']}:{r['start_line']})" for r in rows)
360
+ log.info("Root node(s): %s", names)
361
+ g = graphmod.build_subgraph(
362
+ db, root_ids, depth=args.depth, direction=args.direction,
363
+ include_unresolved=not args.no_unresolved,
364
+ )
365
+ title = f"Call graph: {args.root} (depth {args.depth}, {args.direction})"
366
+ else:
367
+ kinds = set(args.kind.split(",")) if args.kind else None
368
+ g = graphmod.build_whole_graph(db, include_unresolved=not args.no_unresolved, kinds=kinds)
369
+ title = "Call graph (whole project)"
370
+
371
+ if not g.nodes:
372
+ log.info("No call edges found. Did you run `scan` on this project?")
373
+ return 0
374
+
375
+ log.info("Graph: %d node(s), %d edge(s).", len(g.nodes), len(g.edges))
376
+ if len(g.nodes) > 400:
377
+ log.warning(
378
+ "Large graph — use --root <function> to focus or --no-unresolved "
379
+ "to drop external calls."
380
+ )
381
+
382
+ fmt = args.format
383
+ out = args.output
384
+
385
+ if args.json:
386
+ _output_json({
387
+ "title": title,
388
+ "node_count": len(g.nodes),
389
+ "edge_count": len(g.edges),
390
+ "nodes": [{"id": nid, **meta} for nid, meta in g.nodes.items()],
391
+ "edges": [{"from": s, "to": d} for s, d in sorted(g.edges)],
392
+ })
393
+ db.close()
394
+ return 0
395
+
396
+ if fmt == "dot":
397
+ dot_str = graphmod.to_dot(g, title)
398
+ Path(out).write_text(dot_str)
399
+ log.info("Wrote DOT file: %s", out)
400
+ elif fmt == "html":
401
+ html_str = graphmod.to_html(g, title)
402
+ Path(out).write_text(html_str)
403
+ log.info("Wrote interactive HTML: %s (open in a browser)", out)
404
+ elif fmt in ("svg", "png", "pdf"):
405
+ dot_str = graphmod.to_dot(g, title)
406
+ try:
407
+ ok = graphmod.render_dot(dot_str, out, fmt)
408
+ except RuntimeError as e:
409
+ log.error("Graphviz render failed: %s", e)
410
+ return 1
411
+ if not ok:
412
+ fallback = str(Path(out).with_suffix(".dot"))
413
+ Path(fallback).write_text(dot_str)
414
+ log.warning("Graphviz 'dot' not found; wrote DOT to %s", fallback)
415
+ else:
416
+ log.info("Wrote %s: %s", fmt.upper(), out)
417
+ db.close()
418
+ return 0
419
+
420
+
421
+ def cmd_stats(args: argparse.Namespace) -> int:
422
+ """Show project statistics."""
423
+ db = RustCodeDB(args.db)
424
+ n_files, rows = db.stats()
425
+ total, resolved = db.call_graph_stats()
426
+
427
+ if args.json:
428
+ data = {
429
+ "files": n_files,
430
+ "items_by_kind": {r["kind"]: r["n"] for r in rows},
431
+ "total_calls": total,
432
+ "resolved_calls": resolved,
433
+ }
434
+ _output_json(data)
435
+ else:
436
+ total_items = sum(r["n"] for r in rows)
437
+ print(f"Files scanned: {n_files}")
438
+ print(f"Total items: {total_items}")
439
+ print(f"Call edges: {total} ({resolved} resolved)")
440
+ print()
441
+ print("Items by kind:")
442
+ for row in rows:
443
+ bar = "█" * min(row["n"], 50)
444
+ print(f" {row['kind']:<14} {row['n']:>6} {bar}")
445
+ db.close()
446
+ return 0
447
+
448
+
449
+ def cmd_complexity(args: argparse.Namespace) -> int:
450
+ """Show complexity report for functions/methods."""
451
+ db = RustCodeDB(args.db)
452
+ rows = db.complexity_report(min_complexity=args.min_complexity)
453
+
454
+ if not rows:
455
+ log.info("No functions exceed complexity threshold %d.", args.min_complexity)
456
+ return 0
457
+
458
+ if args.json:
459
+ _output_json(_output_rows(rows))
460
+ else:
461
+ print(f"{'Kind':<10} {'Name':<35} {'CC':>4} {'Cog':>4} {'Nest':>5} {'LOC':>5} Location")
462
+ print("-" * 90)
463
+ for row in rows:
464
+ loc = f"{row['file_path']}:{row['start_line']}"
465
+ print(
466
+ f"{row['kind']:<10} {row['name']:<35} "
467
+ f"{row['cyclomatic_complexity']:>4} {row['cognitive_complexity']:>4} "
468
+ f"{row['nesting_depth']:>5} {row['lines_of_code']:>5} {loc}"
469
+ )
470
+ log.info("%d function(s) above complexity threshold.", len(rows))
471
+ db.close()
472
+ return 0
473
+
474
+
475
+ def cmd_api(args: argparse.Namespace) -> int:
476
+ """Show the public API surface of the project."""
477
+ db = RustCodeDB(args.db)
478
+ rows = db.api_surface()
479
+
480
+ if not rows:
481
+ log.info("No public items found.")
482
+ return 0
483
+
484
+ if args.json:
485
+ _output_json(_output_rows(rows))
486
+ else:
487
+ by_kind: dict[str, list[Any]] = {}
488
+ for row in rows:
489
+ by_kind.setdefault(row["kind"], []).append(row)
490
+ for kind in ("struct", "enum", "trait", "function", "method", "const", "type_alias", "union"):
491
+ items = by_kind.get(kind, [])
492
+ if not items:
493
+ continue
494
+ print(f"\n{kind.upper()} ({len(items)}):")
495
+ for row in items:
496
+ vis = row["visibility"] or ""
497
+ loc = f"{row['file_path']}:{row['start_line']}"
498
+ sig = f" {row['signature']}" if row["signature"] else ""
499
+ print(f" {vis} {row['name']}{sig} -- {loc}")
500
+ log.info("%d public item(s) total.", len(rows))
501
+ db.close()
502
+ return 0
503
+
504
+
505
+ def cmd_deps(args: argparse.Namespace) -> int:
506
+ """Show external dependencies (use/extern crate)."""
507
+ db = RustCodeDB(args.db)
508
+
509
+ externs = db.all_extern_crates()
510
+ uses = db.get_use_declarations()
511
+
512
+ if args.json:
513
+ _output_json({
514
+ "extern_crates": [dict(r) for r in externs],
515
+ "use_declarations": [dict(r) for r in uses],
516
+ })
517
+ else:
518
+ if externs:
519
+ print("External crates:")
520
+ for r in externs:
521
+ alias = f" as {r['alias']}" if r["alias"] else ""
522
+ print(f" {r['name']}{alias}")
523
+ else:
524
+ print("No extern crate declarations found.")
525
+
526
+ # Group use paths by top-level crate
527
+ crate_uses: dict[str, list[str]] = {}
528
+ for r in uses:
529
+ path = r["path"]
530
+ top = path.split("::")[0]
531
+ crate_uses.setdefault(top, []).append(path)
532
+
533
+ if crate_uses:
534
+ print(f"\nUse declarations ({len(crate_uses)} top-level crates):")
535
+ for crate_name in sorted(crate_uses):
536
+ paths = crate_uses[crate_name]
537
+ print(f" {crate_name} ({len(paths)} imports)")
538
+ if args.full:
539
+ for p in sorted(paths):
540
+ print(f" use {p};")
541
+ db.close()
542
+ return 0
543
+
544
+
545
+ # ------------------------------------------------------------------
546
+ # CLI parser
547
+ # ------------------------------------------------------------------
548
+
549
+ def build_parser() -> argparse.ArgumentParser:
550
+ db_parent = argparse.ArgumentParser(add_help=False)
551
+ db_parent.add_argument("--db", default=DEFAULT_DB, help=f"SQLite DB path (default: {DEFAULT_DB})")
552
+
553
+ output_parent = argparse.ArgumentParser(add_help=False)
554
+ output_parent.add_argument("--json", action="store_true", help="Output as JSON")
555
+
556
+ p = argparse.ArgumentParser(
557
+ prog="rust-analyzer-db",
558
+ description=__doc__,
559
+ formatter_class=argparse.RawDescriptionHelpFormatter,
560
+ parents=[db_parent],
561
+ )
562
+ p.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
563
+ sub = p.add_subparsers(dest="command", required=True)
564
+
565
+ # scan
566
+ s = sub.add_parser("scan", help="Scan a file or directory of .rs files", parents=[db_parent])
567
+ s.add_argument("path", help="File or directory to scan")
568
+ s.add_argument("--force", action="store_true", help="Re-parse even unchanged files")
569
+ s.add_argument("-q", "--quiet", action="store_true", help="Suppress progress output")
570
+ s.set_defaults(func=cmd_scan)
571
+
572
+ # list
573
+ l = sub.add_parser("list", help="List items, optionally filtered", parents=[db_parent, output_parent])
574
+ l.add_argument("--kind", help="struct|enum|trait|impl|function|method|const|static|mod|...")
575
+ l.add_argument("--name", help="Substring match on name")
576
+ l.add_argument("--target", help="Substring match on impl/method target type")
577
+ l.add_argument("--file", help="Substring match on file path")
578
+ l.add_argument("--limit", type=int, default=200)
579
+ l.set_defaults(func=cmd_list)
580
+
581
+ # show
582
+ sh = sub.add_parser("show", help="Show full source of matching item(s)", parents=[db_parent, output_parent])
583
+ sh.add_argument("name", help="Exact-ish name (substring match)")
584
+ sh.add_argument("--kind", help="Restrict to a kind")
585
+ sh.add_argument("--limit", type=int, default=20)
586
+ sh.set_defaults(func=cmd_show)
587
+
588
+ # methods
589
+ m = sub.add_parser("methods", help="List methods on a type/trait", parents=[db_parent, output_parent])
590
+ m.add_argument("target", help="Type name, e.g. MyStruct")
591
+ m.add_argument("--full", action="store_true", help="Print full source of each method")
592
+ m.set_defaults(func=cmd_methods)
593
+
594
+ # search
595
+ se = sub.add_parser("search", help="Full-text search over name/signature/doc/source", parents=[db_parent, output_parent])
596
+ se.add_argument("query", help="FTS5 query, e.g. 'parse OR tokenize'")
597
+ se.add_argument("--kind", help="Restrict to a kind")
598
+ se.add_argument("--limit", type=int, default=50)
599
+ se.add_argument("--full", action="store_true", help="Print full source of each match")
600
+ se.set_defaults(func=cmd_search)
601
+
602
+ # stats
603
+ st = sub.add_parser("stats", help="Show summary counts", parents=[db_parent, output_parent])
604
+ st.set_defaults(func=cmd_stats)
605
+
606
+ # complexity
607
+ cx = sub.add_parser("complexity", help="Show cyclomatic complexity report", parents=[db_parent, output_parent])
608
+ cx.add_argument("--min", dest="min_complexity", type=int, default=5,
609
+ help="Minimum complexity to report (default 5)")
610
+ cx.set_defaults(func=cmd_complexity)
611
+
612
+ # api
613
+ api = sub.add_parser("api", help="Show public API surface", parents=[db_parent, output_parent])
614
+ api.set_defaults(func=cmd_api)
615
+
616
+ # deps
617
+ dp = sub.add_parser("deps", help="Show external dependencies (use/extern crate)", parents=[db_parent, output_parent])
618
+ dp.add_argument("--full", action="store_true", help="Show all use paths (not just crate summary)")
619
+ dp.set_defaults(func=cmd_deps)
620
+
621
+ # graph
622
+ g = sub.add_parser("graph", help="Render a call graph (execution flow)", parents=[db_parent, output_parent])
623
+ g.add_argument("--root", help="Function/method name to center on (substring match)")
624
+ g.add_argument("--depth", type=int, default=2, help="BFS depth from --root (default 2)")
625
+ g.add_argument("--direction", choices=["callees", "callers", "both"], default="both",
626
+ help="With --root: callees, callers, or both (default both)")
627
+ g.add_argument("--kind", help="Whole-graph only: comma-separated caller kinds")
628
+ g.add_argument("--no-unresolved", action="store_true",
629
+ help="Omit calls that couldn't be resolved")
630
+ g.add_argument("--format", choices=["svg", "png", "pdf", "dot", "html"], default="svg",
631
+ help="Output format (default svg)")
632
+ g.add_argument("-o", "--output", default="callgraph.svg", help="Output file path")
633
+ g.set_defaults(func=cmd_graph)
634
+
635
+ # serve
636
+ sv = sub.add_parser("serve", help="Start the web frontend server", parents=[db_parent])
637
+ sv.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
638
+ sv.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
639
+ sv.set_defaults(func=cmd_serve)
640
+
641
+ # mcp
642
+ mc = sub.add_parser("mcp", help="Start the MCP server (Model Context Protocol)", parents=[db_parent])
643
+ mc.set_defaults(func=cmd_mcp)
644
+
645
+ return p
646
+
647
+
648
+ def main(argv: list[str] | None = None) -> int:
649
+ parser = build_parser()
650
+ args = parser.parse_args(argv)
651
+ setup_logging(verbose=getattr(args, "verbose", False))
652
+ try:
653
+ return args.func(args)
654
+ except AnalysisError as e:
655
+ log.error("%s", e)
656
+ return 1
657
+ except KeyboardInterrupt:
658
+ log.warning("Interrupted.")
659
+ return 130
660
+
661
+
662
+ if __name__ == "__main__":
663
+ sys.exit(main())