Move scripts to docgen/, remove possess_flow.png
- 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>
This commit is contained in:
405
docgen/ue_markdown.py
Normal file
405
docgen/ue_markdown.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user