Files
mcp-ue/ue_markdown.py
Pierre-Marie Charavel 93ca33c36a Add UnrealDocGenerator tool and UE API skill
- ue_parser.py: position-based UE C++ header parser
- ue_markdown.py: compact agent-optimised Markdown renderer
- generate.py: two-pass CLI (parse-all → type index → render-all)
- samples/: representative UE headers (GeomUtils, AIController, GameplayTagsManager)
- .claude/skills/ue-api/: Claude Code skill for querying UE docs + source headers
- CLAUDE.md: architecture notes, usage, critical gotchas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 06:55:05 -05:00

406 lines
13 KiB
Python

"""
ue_markdown.py — Render a ParsedHeader into compact, agent-readable Markdown.
Format goals:
- Ultra-compact function entries: signature in heading, params folded into prose
- Bullet-list properties (no tables)
- Only items with actual C++ doc comments (or deprecated annotations) are emitted
- Cross-reference links to other files in the corpus (via type_index)
"""
from __future__ import annotations
import os
import re
from typing import Optional
from ue_parser import (
ParsedHeader, ClassInfo, EnumInfo, DelegateInfo, NamespaceInfo,
FreeFunction, FunctionInfo, PropertyInfo, DocComment, _split_params,
)
# ---------------------------------------------------------------------------
# Cross-reference utilities
# ---------------------------------------------------------------------------
def _rel_link(current_md: str, target_md: str) -> str:
"""
Compute a relative markdown link from the directory of current_md to target_md.
Both paths are relative to the docs root.
"""
cur_dir = os.path.dirname(current_md) or '.'
return os.path.relpath(target_md, cur_dir).replace('\\', '/')
def _make_type_link(name: str, type_index: dict[str, str],
current_md: str) -> str:
"""Return '[Name](relative/path.md)' if in corpus, else '`Name`'."""
if name in type_index:
link = _rel_link(current_md, type_index[name])
return f"[{name}]({link})"
return f"`{name}`"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _has_doc(comment: Optional[DocComment]) -> bool:
if comment is None:
return False
return bool(comment.description or comment.params or comment.returns)
def _uf_category(uf_specs: str) -> str:
m = re.search(r'Category\s*=\s*"([^"]*)"', uf_specs)
if m:
return m.group(1)
m = re.search(r'Category\s*=\s*([\w|]+)', uf_specs)
return m.group(1) if m else ""
def _uf_flags(uf_specs: str) -> list:
flags = []
for flag in ('BlueprintCallable', 'BlueprintPure', 'BlueprintImplementableEvent',
'BlueprintNativeEvent', 'Exec', 'NetMulticast', 'Server', 'Client',
'CallInEditor'):
if re.search(r'\b' + flag + r'\b', uf_specs):
flags.append(flag)
return flags
def _up_flags(specs: str) -> list:
notable = [
'EditAnywhere', 'EditDefaultsOnly', 'EditInstanceOnly',
'VisibleAnywhere', 'VisibleDefaultsOnly',
'BlueprintReadWrite', 'BlueprintReadOnly', 'BlueprintAssignable',
'Replicated', 'ReplicatedUsing', 'Transient', 'SaveGame', 'Config',
]
return [f for f in notable if re.search(r'\b' + f + r'\b', specs)]
def _compact_params(raw_params: str, max_count: int = 3) -> str:
params = _split_params(raw_params)
if not params:
return ""
shown = []
for p in params[:max_count]:
p = p.strip()
p = re.sub(r'\s*=\s*', '=', p)
shown.append(p)
result = ', '.join(shown)
if len(params) > max_count:
result += ', ...'
return result
def _fn_flags_str(fn: FunctionInfo) -> str:
parts = []
if fn.is_deprecated:
ver = fn.deprecated_version or "?"
parts.append(f"Deprecated {ver}")
if fn.uf_specifiers:
uf = _uf_flags(fn.uf_specifiers)
cat = _uf_category(fn.uf_specifiers)
annotation = ', '.join(uf)
if cat:
annotation = (annotation + '' if annotation else '') + cat
if annotation:
parts.append(annotation)
for mod in fn.modifiers:
if mod in ('virtual', 'static', 'inline', 'constexpr'):
parts.append(mod)
if fn.editor_only:
parts.append("Editor only")
return f" *({', '.join(parts)})*" if parts else ""
_PLACEHOLDER_RE = re.compile(r'^[-_/\\]{6,}')
def _fn_body(fn: FunctionInfo) -> str:
parts = []
c = fn.comment
if c:
if c.description and not _PLACEHOLDER_RE.match(c.description):
desc = c.description.rstrip('.')
parts.append(desc + '.')
for pname, pdesc in c.params.items():
pdesc = pdesc.rstrip('.')
parts.append(f"`{pname}`: {pdesc}.")
if c.returns:
ret = c.returns.rstrip('.')
parts.append(f"**Returns** {ret}.")
return ' '.join(parts)
def _render_function_compact(fn: FunctionInfo) -> str:
params_str = _compact_params(fn.raw_params)
heading = f"##### `{fn.name}({params_str})`"
if fn.return_type:
heading += f" → `{fn.return_type}`"
heading += _fn_flags_str(fn)
body = _fn_body(fn)
return heading + ('\n' + body if body else '')
def _render_ff_compact(fn: FreeFunction, overloads: list = None) -> str:
overloads = overloads or [fn]
fn0 = overloads[0]
params_str = _compact_params(fn0.raw_params)
heading = f"##### `{fn0.name}({params_str})`"
if fn0.return_type:
heading += f" → `{fn0.return_type}`"
flag_parts = [m for m in fn0.modifiers if m in ('inline', 'static', 'constexpr')]
if len(overloads) > 1:
flag_parts.append(f"{len(overloads)} overloads")
if flag_parts:
heading += f" *({', '.join(flag_parts)})*"
body = ""
for f in overloads:
b = _fn_body(f)
if b:
body = b
break
return heading + ('\n' + body if body else '')
# ---------------------------------------------------------------------------
# Section renderers
# ---------------------------------------------------------------------------
def _render_delegates(delegates: list) -> str:
if not delegates:
return ""
lines = ["## Delegates"]
for d in delegates:
suffix_parts = []
if d.is_dynamic:
suffix_parts.append("Dynamic")
if d.is_multicast:
suffix_parts.append("Multicast")
suffix = ", ".join(suffix_parts)
head = f"### `{d.name}`"
if suffix:
head += f" *({suffix})*"
lines.append(head)
if d.comment and d.comment.description:
lines.append(d.comment.description)
if d.params:
param_str = ", ".join(f"`{t} {n}`" for t, n in d.params)
lines.append(f"Params: {param_str}")
return '\n'.join(lines)
def _render_enum(ei: EnumInfo) -> str:
head = f"### `{ei.name}`"
if ei.editor_only:
head += " *(Editor only)*"
lines = [head]
if ei.comment and ei.comment.description:
lines.append(ei.comment.description)
if ei.values:
has_descriptions = any(v.comment for v in ei.values)
if has_descriptions:
lines.append("| Value | Description |")
lines.append("|-------|-------------|")
for v in ei.values:
val_str = f"`{v.name}`"
if v.value:
val_str += f" (={v.value})"
lines.append(f"| {val_str} | {v.comment} |")
else:
# Compact inline list when no value has a description
vals = [v.name for v in ei.values]
lines.append("Values: " + ", ".join(f"`{v}`" for v in vals))
return '\n'.join(lines)
def _render_enums(enums: list, heading: str = "## Enums") -> str:
if not enums:
return ""
parts = [heading]
for ei in enums:
parts.append(_render_enum(ei))
return '\n'.join(parts)
def _render_properties(props: list) -> str:
visible = [p for p in props
if p.access in ('public', 'protected') and _has_doc(p.comment)]
if not visible:
return ""
lines = ["#### Properties"]
for p in visible:
flags = ', '.join(_up_flags(p.specifiers))
desc = p.comment.description if p.comment else ""
line = f"- `{p.name}` `{p.type}`"
if flags:
line += f" *({flags})*"
if desc:
line += f"{desc}"
if p.editor_only:
line += " *(Editor only)*"
lines.append(line)
return '\n'.join(lines)
def _render_functions(fns: list) -> str:
visible = [f for f in fns
if f.access in ('public', 'protected')
and _has_doc(f.comment)
and not f.is_deprecated]
if not visible:
return ""
lines = ["#### Functions"]
seen: dict[str, list] = {}
ordered: list[str] = []
for fn in visible:
if fn.name not in seen:
seen[fn.name] = []
ordered.append(fn.name)
seen[fn.name].append(fn)
for name in ordered:
group = seen[name]
if len(group) == 1:
lines.append(_render_function_compact(group[0]))
else:
fn0 = group[0]
params_str = _compact_params(fn0.raw_params)
heading = f"##### `{fn0.name}({params_str})`"
if fn0.return_type:
heading += f" → `{fn0.return_type}`"
flag_parts = []
if fn0.is_deprecated:
flag_parts.append(f"Deprecated {fn0.deprecated_version or '?'}")
if fn0.uf_specifiers:
uf = _uf_flags(fn0.uf_specifiers)
cat = _uf_category(fn0.uf_specifiers)
ann = ', '.join(uf)
if cat:
ann = (ann + '' if ann else '') + cat
if ann:
flag_parts.append(ann)
flag_parts.append(f"{len(group)} overloads")
heading += f" *({', '.join(flag_parts)})*"
lines.append(heading)
for fn in group:
body = _fn_body(fn)
if body:
lines.append(body)
break
return '\n'.join(lines)
def _class_has_content(ci: ClassInfo) -> bool:
if _has_doc(ci.comment):
return True
if any(p.access in ('public', 'protected') and _has_doc(p.comment)
for p in ci.properties):
return True
if any(f.access in ('public', 'protected') and (_has_doc(f.comment) or f.is_deprecated)
for f in ci.functions):
return True
if ci.nested_enums:
return True
return False
def _render_class(ci: ClassInfo,
type_index: dict[str, str], current_md: str) -> str:
lines = []
head = f"### `{ci.name}` *({ci.kind})*"
lines.append(head)
if ci.bases:
linked = [_make_type_link(b, type_index, current_md) for b in ci.bases]
lines.append("*Inherits*: " + ", ".join(linked))
if ci.module_api:
lines.append(f"*API*: `{ci.module_api}`")
if ci.comment and ci.comment.description:
lines.append(ci.comment.description)
if ci.nested_enums:
lines.append(_render_enums(ci.nested_enums, "#### Enums"))
props_section = _render_properties(ci.properties)
if props_section:
lines.append(props_section)
fns_section = _render_functions(ci.functions)
if fns_section:
lines.append(fns_section)
return '\n'.join(lines)
def _render_namespace(ns: NamespaceInfo) -> str:
doc_fns = [f for f in ns.functions if _has_doc(f.comment)]
if not doc_fns:
return ""
lines = [f"## Free Functions — `{ns.name}`"]
seen: dict[str, list] = {}
ordered: list[str] = []
for fn in doc_fns:
if fn.name not in seen:
seen[fn.name] = []
ordered.append(fn.name)
seen[fn.name].append(fn)
for name in ordered:
group = seen[name]
lines.append(_render_ff_compact(group[0], group))
return '\n'.join(lines)
# ---------------------------------------------------------------------------
# Main render function
# ---------------------------------------------------------------------------
def render_header(parsed: ParsedHeader,
type_index: dict[str, str] = None,
current_md: str = "") -> str:
if type_index is None:
type_index = {}
lines = []
lines.append(f"# `{parsed.filename}`")
lines.append(f"**Module**: `{parsed.module_name}`")
lines.append("")
sections = []
d_sec = _render_delegates(parsed.delegates)
if d_sec.strip():
sections.append(d_sec)
e_sec = _render_enums(parsed.enums)
if e_sec.strip():
sections.append(e_sec)
if parsed.classes:
doc_classes = [ci for ci in parsed.classes if _class_has_content(ci)]
if doc_classes:
cls_lines = ["## Classes"]
for ci in doc_classes:
cls_lines.append(_render_class(ci, type_index, current_md))
sections.append('\n'.join(cls_lines))
for ns in parsed.namespaces:
ns_sec = _render_namespace(ns)
if ns_sec.strip():
sections.append(ns_sec)
if parsed.free_functions:
doc_fns = [f for f in parsed.free_functions if _has_doc(f.comment)]
if doc_fns:
ff_lines = ["## Free Functions"]
for fn in doc_fns:
ff_lines.append(_render_ff_compact(fn))
sections.append('\n'.join(ff_lines))
lines.append('\n\n---\n\n'.join(sections))
return '\n'.join(lines)