- docgen/: generate.py, ue_parser.py, ue_markdown.py, ue_mcp_server.py - .mcp.json: update server path to docgen/ue_mcp_server.py - Update CLAUDE.md and README paths accordingly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
406 lines
13 KiB
Python
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)
|