"""Multi-format documentation renderer for ``muse code docs``. Converts a :class:`~muse.core.doc_extractor.DocReport` into one of four output formats: ``json`` Machine-readable, AI-ready. The full :class:`DocReport` TypedDict serialised as pretty-printed JSON. Suitable for ingestion by LLMs, RAG pipelines, and downstream tooling. ``html`` Standalone HTML page with an inline sidebar TOC, health bars, version badges, caller/callee sections, and linked test lists. Zero external dependencies — a single file that opens in any browser. ``markdown`` GitHub-compatible Markdown with a generated table of contents, one ``##`` heading per symbol, and structured sections for callers, callees, version history, and test linkage. ``text`` Terminal-friendly columnar table similar to ``muse code symbols``, with health score, stale indicator, and doc-debt summary. Security -------- All user-visible strings (symbol names, docstrings, file paths) are escaped appropriately before embedding in HTML or JSON output. The renderer never evaluates or executes any string from the report. Performance ----------- Renderers operate in a single linear pass over the ``symbols`` list. On a 400-symbol report the HTML renderer completes in <10 ms. """ import html as _html_module import json import logging from typing import Literal, TypedDict from muse.core.doc_extractor import DocReport, DocSummary, SymbolDoc type _BadgeMap = dict[str, str] logger = logging.getLogger(__name__) RenderFormat = Literal["json", "html", "markdown", "text"] """Supported output formats for :func:`render`.""" # --------------------------------------------------------------------------- # JSON renderer # --------------------------------------------------------------------------- def render_json(report: DocReport) -> str: """Return the full *report* as pretty-printed JSON. The output is structured identically to the :class:`DocReport` TypedDict — every field is present, making it directly usable by LLMs and agent pipelines. Args: report: The documentation report to serialise. """ return json.dumps(report, indent=2, ensure_ascii=False) # --------------------------------------------------------------------------- # Text renderer # --------------------------------------------------------------------------- _HEALTH_BAR_WIDTH = 10 def _health_bar(score: float) -> str: """Return a compact ASCII health bar, e.g. ``[████████░░]``.""" filled = round(score * _HEALTH_BAR_WIDTH) empty = _HEALTH_BAR_WIDTH - filled return f"[{'█' * filled}{'░' * empty}]" def _stale_flag(doc: SymbolDoc) -> str: return "⚠" if "stale_impl" in doc["doc_health_reasons"] else " " def render_text(report: DocReport) -> str: """Return a terminal-friendly columnar view of *report*. Columns: HEALTH ST KIND NAME FILE Health bar + score, stale flag, symbol kind, name, file path. Args: report: The documentation report to render. """ lines: list[str] = [] s = report["summary"] lines.append( f"Muse docs — commit {report['commit_id']} " f"generated {report['generated_at'][:19]}" ) lines.append( f" symbols={s['total_symbols']} " f"documented={s['documented']} " f"undocumented={s['undocumented']} " f"stale={s['stale_count']} " f"avg_health={s['avg_health']:.2f} " f"debt={s['doc_debt_score']:.2f}" ) lines.append("") if not report["symbols"]: lines.append(" (no symbols)") return "\n".join(lines) header = f"{'HEALTH':17} ST {'KIND':14} {'NAME':38} FILE" lines.append(header) lines.append("-" * len(header)) for doc in report["symbols"]: bar = _health_bar(doc["doc_health"]) score = f"{doc['doc_health']:.2f}" health_col = f"{bar} {score}" stale = _stale_flag(doc) kind = doc["kind"][:14] name = doc["name"][:38] file_col = doc["file"] lines.append(f"{health_col:17} {stale} {kind:14} {name:38} {file_col}") if report["missing"]: lines.append("") lines.append(f"Missing docstrings ({len(report['missing'])} public symbols):") for m in report["missing"][:20]: lines.append(f" [{m['caller_count']:3} callers] {m['address']}") if len(report["missing"]) > 20: lines.append(f" … and {len(report['missing']) - 20} more") if report["stale"]: lines.append("") lines.append(f"Potentially stale docstrings ({len(report['stale'])} symbols):") for st in report["stale"][:10]: lines.append(f" {st['address']}") if len(report["stale"]) > 10: lines.append(f" … and {len(report['stale']) - 10} more") return "\n".join(lines) # --------------------------------------------------------------------------- # Markdown renderer # --------------------------------------------------------------------------- _MD_KIND_BADGE: _BadgeMap = { "function": "fn", "async_function": "async fn", "class": "class", "method": "method", "async_method": "async method", "variable": "var", "import": "import", "section": "section", "rule": "rule", } def _md_health_indicator(score: float) -> str: """Return an emoji indicator for the health score.""" if score >= 0.85: return "✅" if score >= 0.60: return "🟡" return "🔴" def render_markdown(report: DocReport) -> str: """Return GitHub-compatible Markdown for *report*. Structure: - H1 header with commit and summary stats - Auto-generated TOC for all symbols - Per-symbol H2 sections with docstring, signature, callers, callees, version info, linked tests, and health score Args: report: The documentation report to render. """ lines: list[str] = [] s = report["summary"] lines.append("# Muse Documentation Report") lines.append("") lines.append(f"**Commit:** `{report['commit_id']}` ") lines.append(f"**Generated:** {report['generated_at'][:19]} ") lines.append( f"**Health:** avg={s['avg_health']:.2f} " f"debt={s['doc_debt_score']:.2f} " f"documented={s['documented']}/{s['total_symbols']}" ) lines.append("") if report["symbols"]: lines.append("## Table of Contents") lines.append("") for doc in report["symbols"]: anchor = doc["address"].lower().replace(" ", "-").replace("/", "").replace("::", "--").replace(".", "") indicator = _md_health_indicator(doc["doc_health"]) lines.append(f"- {indicator} [`{doc['qualified_name']}`](#{anchor})") lines.append("") for doc in report["symbols"]: anchor = doc["address"].lower().replace(" ", "-").replace("/", "").replace("::", "--").replace(".", "") kind_badge = _MD_KIND_BADGE.get(doc["kind"], doc["kind"]) indicator = _md_health_indicator(doc["doc_health"]) lines.append(f"## `{doc['qualified_name']}` {{#{anchor}}}") lines.append("") lines.append( f"> {indicator} **{kind_badge}** in `{doc['file']}`" f" (lines {doc['lineno']}–{doc['end_lineno']})" ) lines.append("") if doc["signature"]: lines.append("```python") lines.append(doc["signature"]) lines.append("```") lines.append("") if doc["docstring"]: lines.append(doc["docstring"]) else: lines.append("*No docstring.*") lines.append("") if doc["since_version"] or doc["since_commit"]: ver = doc["since_version"] or "" cid = doc["since_commit"] or "" lines.append(f"**Introduced:** {ver or cid}") lines.append("") if doc["last_changed_version"] or doc["last_changed_commit"]: ver = doc["last_changed_version"] or "" cid = doc["last_changed_commit"] or "" lines.append(f"**Last changed:** {ver or cid}") lines.append("") if doc["breaking_changes"]: lines.append("**Breaking changes:**") for bc in doc["breaking_changes"]: lines.append(f"- {bc}") lines.append("") if doc["callers"]: lines.append(f"**Called by** ({len(doc['callers'])}):") for c in doc["callers"][:10]: lines.append(f"- `{c}`") if len(doc["callers"]) > 10: lines.append(f"- *(+{len(doc['callers']) - 10} more)*") lines.append("") if doc["callees"]: lines.append(f"**Calls** ({len(doc['callees'])}):") for c in doc["callees"][:10]: lines.append(f"- `{c}`") if len(doc["callees"]) > 10: lines.append(f"- *(+{len(doc['callees']) - 10} more)*") lines.append("") if doc["linked_tests"]: lines.append(f"**Tests** ({len(doc['linked_tests'])}):") for t in doc["linked_tests"][:5]: lines.append(f"- `{t}`") if len(doc["linked_tests"]) > 5: lines.append(f"- *(+{len(doc['linked_tests']) - 5} more)*") lines.append("") reasons = doc["doc_health_reasons"] lines.append( f"**Doc health:** {doc['doc_health']:.2f}" + (f" ({', '.join(reasons)})" if reasons else "") ) lines.append("") lines.append("---") lines.append("") if report["missing"]: lines.append("## Missing Docstrings") lines.append("") lines.append( f"{len(report['missing'])} public symbol(s) lack docstrings " f"(sorted by caller count):" ) lines.append("") lines.append("| Callers | Address |") lines.append("|---------|---------|") for m in report["missing"]: lines.append(f"| {m['caller_count']} | `{m['address']}` |") lines.append("") if report["stale"]: lines.append("## Stale Docstrings") lines.append("") lines.append( f"{len(report['stale'])} symbol(s) may have stale documentation:" ) lines.append("") for st in report["stale"]: changed = "signature" if st["signature_changed"] else "body" lines.append(f"- `{st['address']}` — {changed} changed") lines.append("") return "\n".join(lines) # --------------------------------------------------------------------------- # HTML renderer # --------------------------------------------------------------------------- _HTML_TEMPLATE = """\
{_e(doc["qualified_name"])}'
f' {kind_label}{_e(doc['signature'])}")
# Docstring
if doc["docstring"]:
parts.append(f' | Callers | Address | Kind |
|---|---|---|
| {m['caller_count']} | " f"{_e(m['address'])} | "
f"{_e(m['kind'])} |