gabriel / muse public
cat.py python
162 lines 5.5 KB
00373ad0 feat: migrate CLI from typer to argparse (POSIX-compliant, order-independent) Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """muse cat — print the source of a specific symbol from HEAD or any commit.
2
3 Address format::
4
5 muse cat cache.py::LRUCache.get
6 muse cat cache.py::LRUCache.get --at abc123
7 muse cat cache.py::LRUCache.get --at v0.1.4
8
9 The ``::`` separator is the same format used throughout Muse's symbol graph.
10 The right-hand side is matched against the symbol's ``qualified_name`` first,
11 then ``name`` (allowing short references like ``get`` when unambiguous).
12
13 Exit codes
14 ----------
15 0 Symbol found and printed.
16 1 Address malformed, symbol not found, or file not tracked.
17 3 I/O error reading from the object store.
18 """
19
20 from __future__ import annotations
21
22 import argparse
23 import json
24 import logging
25 import pathlib
26 import sys
27
28 from muse.core.errors import ExitCode
29 from muse.core.object_store import read_object
30 from muse.core.repo import require_repo
31 from muse.core.store import (
32 get_commit_snapshot_manifest,
33 get_head_snapshot_manifest,
34 read_current_branch,
35 resolve_commit_ref,
36 )
37 from muse.core.validation import sanitize_display
38 from muse.plugins.code.ast_parser import adapter_for_path
39 from muse.plugins.registry import read_domain
40
41 logger = logging.getLogger(__name__)
42
43
44 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
45 """Register the cat subcommand."""
46 parser = subparsers.add_parser(
47 "cat",
48 help="Print the source code of a single symbol.",
49 description=__doc__,
50 )
51 parser.add_argument(
52 "address",
53 help="Symbol address: 'file.py::ClassName.method' or 'file.py::function_name'.",
54 )
55 parser.add_argument(
56 "--at", default=None,
57 help="Commit ref (SHA, branch, tag) to read from. Defaults to HEAD.",
58 )
59 parser.set_defaults(func=run)
60
61
62 def run(args: argparse.Namespace) -> None:
63 """Print the source code of a single symbol.
64
65 Address format: ``file.py::ClassName.method`` — the same ``::`` separator
66 used throughout Muse's symbol graph. The right side is matched against
67 ``qualified_name`` first, then ``name`` when unambiguous.
68 """
69 address: str = args.address
70 at: str | None = args.at
71
72 if "::" not in address:
73 print(
74 "❌ Address must contain '::' separator, e.g. cache.py::LRUCache.get",
75 file=sys.stderr,
76 )
77 raise SystemExit(ExitCode.USER_ERROR)
78
79 file_path, _, symbol_ref = address.partition("::")
80
81 root = require_repo()
82 repo_id = str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
83 branch = read_current_branch(root)
84 domain = read_domain(root)
85
86 if domain != "code":
87 print(f"❌ muse cat requires the code domain (current domain: {domain})", file=sys.stderr)
88 raise SystemExit(ExitCode.USER_ERROR)
89
90 # Resolve snapshot manifest for the requested ref.
91 manifest: dict[str, str]
92 if at is None:
93 manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
94 else:
95 resolved = resolve_commit_ref(root, repo_id, branch, at)
96 if resolved is None:
97 print(f"❌ Ref not found: {sanitize_display(at)}", file=sys.stderr)
98 raise SystemExit(ExitCode.USER_ERROR)
99 manifest = get_commit_snapshot_manifest(root, resolved.commit_id) or {}
100
101 if file_path not in manifest:
102 print(f"❌ File not tracked in snapshot: {sanitize_display(file_path)}", file=sys.stderr)
103 raise SystemExit(ExitCode.USER_ERROR)
104
105 raw = read_object(root, manifest[file_path])
106 if raw is None:
107 print(f"❌ Blob not found in object store: {manifest[file_path][:12]}", file=sys.stderr)
108 raise SystemExit(ExitCode.INTERNAL_ERROR)
109
110 try:
111 text = raw.decode("utf-8", errors="replace")
112 except Exception:
113 print("❌ File is not valid UTF-8.", file=sys.stderr)
114 raise SystemExit(ExitCode.USER_ERROR)
115
116 # Parse symbol tree using the file-appropriate adapter.
117 adapter = adapter_for_path(file_path)
118 tree = adapter.parse_symbols(raw, file_path)
119
120 if not tree:
121 print(f"❌ No symbols found in {sanitize_display(file_path)}", file=sys.stderr)
122 raise SystemExit(ExitCode.USER_ERROR)
123
124 # Match against qualified_name first, then fall back to plain name.
125 match = next(
126 (rec for rec in tree.values() if rec["qualified_name"] == symbol_ref),
127 None,
128 )
129 if match is None:
130 candidates = [rec for rec in tree.values() if rec["name"] == symbol_ref]
131 if len(candidates) == 1:
132 match = candidates[0]
133 elif len(candidates) > 1:
134 names = ", ".join(rec["qualified_name"] for rec in candidates)
135 print(
136 f"❌ Ambiguous symbol '{sanitize_display(symbol_ref)}'. "
137 f"Qualify it: {names}",
138 file=sys.stderr,
139 )
140 raise SystemExit(ExitCode.USER_ERROR)
141
142 if match is None:
143 available = ", ".join(sorted(rec["qualified_name"] for rec in tree.values()))
144 print(
145 f"❌ Symbol '{sanitize_display(symbol_ref)}' not found in "
146 f"{sanitize_display(file_path)}.\n"
147 f" Available: {available}",
148 file=sys.stderr,
149 )
150 raise SystemExit(ExitCode.USER_ERROR)
151
152 # Slice source lines (SymbolRecord lineno is 1-indexed).
153 lines = text.splitlines()
154 start = max(0, match["lineno"] - 1)
155 end = min(len(lines), match["end_lineno"])
156
157 ref_label = sanitize_display(at) if at else "HEAD"
158 print(
159 f"# {file_path}::{match['qualified_name']}"
160 f" L{match['lineno']}–{match['end_lineno']} ({ref_label})"
161 )
162 print("\n".join(lines[start:end]))