gabriel / muse public
cat.py python
158 lines 5.3 KB
65b33a65 feat: add muse cat and muse diff --text Gabriel Cardona <gabriel@tellurstori.com> 3d 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 json
23 import logging
24 import pathlib
25
26 import typer
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 app = typer.Typer()
44
45
46 @app.callback(invoke_without_command=True)
47 def cat(
48 ctx: typer.Context,
49 address: str = typer.Argument(
50 ...,
51 help="Symbol address: 'file.py::ClassName.method' or 'file.py::function_name'.",
52 ),
53 at: str | None = typer.Option(
54 None,
55 "--at",
56 help="Commit ref (SHA, branch, tag) to read from. Defaults to HEAD.",
57 ),
58 ) -> None:
59 """Print the source code of a single symbol.
60
61 Address format: ``file.py::ClassName.method`` — the same ``::`` separator
62 used throughout Muse's symbol graph. The right side is matched against
63 ``qualified_name`` first, then ``name`` when unambiguous.
64 """
65 if "::" not in address:
66 typer.echo(
67 "❌ Address must contain '::' separator, e.g. cache.py::LRUCache.get",
68 err=True,
69 )
70 raise typer.Exit(code=ExitCode.USER_ERROR)
71
72 file_path, _, symbol_ref = address.partition("::")
73
74 root = require_repo()
75 repo_id = str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
76 branch = read_current_branch(root)
77 domain = read_domain(root)
78
79 if domain != "code":
80 typer.echo(f"❌ muse cat requires the code domain (current domain: {domain})", err=True)
81 raise typer.Exit(code=ExitCode.USER_ERROR)
82
83 # Resolve snapshot manifest for the requested ref.
84 manifest: dict[str, str]
85 if at is None:
86 manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
87 else:
88 resolved = resolve_commit_ref(root, repo_id, branch, at)
89 if resolved is None:
90 typer.echo(f"❌ Ref not found: {sanitize_display(at)}", err=True)
91 raise typer.Exit(code=ExitCode.USER_ERROR)
92 manifest = get_commit_snapshot_manifest(root, resolved.commit_id) or {}
93
94 if file_path not in manifest:
95 typer.echo(f"❌ File not tracked in snapshot: {sanitize_display(file_path)}", err=True)
96 raise typer.Exit(code=ExitCode.USER_ERROR)
97
98 raw = read_object(root, manifest[file_path])
99 if raw is None:
100 typer.echo(f"❌ Blob not found in object store: {manifest[file_path][:12]}", err=True)
101 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
102
103 try:
104 text = raw.decode("utf-8", errors="replace")
105 except Exception:
106 typer.echo("❌ File is not valid UTF-8.", err=True)
107 raise typer.Exit(code=ExitCode.USER_ERROR)
108
109 # Parse symbol tree using the file-appropriate adapter.
110 adapter = adapter_for_path(file_path)
111 tree = adapter.parse_symbols(raw, file_path)
112
113 if not tree:
114 typer.echo(f"❌ No symbols found in {sanitize_display(file_path)}", err=True)
115 raise typer.Exit(code=ExitCode.USER_ERROR)
116
117 # Match against qualified_name first, then fall back to plain name.
118 match = next(
119 (rec for rec in tree.values() if rec["qualified_name"] == symbol_ref),
120 None,
121 )
122 if match is None:
123 candidates = [rec for rec in tree.values() if rec["name"] == symbol_ref]
124 if len(candidates) == 1:
125 match = candidates[0]
126 elif len(candidates) > 1:
127 names = ", ".join(rec["qualified_name"] for rec in candidates)
128 typer.echo(
129 f"❌ Ambiguous symbol '{sanitize_display(symbol_ref)}'. "
130 f"Qualify it: {names}",
131 err=True,
132 )
133 raise typer.Exit(code=ExitCode.USER_ERROR)
134
135 if match is None:
136 available = ", ".join(sorted(rec["qualified_name"] for rec in tree.values()))
137 typer.echo(
138 f"❌ Symbol '{sanitize_display(symbol_ref)}' not found in "
139 f"{sanitize_display(file_path)}.\n"
140 f" Available: {available}",
141 err=True,
142 )
143 raise typer.Exit(code=ExitCode.USER_ERROR)
144
145 # Slice source lines (SymbolRecord lineno is 1-indexed).
146 lines = text.splitlines()
147 start = max(0, match["lineno"] - 1)
148 end = min(len(lines), match["end_lineno"])
149
150 ref_label = sanitize_display(at) if at else "HEAD"
151 typer.echo(
152 typer.style(
153 f"# {file_path}::{match['qualified_name']}"
154 f" L{match['lineno']}–{match['end_lineno']} ({ref_label})",
155 dim=True,
156 )
157 )
158 typer.echo("\n".join(lines[start:end]))