"""muse code languages — language breakdown of the current snapshot. Shows the composition of the repository by programming language — how many files, symbols, and which symbol kinds are present for each language. By default import pseudo-symbols are excluded from counts so the numbers reflect semantic code density (functions, classes, methods, sections) rather than dependency volume. Pass ``--include-imports`` to add them back. Pass ``--diff REF`` to see how language composition *changed* between an earlier commit and the current snapshot (or ``--commit`` target). Usage:: muse code languages muse code languages --commit a3f2c9 muse code languages --diff a3f2c9 muse code languages --diff a3f2c9 --commit 97fe523d muse code languages --sort symbols muse code languages --json Output:: Language breakdown — commit 97fe523d Python 378 files 12 347 symbols (fn: 1974 class: 969 method: 3908 var: 797) Markdown 45 files 2 239 symbols (section: 1502 var: 737) TOML 1 file 56 symbols (var: 56) Shell 2 files 0 symbols ──────────────────────────────────────────────────────────────────────────── Total 437 files 14 642 symbols (6 languages) Diff output (--diff a3f2c9):: Language change — a3f2c9..97fe523d Python +12 files +382 symbols (+3.2%) Markdown +1 file +14 symbols (+0.6%) TOML (unchanged) ──────────────────────────────────────────────────────────────────────────── Net +13 files +396 symbols """ from __future__ import annotations import argparse import json import logging import pathlib import sys from typing import TypedDict from muse.core.errors import ExitCode from muse.core.repo import read_repo_id, require_repo from muse.core.store import ( Manifest, get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref, ) from muse.core.symbol_cache import SymbolCache, load_symbol_cache from muse.plugins.code._query import language_of, symbols_for_snapshot logger = logging.getLogger(__name__) type LangCount = dict[str, int] # language → count (files or symbols) type LangKinds = dict[str, dict[str, int]] # language → kind → count _KindLabelMap = dict[str, str] # Kinds treated as import pseudo-symbols — excluded from default counts. _IMPORT_KINDS: frozenset[str] = frozenset({"import"}) # Display order and labels for known kinds. _KIND_LABEL: _KindLabelMap = { "function": "fn", "async_function": "fn~", "class": "class", "method": "method", "async_method": "method~", "section": "section", "variable": "var", "import": "import", } _SORT_CHOICES = ("name", "files", "symbols") class _LangEntry(TypedDict): language: str files: int symbols: int kinds: LangCount class _DiffEntry(TypedDict): language: str delta_files: int delta_symbols: int files_before: int files_after: int symbols_before: int symbols_after: int status: str # "added" | "removed" | "changed" | "unchanged" def _first_line(message: str) -> str: for line in message.splitlines(): s = line.strip() if s: return s return message.strip() def _collect_stats( root: pathlib.Path, manifest: Manifest, include_imports: bool, cache: SymbolCache | None, ) -> tuple[dict[str, int], dict[str, int], dict[str, dict[str, int]]]: """Return (lang_files, lang_symbols, lang_kinds) for a manifest. When *include_imports* is False, import pseudo-symbols are excluded from symbol counts and kind breakdowns. """ sc = cache symbol_map = symbols_for_snapshot(root, manifest, cache=sc) lang_files: LangCount = {} lang_symbols: LangCount = {} lang_kinds: LangKinds = {} for file_path in manifest: lang = language_of(file_path) lang_files[lang] = lang_files.get(lang, 0) + 1 for file_path, tree in symbol_map.items(): lang = language_of(file_path) kinds = lang_kinds.setdefault(lang, {}) for rec in tree.values(): kind: str = rec["kind"] if not include_imports and kind in _IMPORT_KINDS: continue lang_symbols[lang] = lang_symbols.get(lang, 0) + 1 kinds[kind] = kinds.get(kind, 0) + 1 return lang_files, lang_symbols, lang_kinds def _kind_str(kinds: LangCount) -> str: """Format the kind breakdown as a parenthesised string.""" parts: list[str] = [] # Emit in canonical order for known kinds, then any remainder alphabetically. seen: set[str] = set() for k, label in _KIND_LABEL.items(): if k in kinds: parts.append(f"{label}: {kinds[k]}") seen.add(k) for k in sorted(kinds): if k not in seen: parts.append(f"{k}: {kinds[k]}") return f" ({', '.join(parts)})" if parts else "" def _sorted_langs( lang_files: LangCount, lang_symbols: LangCount, sort_by: str, ) -> list[str]: all_langs = list(lang_files) if sort_by == "files": all_langs.sort(key=lambda l: (-lang_files[l], l)) elif sort_by == "symbols": all_langs.sort(key=lambda l: (-lang_symbols.get(l, 0), l)) else: all_langs.sort() return all_langs def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the languages subcommand.""" parser = subparsers.add_parser( "languages", help="Show the language composition of the repository.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--commit", "-c", dest="ref", default=None, metavar="REF", help="Commit to inspect (default: HEAD).", ) parser.add_argument( "--diff", "-d", dest="diff_ref", default=None, metavar="REF", help="Show the language composition *change* from REF to --commit (or HEAD).", ) parser.add_argument( "--sort", "-s", dest="sort_by", default="name", choices=_SORT_CHOICES, metavar="KEY", help=f"Sort output by: {', '.join(_SORT_CHOICES)} (default: name).", ) parser.add_argument( "--include-imports", dest="include_imports", action="store_true", help="Include import pseudo-symbols in counts (excluded by default).", ) parser.add_argument( "--json", dest="as_json", action="store_true", help="Emit results as JSON.", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Show the language composition of the repository. Counts files and semantic symbols (functions, classes, methods, sections) by programming language. Import pseudo-symbols are excluded by default so the numbers reflect real code density. Pass ``--diff REF`` to see how the breakdown *changed* between REF and the target commit — perfect for sprint-over-sprint or release-over-release language drift analysis. """ ref: str | None = args.ref diff_ref: str | None = args.diff_ref sort_by: str = args.sort_by include_imports: bool = args.include_imports as_json: bool = args.as_json root = require_repo() repo_id = read_repo_id(root) branch = read_current_branch(root) commit_b = resolve_commit_ref(root, repo_id, branch, ref) if commit_b is None: print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) manifest_b: Manifest = get_commit_snapshot_manifest(root, commit_b.commit_id) or {} # Shared cache across all snapshot loads. shared_cache = load_symbol_cache(root) files_b, syms_b, kinds_b = _collect_stats(root, manifest_b, include_imports, shared_cache) # ── diff mode ──────────────────────────────────────────────────────────── if diff_ref is not None: commit_a = resolve_commit_ref(root, repo_id, branch, diff_ref) if commit_a is None: print(f"❌ Commit '{diff_ref}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) manifest_a: Manifest = get_commit_snapshot_manifest(root, commit_a.commit_id) or {} files_a, syms_a, _ = _collect_stats(root, manifest_a, include_imports, shared_cache) all_langs = sorted(set(files_a) | set(files_b)) if as_json: entries: list[_DiffEntry] = [] for lang in all_langs: fa = files_a.get(lang, 0) fb = files_b.get(lang, 0) sa = syms_a.get(lang, 0) sb = syms_b.get(lang, 0) if fa == 0 and fb > 0: status = "added" elif fa > 0 and fb == 0: status = "removed" elif fa == fb and sa == sb: status = "unchanged" else: status = "changed" entries.append(_DiffEntry( language=lang, delta_files=fb - fa, delta_symbols=sb - sa, files_before=fa, files_after=fb, symbols_before=sa, symbols_after=sb, status=status, )) print(json.dumps({ "from": {"commit_id": commit_a.commit_id, "message": _first_line(commit_a.message)}, "to": {"commit_id": commit_b.commit_id, "message": _first_line(commit_b.message)}, "include_imports": include_imports, "diff": entries, }, indent=2)) return _print_diff( commit_a.commit_id, commit_b.commit_id, files_a, syms_a, files_b, syms_b, all_langs, sort_by, ) return # ── snapshot mode ──────────────────────────────────────────────────────── all_langs_snap = _sorted_langs(files_b, syms_b, sort_by) if as_json: out: list[_LangEntry] = [ _LangEntry( language=lang, files=files_b[lang], symbols=syms_b.get(lang, 0), kinds=kinds_b.get(lang, {}), ) for lang in all_langs_snap ] print(json.dumps({ "commit": { "commit_id": commit_b.commit_id, "message": _first_line(commit_b.message), }, "include_imports": include_imports, "languages": out, }, indent=2)) return _print_snapshot(commit_b.commit_id, files_b, syms_b, kinds_b, all_langs_snap) def _print_snapshot( commit_id: str, lang_files: LangCount, lang_symbols: LangCount, lang_kinds: LangKinds, langs: list[str], ) -> None: print(f"\nLanguage breakdown — commit {commit_id[:8]}\n") max_lang = max((len(l) for l in langs), default=8) total_files = total_syms = 0 for lang in langs: files = lang_files[lang] syms = lang_symbols.get(lang, 0) total_files += files total_syms += syms kinds = lang_kinds.get(lang, {}) ks = _kind_str(kinds) file_label = "file " if files == 1 else "files" print(f" {lang:<{max_lang}} {files:>4} {file_label} {syms:>6} symbols{ks}") print(" " + "─" * 66) print( f" {'Total':<{max_lang}} {total_files:>4} files {total_syms:>6} symbols" f" ({len(langs)} languages)" ) def _print_diff( commit_id_a: str, commit_id_b: str, files_a: LangCount, syms_a: LangCount, files_b: LangCount, syms_b: LangCount, all_langs: list[str], sort_by: str, ) -> None: print(f"\nLanguage change — {commit_id_a[:8]}..{commit_id_b[:8]}\n") max_lang = max((len(l) for l in all_langs), default=8) net_files = net_syms = 0 # Sort diff: by abs(delta_symbols) desc, then name. def _sort_key(lang: str) -> tuple[int, int, str]: sa = syms_a.get(lang, 0) sb = syms_b.get(lang, 0) if sort_by == "symbols": return (0, -(abs(sb - sa)), lang) if sort_by == "files": fa = files_a.get(lang, 0) fb = files_b.get(lang, 0) return (0, -(abs(fb - fa)), lang) return (0, 0, lang) sorted_langs = sorted(all_langs, key=_sort_key) for lang in sorted_langs: fa = files_a.get(lang, 0) fb = files_b.get(lang, 0) sa = syms_a.get(lang, 0) sb = syms_b.get(lang, 0) df = fb - fa ds = sb - sa net_files += df net_syms += ds if df == 0 and ds == 0: print(f" {lang:<{max_lang}} (unchanged)") continue status = "" if fa == 0: status = " (new)" elif fb == 0: status = " (removed)" df_str = f"{df:+d} {'file' if abs(df) == 1 else 'files'}" pct = f" ({ds / sa * 100:+.1f}%)" if sa > 0 else (" (+∞)" if ds > 0 else "") ds_str = f"{ds:+d} symbols{pct}" print(f" {lang:<{max_lang}} {df_str:<14} {ds_str}{status}") print(" " + "─" * 66) ndf_str = f"{net_files:+d} {'file' if abs(net_files) == 1 else 'files'}" nds_str = f"{net_syms:+d} symbols" print(f" {'Net':<{max_lang}} {ndf_str:<14} {nds_str}")