from __future__ import annotations
import argparse
import difflib
import json
import re
import sys
import os
from dataclasses import dataclass, field
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
@dataclass(frozen=True)
class CrateSpec:
crate: str rustdoc: str md_subdir: str title: str group: str is_widget: bool
catalog: bool
@property
def src(self) -> Path:
return REPO_ROOT / "crates" / self.crate / "src"
@property
def docs_rs(self) -> str:
return f"https://docs.rs/{self.crate}/latest"
@property
def marker_begin(self) -> str:
return f"<!-- BEGIN GENERATED {self.md_subdir.upper()} -->"
@property
def marker_end(self) -> str:
return f"<!-- END GENERATED {self.md_subdir.upper()} -->"
CRATE_SPECS: dict[str, CrateSpec] = {
"widgets": CrateSpec("teksilo-widgets", "teksilo_widgets", "widgets", "Widget Catalog", "Widgets", True, True),
"data": CrateSpec("teksilo-data", "teksilo_data", "data-collections", "Data Collections", "Models", False, True),
"settings": CrateSpec("teksilo-settings", "teksilo_settings", "settings", "Settings", "Stores & services", False, True),
"scene": CrateSpec("teksilo-scene", "teksilo_scene", "scene", "Scene", "Scene", False, True),
"core": CrateSpec("teksilo-core", "teksilo_core", "core", "Core Framework", "Framework", False, False),
"tokens": CrateSpec("teksilo-tokens", "teksilo_tokens", "tokens", "Design Tokens", "Tokens", False, False),
"canvas": CrateSpec("teksilo-canvas", "teksilo_canvas", "canvas", "Canvas API", "Canvas", False, False),
"render": CrateSpec("teksilo-render", "teksilo_render", "render", "Renderer", "Rendering", False, False),
"platform": CrateSpec("teksilo-platform", "teksilo_platform", "platform", "Platform Integration", "Platform", False, False),
"app": CrateSpec("teksilo-app", "teksilo_app", "app", "App Runtime", "App", False, False),
"text": CrateSpec("teksilo-text", "teksilo_text", "text", "Text Backend", "Text", False, False),
"charts": CrateSpec("teksilo-charts", "teksilo_charts", "charts", "Charts", "Charts", False, False),
"i18n": CrateSpec("teksilo-i18n", "teksilo_i18n", "i18n", "Internationalization", "I18n", False, False),
"telemetry": CrateSpec("teksilo-telemetry", "teksilo_telemetry", "telemetry", "Telemetry", "Telemetry", False, False),
"automation": CrateSpec("teksilo-automation", "teksilo_automation", "automation", "Automation Toolkit", "Automation", False, False),
"webview": CrateSpec("teksilo-webview", "teksilo_webview", "webview", "WebView", "WebView", False, False),
"terminal": CrateSpec("teksilo-terminal", "teksilo_terminal", "terminal", "Terminal", "Terminal", False, False),
"inspector": CrateSpec("teksilo-inspector", "teksilo_inspector", "inspector", "Inspector", "Tooling", False, False),
"async": CrateSpec("teksilo-async", "teksilo_async", "async", "Async Executor", "Async", False, False),
"analytics-native": CrateSpec("teksilo-analytics-native", "teksilo_analytics_native", "analytics-native", "Analytics: Native", "Analytics", False, False),
"analytics-otlp": CrateSpec("teksilo-analytics-otlp", "teksilo_analytics_otlp", "analytics-otlp", "Analytics: OTLP", "Analytics", False, False),
"analytics-plausible": CrateSpec("teksilo-analytics-plausible", "teksilo_analytics_plausible", "analytics-plausible", "Analytics: Plausible", "Analytics", False, False),
"theme-fluent": CrateSpec("teksilo-theme-fluent", "teksilo_theme_fluent", "theme-fluent", "Fluent Theme", "Themes", False, False),
"theme-macos": CrateSpec("teksilo-theme-macos", "teksilo_theme_macos", "theme-macos", "macOS Theme", "Themes", False, False),
"theme-material3": CrateSpec("teksilo-theme-material3", "teksilo_theme_material3", "theme-material3", "Material 3 Theme", "Themes", False, False),
"preview": CrateSpec("teksilo-preview", "teksilo_preview", "preview", "Widget Previewer Core", "Preview", False, False),
"preview-ui": CrateSpec("teksilo-preview-ui", "teksilo_preview_ui", "preview-ui", "Previewer UI", "Preview", False, False),
"fmt": CrateSpec("teksilo-fmt", "teksilo_fmt", "fmt", "teksu! Formatter", "Tooling", False, False),
"parse": CrateSpec("teksilo-parse", "teksilo_parse", "parse", "teksu! Parser", "Tooling", False, False),
"teksilo": CrateSpec("teksilo", "teksilo", "umbrella", "Umbrella Crate", "Umbrella", False, False),
"text-document": CrateSpec("text-document", "text_document", "text-document", "Rich Text Document", "Text", False, False),
"text-typeset": CrateSpec("text-typeset", "text_typeset", "text-typeset", "Typesetter", "Text", False, False),
}
SPEC: CrateSpec = CRATE_SPECS["widgets"]
WIDGETS_SRC = CRATE_SPECS["widgets"].src
PRIMITIVES_DIR = WIDGETS_SRC / "primitives"
ANIMATIONS_DIR = WIDGETS_SRC / "animations"
SKIP_FILES = {"lib.rs", "primitives.rs", "animations.rs", "layout_integration_tests.rs", "mod.rs"}
LIB_RS_DEFINES_TYPES_RE = re.compile(
r"^\s*pub\s+(?:struct|enum|trait|union)\s+[A-Z]", re.MULTILINE
)
def _lib_rs_is_pure_aggregator(path: Path) -> bool:
try:
return LIB_RS_DEFINES_TYPES_RE.search(path.read_text(errors="ignore")) is None
except OSError:
return True
def _is_scannable(p: Path) -> bool:
if _is_test_file(p):
return False
if p.name == "lib.rs":
return not _lib_rs_is_pure_aggregator(p)
return p.name not in SKIP_FILES
UMBRELLA_REEXPORTS: dict[str, str] = {
"AccessNodeBuilder": "core",
"AccessSubtreeMode": "core",
"AccessibilityOverrides": "core",
"Action": "core",
"AnimationSpec": "core",
"BuildContext": "core",
"ButtonMask": "core",
"CloseResponse": "core",
"ColorProp": "core",
"CursorIcon": "core",
"DecorationsMode": "core",
"DimWhenInactive": "core",
"EventContext": "core",
"EventResponse": "core",
"ImeContext": "core",
"ImePurpose": "core",
"Intent": "core",
"IntentKind": "core",
"IntentResponse": "core",
"IntoTeksiChild": "core",
"IntoTeksiCondition": "core",
"Key": "core",
"KeyStroke": "core",
"LayoutContext": "core",
"LayoutResponse": "core",
"ModalCloseBehavior": "core",
"ModalConfig": "core",
"ModalPresentation": "core",
"Modifiers": "core",
"OverscrollBehavior": "core",
"PaintContext": "core",
"PointerButton": "core",
"Politeness": "core",
"Prop": "core",
"Shortcut": "core",
"ShortcutRegistry": "core",
"ShortcutScope": "core",
"Signal": "core",
"SizeToContent": "core",
"TapEvent": "core",
"TeksiBranch": "core",
"TeksiBranch3": "core",
"TeksiBranch4": "core",
"TeksiloWindowId": "core",
"TextStyleProp": "core",
"Theme": "core",
"ThemeAppearance": "core",
"ThemeExtensions": "core",
"ThemeId": "core",
"TraversalScopePolicy": "core",
"UserAttentionKind": "core",
"Widget": "core",
"WidgetBuilder": "core",
"WidgetEvent": "core",
"WidgetId": "core",
"WindowCommand": "core",
"WindowConfig": "core",
"WindowPlacement": "core",
"WindowRemovedCallback": "core",
"WindowRemovedEvent": "core",
"WindowState": "core",
"Canvas": "canvas",
"EllipsisMode": "canvas",
"Paint": "canvas",
"Path": "canvas",
"Point": "canvas",
"Rect": "canvas",
"RenderFrame": "canvas",
"Size": "canvas",
"SizeProposal": "canvas",
"TextOverflow": "canvas",
"Vec2": "canvas",
"BorderRole": "tokens",
"Color": "tokens",
"CornerRadius": "tokens",
"SurfaceRole": "tokens",
"TextRole": "tokens",
"TextStyleRole": "tokens",
"TeksiloAppBuilder": "app",
"ThemeMode": "app",
"AppPaths": "settings",
"MruEntry": "settings",
"MruList": "settings",
"PerWindowState": "settings",
"SettingsBundle": "settings",
"SettingsExt": "settings",
"SettingsFile": "settings",
"SettingsKey": "settings",
"SettingsStore": "settings",
"TEXT_SCALE_KEY": "settings",
"WindowStateService": "settings",
"I18nConfig": "i18n",
"LocalizedString": "i18n",
"TeksiloAppBuilderInspectorExt": "inspector",
"TeksiloAppBuilderToastExt": "teksilo",
"TeksiloAppBuilderWebViewExt": "teksilo",
"EventContextToastExt": "widgets",
"NotificationArchive": "widgets",
"NotificationArchiveModel": "widgets",
"NotificationCenterButton": "widgets",
"NotificationEntry": "widgets",
"NotificationLog": "widgets",
"NotificationLogDialog": "widgets",
"Toast": "widgets",
"ToastAction": "widgets",
"ToastActionStyle": "widgets",
"ToastAudience": "widgets",
"ToastDismissCause": "widgets",
"ToastHandle": "widgets",
"ToastHost": "widgets",
"ToastInstallOptions": "widgets",
"ToastRegistry": "widgets",
"ToastRoute": "widgets",
"ToastPriority": "core",
"TeksiloAppBuilderAutomationExt": "app",
"WebSource": "webview",
"WebView": "webview",
"WebViewBackend": "webview",
"WebViewEvent": "webview",
"WebViewHandle": "webview",
"WebViewId": "webview",
"WebViewRegistry": "webview",
"WebViewStyle": "core",
"BellStyle": "terminal",
"ColorScheme": "terminal",
"CursorStyle": "terminal",
"Terminal": "terminal",
"TerminalClosePolicy": "terminal",
"TerminalCommand": "terminal",
"TerminalController": "terminal",
"TerminalStyle": "terminal",
"EventContextFileDialogExt": "platform",
"FileDialogHandle": "platform",
"FileDialogRequest": "platform",
"FileDialogResult": "platform",
"AsyncRuntimeHandle": "async",
"BlockingError": "async",
"EventContextAsyncExt": "async",
"TaskHandle": "async",
"TeksiloAppBuilderAsyncExt": "async",
}
@dataclass
class EnumVariant:
name: str
signature: str
doc: str
@dataclass
class Item:
kind: str name: str
signature: str
doc: str
hidden: bool = False
cfg: list[str] = field(default_factory=list)
variants: list[EnumVariant] = field(default_factory=list)
methods: list["Item"] = field(default_factory=list)
@dataclass
class ParsedFile:
module_name: str
file_path: Path
header_doc: str
cfg: list[str]
items: list[Item]
class BlockCommentTracker:
def __init__(self) -> None:
self.in_block = False
def process(self, line: str) -> str:
out: list[str] = []
i = 0
n = len(line)
while i < n:
if self.in_block:
j = line.find("*/", i)
if j < 0:
out.append(" " * (n - i))
return "".join(out)
out.append(" " * (j + 2 - i))
i = j + 2
self.in_block = False
else:
j = line.find("/*", i)
if j < 0:
out.append(line[i:])
return "".join(out)
out.append(line[i:j])
out.append(" ")
i = j + 2
self.in_block = True
return "".join(out)
def strip_line_literals(line: str) -> str:
out: list[str] = []
i = 0
n = len(line)
while i < n:
c = line[i]
if c == "/" and i + 1 < n and line[i + 1] == "/":
out.append("//")
out.append(" " * (n - i - 2))
return "".join(out)
if c == '"':
out.append(" ")
i += 1
while i < n:
if line[i] == "\\" and i + 1 < n:
out.append(" ")
i += 2
elif line[i] == '"':
out.append(" ")
i += 1
break
else:
out.append(" ")
i += 1
continue
if c == "'":
m = re.match(r"'(?:\\u\{[0-9a-fA-F]+\}|\\.|[^'\\])'", line[i:])
if m:
out.append(" " * len(m.group(0)))
i += len(m.group(0))
continue
out.append(c)
i += 1
continue
out.append(c)
i += 1
return "".join(out)
def preprocess(raw_lines: list[str]) -> list[str]:
bc = BlockCommentTracker()
return [strip_line_literals(bc.process(line)) for line in raw_lines]
DOC_OUTER = re.compile(r"^\s*///(.*)$")
DOC_INNER = re.compile(r"^\s*//!(.*)$")
ATTR_PREFIX = re.compile(r"^\s*#\[")
IMPL_PREFIX = re.compile(r"^\s*impl\b")
PUB_TRAIT = re.compile(r"^\s*pub\s+(?:unsafe\s+)?trait\s+([A-Za-z_]\w*)")
PUB_STRUCT = re.compile(r"^\s*pub\s+struct\s+([A-Za-z_]\w*)")
PUB_ENUM = re.compile(r"^\s*pub\s+enum\s+([A-Za-z_]\w*)")
PUB_TYPE = re.compile(r"^\s*pub\s+type\s+([A-Za-z_]\w*)")
PUB_CONST = re.compile(r"^\s*pub\s+(?:const|static)\s+([A-Za-z_]\w*)")
PUB_FN = re.compile(
r"^\s*pub" r"(?:\s+(?:async|const|unsafe|extern(?:\s+\"[^\"]*\")?))*"
r"\s+fn\s+([A-Za-z_]\w*)"
)
WIDGET_IMPL_RE = re.compile(r"impl\b[^{]*?\bWidget\s+for\s+([A-Za-z_]\w*)")
def _doc_text(m: re.Match[str]) -> str:
s = m.group(1)
if s.startswith(" "):
s = s[1:]
return s
def find_matching_brace(
cleaned: list[str], start_line: int, start_col: int
) -> tuple[int, int]:
depth = 0
for li in range(start_line, len(cleaned)):
line = cleaned[li]
start = start_col if li == start_line else 0
for ci in range(start, len(line)):
c = line[ci]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return li, ci
return len(cleaned) - 1, 0
def consume_attribute(
raw: list[str], cleaned: list[str], i: int
) -> tuple[str, int]:
depth = 0
started = False
for li in range(i, len(cleaned)):
line = cleaned[li]
for col, c in enumerate(line):
if c == "[":
depth += 1
started = True
elif c == "]":
depth -= 1
if started and depth == 0:
if li == i:
return raw[i][: col + 1].strip(), i
parts = (
[raw[i].strip()]
+ [raw[j].strip() for j in range(i + 1, li)]
+ [raw[li][: col + 1].strip()]
)
return " ".join(p for p in parts if p), li
return raw[i].strip(), i
def _join_signature(raw: list[str], start: int, end_line: int, end_col: int) -> str:
if end_line == start:
return raw[start][:end_col].rstrip()
parts = [raw[start]]
parts.extend(raw[j] for j in range(start + 1, end_line))
parts.append(raw[end_line][:end_col])
return "\n".join(parts).rstrip()
def consume_stmt(
raw: list[str], cleaned: list[str], i: int
) -> tuple[str, int]:
depth = 0
for li in range(i, len(cleaned)):
for col, c in enumerate(cleaned[li]):
if c == "{":
depth += 1
elif c == "}":
depth -= 1
elif depth == 0 and c == ";":
return _join_signature(raw, i, li, col + 1), li
return raw[i].rstrip(), i
def consume_item_signature(
raw: list[str], cleaned: list[str], i: int
) -> tuple[str, int, tuple[int, int] | None]:
paren = 0
for li in range(i, len(cleaned)):
for col, c in enumerate(cleaned[li]):
if c == "(":
paren += 1
elif c == ")":
paren -= 1
elif paren == 0 and c == ";":
return _join_signature(raw, i, li, col + 1), li, None
elif paren == 0 and c == "{":
sig = _join_signature(raw, i, li, col)
close_line, _ = find_matching_brace(cleaned, li, col)
return sig, close_line, (li, col)
return raw[i].rstrip(), i, None
def consume_fn_signature(
raw: list[str], cleaned: list[str], i: int
) -> tuple[str, int, bool]:
paren = 0
started_paren = False
for li in range(i, len(cleaned)):
for col, c in enumerate(cleaned[li]):
if c == "(":
paren += 1
started_paren = True
elif c == ")":
paren -= 1
elif paren == 0 and started_paren and c == ";":
return _join_signature(raw, i, li, col), li, False
elif paren == 0 and started_paren and c == "{":
sig = _join_signature(raw, i, li, col)
close_line, _ = find_matching_brace(cleaned, li, col)
return sig, close_line, True
return raw[i].rstrip(), i, False
def consume_impl_header(
raw: list[str], cleaned: list[str], i: int
) -> tuple[str, int, int] | None:
for li in range(i, len(cleaned)):
for col, c in enumerate(cleaned[li]):
if c == "{":
header = _join_signature(raw, i, li, col)
return header, li, col
return None
_HRTB_RE = re.compile(r"for\s*<[^>]*>")
def _normalize_impl_header(header: str) -> str:
return _HRTB_RE.sub(" ", header)
def is_trait_impl(header: str) -> bool:
return re.search(r"\bfor\b", _normalize_impl_header(header)) is not None
def extract_impl_target(header: str) -> str:
normalized = _normalize_impl_header(header)
m = re.match(r"\s*impl\b", normalized)
if not m:
return ""
body = normalized[m.end() :].strip()
if body.startswith("<"):
depth = 0
j = 0
while j < len(body):
if body[j] == "<":
depth += 1
elif body[j] == ">":
depth -= 1
if depth == 0:
j += 1
break
j += 1
body = body[j:].strip()
if re.search(r"\bfor\b", body):
_, _, after = body.partition(" for ")
if not after:
after = re.split(r"\bfor\b", body, maxsplit=1)[1]
target_part = after.strip()
else:
target_part = body
target_part = re.split(r"\bwhere\b", target_part, maxsplit=1)[0].strip()
target_part = target_part.rstrip("{").strip()
m2 = re.match(r"([A-Za-z_][A-Za-z0-9_]*)", target_part)
return m2.group(1) if m2 else ""
def _is_hidden(attrs: list[str]) -> bool:
return any(
re.search(r"#\[\s*doc\s*\(\s*hidden\s*\)\s*\]", a) for a in attrs
)
def _extract_cfgs(attrs: list[str]) -> list[str]:
return [a for a in attrs if re.search(r"#\[\s*cfg\s*\(", a)]
def _parse_enum_variants(
raw: list[str], cleaned: list[str], start: int, end: int
) -> list[EnumVariant]:
variants: list[EnumVariant] = []
v_doc: list[str] = []
i = start
while i <= end:
line = raw[i]
cln = cleaned[i]
stripped = line.strip()
m = DOC_OUTER.match(line)
if m:
v_doc.append(_doc_text(m))
i += 1
continue
if stripped.startswith("#["):
_, end_i = consume_attribute(raw, cleaned, i)
i = end_i + 1
continue
if not stripped:
i += 1
continue
paren = 0
brace = 0
end_line = None
end_col = None
for li in range(i, end + 1):
for col, c in enumerate(cleaned[li]):
if c == "(":
paren += 1
elif c == ")":
paren -= 1
elif c == "{":
brace += 1
elif c == "}":
brace -= 1
elif paren == 0 and brace == 0 and c == ",":
end_line, end_col = li, col
break
if end_line is not None:
break
if end_line is None:
end_line = end
end_col = len(cleaned[end])
sig = _join_signature(raw, i, end_line, end_col).strip()
name_m = re.match(r"(\w+)", sig)
name = name_m.group(1) if name_m else "<?>"
variants.append(
EnumVariant(
name=name,
signature=sig,
doc="\n".join(v_doc).rstrip(),
)
)
v_doc = []
i = end_line + 1
return variants
def _parse_impl_body(
raw: list[str], cleaned: list[str], start: int, end: int, *, trait: bool = False
) -> list[Item]:
fn_pattern = re.compile(PUB_FN.pattern.replace(r"pub", r"(?:pub\s+)?", 1).replace(r"\s+fn", r"\s*fn", 1)) if trait else PUB_FN
const_pattern = re.compile(r"^\s*(?:const|static)\s+([A-Za-z_]\w*)") if trait else PUB_CONST
type_pattern = re.compile(r"^\s*type\s+([A-Za-z_]\w*)") if trait else PUB_TYPE
methods: list[Item] = []
doc_buf: list[str] = []
attr_buf: list[str] = []
def clear() -> None:
doc_buf.clear()
attr_buf.clear()
i = start
while i <= end:
line = raw[i]
cln = cleaned[i]
stripped = line.strip()
m = DOC_OUTER.match(line)
if m:
doc_buf.append(_doc_text(m))
i += 1
continue
if stripped.startswith("#["):
attr, end_i = consume_attribute(raw, cleaned, i)
attr_buf.append(attr)
i = end_i + 1
continue
if not stripped:
if doc_buf or attr_buf:
j = i + 1
while j <= end and not raw[j].strip():
j += 1
if j > end:
clear()
else:
nxt = raw[j]
if not (
DOC_OUTER.match(nxt) or nxt.strip().startswith("#[")
):
clear()
i += 1
continue
if fn_pattern.match(line):
name = fn_pattern.match(line).group(1) sig, end_line, had_body = consume_fn_signature(raw, cleaned, i)
if trait:
sig += " { /* default implementation */ }" if had_body else ";"
methods.append(
Item(
kind="fn",
name=name,
signature=sig.strip(),
doc="\n".join(doc_buf).rstrip(),
hidden=_is_hidden(attr_buf),
cfg=_extract_cfgs(attr_buf),
)
)
clear()
i = end_line + 1
continue
if const_pattern.match(line):
name = const_pattern.match(line).group(1) sig, end_line = consume_stmt(raw, cleaned, i)
methods.append(
Item(
kind="const",
name=name,
signature=sig.strip(),
doc="\n".join(doc_buf).rstrip(),
hidden=_is_hidden(attr_buf),
cfg=_extract_cfgs(attr_buf),
)
)
clear()
i = end_line + 1
continue
if type_pattern.match(line):
name = type_pattern.match(line).group(1) sig, end_line = consume_stmt(raw, cleaned, i)
methods.append(
Item(
kind="type",
name=name,
signature=sig.strip(),
doc="\n".join(doc_buf).rstrip(),
hidden=_is_hidden(attr_buf),
cfg=_extract_cfgs(attr_buf),
)
)
clear()
i = end_line + 1
continue
clear()
i += 1
return methods
def parse_file(path: Path, module_name: str, cfg: list[str]) -> ParsedFile:
raw = path.read_text(encoding="utf-8").splitlines()
cleaned = preprocess(raw)
n = len(raw)
header_lines: list[str] = []
started = False
i = 0
while i < n:
stripped = raw[i].strip()
if not stripped:
if started:
header_lines.append("")
i += 1
continue
m = DOC_INNER.match(raw[i])
if m:
started = True
header_lines.append(_doc_text(m))
i += 1
continue
if not started and (stripped.startswith("//") or stripped.startswith("#![")):
i += 1
continue
break
while header_lines and not header_lines[-1].strip():
header_lines.pop()
header_doc = "\n".join(header_lines)
items: list[Item] = []
item_by_name: dict[str, Item] = {}
doc_buf: list[str] = []
attr_buf: list[str] = []
def clear() -> None:
doc_buf.clear()
attr_buf.clear()
def ensure_item(name: str) -> Item:
it = item_by_name.get(name)
if it is not None:
return it
placeholder = Item(
kind="external", name=name, signature="", doc=""
)
items.append(placeholder)
item_by_name[name] = placeholder
return placeholder
i = 0
while i < n:
line = raw[i]
stripped = line.strip()
if DOC_INNER.match(line):
i += 1
continue
m = DOC_OUTER.match(line)
if m:
doc_buf.append(_doc_text(m))
i += 1
continue
if stripped.startswith("#["):
attr, end_i = consume_attribute(raw, cleaned, i)
attr_buf.append(attr)
i = end_i + 1
continue
if not stripped:
if doc_buf or attr_buf:
j = i + 1
while j < n and not raw[j].strip():
j += 1
if j >= n:
clear()
else:
nxt = raw[j]
if not (DOC_OUTER.match(nxt) or nxt.strip().startswith("#[")):
clear()
i += 1
continue
if IMPL_PREFIX.match(line):
parsed = consume_impl_header(raw, cleaned, i)
if parsed is None:
clear()
i += 1
continue
header, open_line, open_col = parsed
close_line, _ = find_matching_brace(cleaned, open_line, open_col)
if is_trait_impl(header):
clear()
i = close_line + 1
continue
target = extract_impl_target(header)
methods = _parse_impl_body(
raw, cleaned, open_line + 1, close_line - 1
)
parent = ensure_item(target) if target else None
if parent is not None:
parent.methods.extend(methods)
clear()
i = close_line + 1
continue
if PUB_TRAIT.match(line):
name = PUB_TRAIT.match(line).group(1)
sig, end_line, open_pos = consume_item_signature(raw, cleaned, i)
members = []
if open_pos is not None:
members = _parse_impl_body(raw, cleaned, open_pos[0] + 1, end_line - 1, trait=True)
item = Item(
kind="trait", name=name, signature=sig.strip(),
doc="\n".join(doc_buf).rstrip(), hidden=_is_hidden(attr_buf),
cfg=_extract_cfgs(attr_buf), methods=members,
)
items.append(item)
item_by_name[name] = item
clear()
i = end_line + 1
continue
if PUB_STRUCT.match(line):
name = PUB_STRUCT.match(line).group(1) sig, end_line, _open = consume_item_signature(raw, cleaned, i)
item = Item(
kind="struct",
name=name,
signature=sig.strip(),
doc="\n".join(doc_buf).rstrip(),
hidden=_is_hidden(attr_buf),
cfg=_extract_cfgs(attr_buf),
)
items.append(item)
item_by_name[name] = item
clear()
i = end_line + 1
continue
if PUB_ENUM.match(line):
name = PUB_ENUM.match(line).group(1) sig, end_line, open_pos = consume_item_signature(raw, cleaned, i)
variants: list[EnumVariant] = []
if open_pos is not None:
open_line, _oc = open_pos
variants = _parse_enum_variants(
raw, cleaned, open_line + 1, end_line - 1
)
item = Item(
kind="enum",
name=name,
signature=sig.strip(),
doc="\n".join(doc_buf).rstrip(),
hidden=_is_hidden(attr_buf),
cfg=_extract_cfgs(attr_buf),
variants=variants,
)
items.append(item)
item_by_name[name] = item
clear()
i = end_line + 1
continue
if PUB_TYPE.match(line):
name = PUB_TYPE.match(line).group(1) sig, end_line = consume_stmt(raw, cleaned, i)
items.append(
Item(
kind="type",
name=name,
signature=sig.strip(),
doc="\n".join(doc_buf).rstrip(),
hidden=_is_hidden(attr_buf),
cfg=_extract_cfgs(attr_buf),
)
)
clear()
i = end_line + 1
continue
if PUB_CONST.match(line):
name = PUB_CONST.match(line).group(1) sig, end_line = consume_stmt(raw, cleaned, i)
items.append(
Item(
kind="const",
name=name,
signature=sig.strip(),
doc="\n".join(doc_buf).rstrip(),
hidden=_is_hidden(attr_buf),
cfg=_extract_cfgs(attr_buf),
)
)
clear()
i = end_line + 1
continue
if PUB_FN.match(line):
name = PUB_FN.match(line).group(1) sig, end_line, _had_body = consume_fn_signature(raw, cleaned, i)
items.append(
Item(
kind="fn",
name=name,
signature=sig.strip(),
doc="\n".join(doc_buf).rstrip(),
hidden=_is_hidden(attr_buf),
cfg=_extract_cfgs(attr_buf),
)
)
clear()
i = end_line + 1
continue
clear()
i += 1
items = [
it
for it in items
if it.kind != "external" or it.methods
]
return ParsedFile(
module_name=module_name,
file_path=path,
header_doc=header_doc,
cfg=cfg,
items=items,
)
@dataclass
class Registry:
files: list[Path]
cfg_by_file: dict[Path, list[str]]
type_to_file: dict[str, Path] module_to_file: dict[str, Path] type_display: dict[Path, list[str]] widget_display: dict[Path, list[str]] exported: set[str] = field(default_factory=set)
def _stem_to_camel(stem: str) -> str:
return "".join(part[:1].upper() + part[1:] for part in stem.split("_") if part)
def _is_test_file(p: Path) -> bool:
return p.name == "tests.rs" or p.name.endswith("_tests.rs")
_EXPORT_TOKEN_RE = re.compile(r"[A-Z][A-Za-z0-9_]*")
def _parse_public_exports(lib_rs: Path) -> set[str]:
if not lib_rs.exists():
return set()
text = lib_rs.read_text(encoding="utf-8")
exported: set[str] = set()
for m in re.finditer(r"\bpub\s+use\b(.*?);", text, re.DOTALL):
exported.update(_EXPORT_TOKEN_RE.findall(m.group(1)))
return exported
def _pick_widget_names(
stem: str,
pub_names: list[str],
widget_impls: set[str],
exported: set[str],
nested: bool,
is_widget: bool = True,
) -> list[str]:
camel = _stem_to_camel(stem)
if not is_widget:
candidates = [n for n in pub_names if n in exported]
if camel in candidates:
return [camel]
return candidates
candidates = [n for n in pub_names if n in widget_impls and n in exported]
if camel in candidates:
return [camel]
if candidates:
return candidates
if nested:
return []
widget_pub = [n for n in pub_names if n in widget_impls]
if camel in widget_pub:
return [camel]
if widget_pub:
return widget_pub
if camel in pub_names:
return [camel]
return pub_names
def _collect_cfgs_from_aggregator(aggregator: Path, base: Path) -> dict[Path, list[str]]:
out: dict[Path, list[str]] = {}
if not aggregator.exists():
return out
raw = aggregator.read_text(encoding="utf-8").splitlines()
pending: list[str] = []
for line in raw:
s = line.strip()
if not s or s.startswith("//"):
continue
if s.startswith("#["):
if re.search(r"#\[\s*cfg\s*\(", s):
pending.append(s)
continue
m = re.match(r"(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+(\w+)\s*;", s)
if m:
name = m.group(1)
target = base / f"{name}.rs"
if target.exists():
out[target.resolve()] = list(pending)
pending = []
continue
pending = []
return out
def build_registry() -> Registry:
src = SPEC.src
if not src.exists():
raise SystemExit(f"{SPEC.crate} src not found at {src}")
files = sorted(
(
p
for p in src.rglob("*.rs")
if _is_scannable(p)
),
key=lambda p: (len(p.relative_to(src).parts), str(p)),
)
cfg_by_file: dict[Path, list[str]] = {}
cfg_by_file.update(_collect_cfgs_from_aggregator(src / "lib.rs", src))
if SPEC.is_widget:
cfg_by_file.update(
_collect_cfgs_from_aggregator(src / "primitives.rs", src / "primitives")
)
cfg_by_file.update(
_collect_cfgs_from_aggregator(src / "animations.rs", src / "animations")
)
for fp in files:
rel = fp.relative_to(src)
if len(rel.parts) > 1 and fp.resolve() not in cfg_by_file:
ancestor = (src / f"{rel.parts[0]}.rs").resolve()
inherited = cfg_by_file.get(ancestor)
if inherited:
cfg_by_file[fp.resolve()] = list(inherited)
exported = _parse_public_exports(src / "lib.rs")
type_to_file: dict[str, Path] = {}
module_to_file: dict[str, Path] = {}
type_display: dict[Path, list[str]] = {}
widget_display: dict[Path, list[str]] = {}
type_re = re.compile(
r"^\s*pub\s+(?:struct|enum|type|(?:unsafe\s+)?trait)\s+([A-Za-z_]\w*)",
re.MULTILINE,
)
for fp in files:
module_to_file.setdefault(fp.stem.lower(), fp)
text = fp.read_text(encoding="utf-8")
names = [m.group(1) for m in type_re.finditer(text)]
type_display[fp] = names
widget_impls = (
{m.group(1) for m in WIDGET_IMPL_RE.finditer(text)} if SPEC.is_widget else set()
)
rel_parts = fp.relative_to(src).parts
lenient = len(rel_parts) == 1 or (
len(rel_parts) == 2 and rel_parts[0] in ("primitives", "animations")
)
widget_display[fp] = _pick_widget_names(
fp.stem, names, widget_impls, exported, nested=not lenient,
is_widget=SPEC.is_widget,
)
for name in names:
type_to_file.setdefault(name.lower(), fp)
return Registry(
files=files,
cfg_by_file=cfg_by_file,
type_to_file=type_to_file,
module_to_file=module_to_file,
type_display=type_display,
widget_display=widget_display,
exported=exported,
)
def resolve_name(reg: Registry, name: str) -> Path | None:
key = name.lower()
if key in reg.type_to_file:
return reg.type_to_file[key]
if key in reg.module_to_file:
return reg.module_to_file[key]
return None
class MissingCrateSource(Exception):
_registry_cache: dict[str, Registry] = {}
def _registry_for(key: str) -> Registry:
if key in _registry_cache:
return _registry_cache[key]
if not CRATE_SPECS[key].src.exists():
raise MissingCrateSource(key)
global SPEC
prev = SPEC
try:
SPEC = CRATE_SPECS[key]
reg = build_registry()
finally:
SPEC = prev
_registry_cache[key] = reg
return reg
def _crate_owners(name: str) -> list[str]:
owners: list[str] = []
for key in CRATE_SPECS:
try:
if resolve_name(_registry_for(key), name) is not None:
owners.append(key)
except Exception:
continue
return owners
def _cross_crate_hints(name: str, active_key: str, n: int = 5) -> list[str]:
pool: dict[str, str] = {}
for key in CRATE_SPECS:
try:
r = _registry_for(key)
except Exception:
continue
for candidate in set(r.type_to_file) | set(r.module_to_file):
pool.setdefault(candidate, key)
return [
m if pool[m] == active_key else f"{m} (--crate {pool[m]})"
for m in difflib.get_close_matches(name.lower(), list(pool), n=n)
]
def _resolve_across_crates(
reg: Registry, current_key: str, name: str
) -> tuple[Path | None, str, Registry]:
fp = resolve_name(reg, name)
if fp is not None:
return fp, current_key, reg
owner = UMBRELLA_REEXPORTS.get(name)
if owner is not None and owner != current_key:
other_reg = _registry_for(owner)
fp = resolve_name(other_reg, name)
if fp is not None:
return fp, owner, other_reg
for key in _crate_owners(name):
if key == current_key:
continue
other_reg = _registry_for(key)
fp = resolve_name(other_reg, name)
if fp is not None:
return fp, key, other_reg
return None, current_key, reg
def _fmt_cfg(cfg: list[str]) -> str:
return " ".join(cfg)
def format_markdown(pf: ParsedFile) -> str:
out: list[str] = []
rel = pf.file_path.relative_to(REPO_ROOT) if REPO_ROOT in pf.file_path.parents else pf.file_path
out.append(f"# `{pf.module_name}.rs`")
out.append("")
out.append(f"> Source: [{rel}]({rel})")
if pf.cfg:
out.append(f"> cfg: `{_fmt_cfg(pf.cfg)}`")
out.append("")
if pf.header_doc:
out.append(pf.header_doc.rstrip())
out.append("")
_emit_items_md(pf.items, out)
return "\n".join(out).rstrip() + "\n"
def _emit_items_md(items: list[Item], out: list[str]) -> None:
for it in items:
if it.kind == "external":
out.append(f"## `impl {it.name}` *(methods defined in this file)*")
out.append("")
_emit_methods_md(it, out)
continue
flags: list[str] = []
if it.hidden:
flags.append("hidden")
if it.cfg:
flags.append(_fmt_cfg(it.cfg))
flags_str = f" *({'; '.join(flags)})*" if flags else ""
if it.kind == "struct":
out.append(f"## `pub struct {it.name}`{flags_str}")
elif it.kind == "enum":
out.append(f"## `pub enum {it.name}`{flags_str}")
elif it.kind == "trait":
out.append(f"## `pub trait {it.name}`{flags_str}")
elif it.kind == "type":
out.append(f"## `pub type {it.name}`{flags_str}")
elif it.kind == "const":
out.append(f"## `pub const {it.name}`{flags_str}")
elif it.kind == "fn":
out.append(f"## `pub fn {it.name}(...)`{flags_str}")
out.append("")
if it.doc:
out.append(it.doc.rstrip())
out.append("")
if it.kind == "struct":
out.append("```rust")
out.append(f"{it.signature} {{ /* fields */ }}"
if "{" not in it.signature and not it.signature.rstrip().endswith(";")
else it.signature)
out.append("```")
out.append("")
elif it.kind == "trait":
out.extend(["```rust", f"{it.signature} {{ /* associated items below */ }}", "```", ""])
elif it.kind == "enum":
out.append("```rust")
out.append(f"{it.signature} {{ /* variants */ }}")
out.append("```")
out.append("")
elif it.kind in ("type", "const", "fn"):
out.append("```rust")
sig = it.signature
if it.kind == "fn" and not sig.rstrip().endswith(";"):
sig = sig + ";"
out.append(sig)
out.append("```")
out.append("")
if it.kind == "enum" and it.variants:
out.append("### Variants")
out.append("")
for v in it.variants:
doc = v.doc.replace("\n", " ").strip()
if doc:
out.append(f"- **`{v.name}`** — {doc}")
else:
out.append(f"- **`{v.name}`**")
out.append("")
if it.methods:
_emit_methods_md(it, out)
def _emit_methods_md(it: Item, out: list[str]) -> None:
out.append("### Associated items" if it.kind == "trait" else "### Methods")
out.append("")
for m in it.methods:
flags: list[str] = []
if m.hidden:
flags.append("hidden")
if m.cfg:
flags.append(_fmt_cfg(m.cfg))
flags_str = f" *({'; '.join(flags)})*" if flags else ""
sig = " ".join(m.signature.split())
out.append(f"#### `{sig}`{flags_str}")
out.append("")
if m.doc:
out.append(m.doc.rstrip())
out.append("")
def format_text(pf: ParsedFile) -> str:
out: list[str] = []
out.append(f"=== {pf.module_name}.rs ===")
out.append(f"Path: {pf.file_path}")
if pf.cfg:
out.append(f"cfg: {_fmt_cfg(pf.cfg)}")
out.append("")
if pf.header_doc:
out.append(pf.header_doc.rstrip())
out.append("")
for it in pf.items:
if it.kind == "external":
out.append(f"--- impl {it.name} (methods defined in this file) ---")
else:
tag = it.kind
flags = []
if it.hidden:
flags.append("hidden")
if it.cfg:
flags.append(_fmt_cfg(it.cfg))
fs = f" [{'; '.join(flags)}]" if flags else ""
out.append(f"--- {tag} {it.name}{fs} ---")
if it.doc:
out.append(_indent(it.doc, " "))
if it.signature:
out.append(f" {it.signature.strip()}")
if it.variants:
out.append(" variants:")
for v in it.variants:
if v.doc:
out.append(f" - {v.name}: {v.doc.splitlines()[0]}")
else:
out.append(f" - {v.name}")
if it.methods:
out.append(" methods:")
for m in it.methods:
flags = []
if m.hidden:
flags.append("hidden")
if m.cfg:
flags.append(_fmt_cfg(m.cfg))
fs = f" [{'; '.join(flags)}]" if flags else ""
out.append(f" • {m.signature.strip()}{fs}")
if m.doc:
out.append(_indent(m.doc, " "))
out.append("")
return "\n".join(out).rstrip() + "\n"
def _indent(s: str, prefix: str) -> str:
return "\n".join(prefix + line for line in s.splitlines())
def format_json(pfs: list[ParsedFile]) -> str:
def item_to_dict(it: Item) -> dict:
return {
"kind": it.kind,
"name": it.name,
"signature": it.signature,
"doc": it.doc,
"hidden": it.hidden,
"cfg": it.cfg,
"variants": [v.__dict__ for v in it.variants],
"methods": [item_to_dict(m) for m in it.methods],
}
payload = [
{
"module": pf.module_name,
"file": str(pf.file_path),
"cfg": pf.cfg,
"header_doc": pf.header_doc,
"items": [item_to_dict(it) for it in pf.items],
}
for pf in pfs
]
return json.dumps(payload, indent=2)
SUMMARY_BEGIN = "<!-- BEGIN GENERATED WIDGETS -->"
SUMMARY_END = "<!-- END GENERATED WIDGETS -->"
DENSITY_IMAGE_SUFFIXES: list[tuple[str, str]] = [
("-comfortable", "Comfortable"),
("-touch", "Touch"),
]
def _density_image_stem(stem: str) -> str:
for suffix, _ in DENSITY_IMAGE_SUFFIXES:
if stem.endswith(suffix):
return stem[: -len(suffix)]
return stem
_MD_SPDX_HEADER = [
"<!-- SPDX-License-Identifier: MPL-2.0 -->",
"<!-- SPDX-FileCopyrightText: 2026 FernTech -->",
"",
]
_OVERVIEW = REPO_ROOT / "docs" / "widgets-overview.md"
_OV_SECTION_RE = re.compile(r"^#{2,3}\s+(.+?)(?:\s+[—-].*)?$")
_OV_LINK_RE = re.compile(r"\]\([^)]*crates/teksilo-widgets/src/([^)\s]+?\.rs)[^)]*\)")
def _overview_category_map() -> "tuple[dict[str, str], list[str]]":
mapping: dict[str, str] = {}
order: list[str] = []
if not _OVERVIEW.exists():
return mapping, order
cur: str | None = None
for ln in _OVERVIEW.read_text(encoding="utf-8").splitlines():
s = ln.strip()
if s.startswith("#"):
m = _OV_SECTION_RE.match(s)
if m:
name = m.group(1).strip()
if name.lower() in ("cross-references", "styling status"):
cur = None
else:
cur = name
if cur not in order:
order.append(cur)
continue
if cur and s.startswith("- "):
for lm in _OV_LINK_RE.finditer(ln):
mapping[lm.group(1)] = cur
return mapping, order
def _catalog_title(reg: "Registry", pf: ParsedFile) -> str:
names = reg.widget_display.get(pf.file_path) or []
if names:
return names[0]
return _stem_to_camel(pf.module_name)
def _catalog_category(fp: Path, overview: "dict[str, str] | None" = None) -> str:
rel = fp.relative_to(SPEC.src)
if SPEC.is_widget:
if overview:
hit = overview.get(rel.as_posix())
if hit:
return hit
if len(rel.parts) == 1:
return "Other" top = rel.parts[0]
return {
"primitives": "Layout primitives",
"animations": "Animations",
}.get(top, f"{_stem_to_camel(top)} (submodule)")
if len(rel.parts) == 1:
return SPEC.group
return _stem_to_camel(rel.parts[0])
def _build_slugs(parsed: list[ParsedFile]) -> dict[Path, str]:
slugs: dict[Path, str] = {}
used: set[str] = {"index"} for pf in parsed:
slug = pf.module_name
if slug in used:
rel = pf.file_path.relative_to(SPEC.src).with_suffix("")
slug = "_".join(rel.parts)
while slug in used: slug = f"{slug}_"
used.add(slug)
slugs[pf.file_path] = slug
return slugs
def _rustdoc_module_url(
api_base: "str | None", fp: Path, api_dir: "Path | None" = None
) -> str:
rel = fp.relative_to(SPEC.src).with_suffix("")
parts = list(rel.parts)
base = SPEC.docs_rs if api_base is None else api_base
def url(ps: list[str]) -> str:
tail = "/".join([SPEC.rustdoc, *ps, "index.html"])
return f"{base.rstrip('/')}/{tail}"
if api_dir is not None:
cand = list(parts)
while cand:
disk = Path(api_dir) / SPEC.rustdoc / Path(*cand) / "index.html"
if disk.exists():
return url(cand)
cand = cand[:-1]
return url([])
if len(parts) >= 2 and not (SPEC.is_widget and parts[0] in ("primitives", "animations")):
parts = parts[:1]
return url(parts)
def _first_sentence(text: str, limit: int = 160) -> str:
for line in text.splitlines():
s = line.strip()
if not s or s.startswith("#") or s.startswith("```"):
continue
m = re.search(r"\.(?:\s|$)", s)
sentence = s[: m.start()] if m else s
sentence = sentence.strip()
if len(sentence) > limit:
sentence = sentence[: limit - 1].rstrip() + "…"
return sentence
return ""
_INLINE_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_REF_DEF_RE = re.compile(r"^(\s*)\[([^\]]+)\]:\s*(\S+).*$")
def _as_code(label: str) -> str:
label = label.strip()
if label.startswith("`") and label.endswith("`"):
return label
return f"`{label}`"
def _catalog_keep_target(target: str) -> bool:
t = target.strip()
return (
t.startswith("#")
or t.startswith("../api/")
or t.startswith("img/")
or t.startswith("mailto:")
or "://" in t
)
def _clean_catalog_links(md: str) -> str:
dropped: set[str] = set()
kept: list[str] = []
for ln in md.split("\n"):
m = _REF_DEF_RE.match(ln)
if m and not _catalog_keep_target(m.group(3)):
dropped.add(m.group(2).strip())
continue
kept.append(ln)
md = "\n".join(kept)
def repl(m: "re.Match[str]") -> str:
if _catalog_keep_target(m.group(2)):
return m.group(0)
return _as_code(m.group(1))
md = _INLINE_LINK_RE.sub(repl, md)
for lbl in dropped:
code = _as_code(lbl)
md = md.replace(f"[{lbl}][]", code)
md = re.sub(re.escape(f"[{lbl}]") + r"(?![\(\[])", code, md)
md = re.sub(r"\[(`[^`\]]+`)\](?![\(\[:])", r"\1", md)
return md
def _catalog_abilities(pf: ParsedFile, title: str) -> str:
primary = None
for it in pf.items:
if it.kind in ("struct", "external") and it.name == title and it.methods:
primary = it
break
if primary is None:
for it in pf.items:
if it.methods:
primary = it
break
if primary is None:
return ""
names = [
f"`{m.name}`"
for m in primary.methods
if not m.hidden and m.name not in ("new", "default")
]
return ", ".join(names)
def format_catalog_markdown(
pf: ParsedFile,
*,
title: str,
slug: str,
api_base: "str | None",
img_dir: Path,
api_dir: "Path | None" = None,
) -> str:
out: list[str] = list(_MD_SPDX_HEADER)
out.append(f"# {title}")
out.append("")
if (img_dir / f"{slug}.png").exists():
out.append(f"")
out.append("")
if pf.cfg:
out.append(f"> Available under: `{_fmt_cfg(pf.cfg)}`")
out.append("")
if pf.header_doc:
out.append(pf.header_doc.rstrip())
out.append("")
density_images = [
(suffix, label)
for suffix, label in DENSITY_IMAGE_SUFFIXES
if (img_dir / f"{slug}{suffix}.png").exists()
]
if density_images:
out.append("## Density")
out.append("")
out.append(
"The picture above is the widget at `TargetDensity::Compact`, the "
"mouse-and-keyboard ladder. Below is the same subject on the same "
"canvas with only the ladder changed, so what moves is the density "
"and nothing else — where the subject no longer fits, that is what "
"the denser targets cost it at that size. "
"See `docs/density-and-targets.md`."
)
out.append("")
for suffix, label in density_images:
out.append(f"**{label}**")
out.append("")
out.append(f"")
out.append("")
abilities = _catalog_abilities(pf, title)
if abilities:
out.append("## Builder methods at a glance")
out.append("")
out.append(abilities)
out.append("")
out.append("## API reference")
out.append("")
out.append(
f"📖 [Full rustdoc API for this module]"
f"({_rustdoc_module_url(api_base, pf.file_path, api_dir)})"
)
out.append("")
_emit_items_md(pf.items, out)
return _clean_catalog_links("\n".join(out).rstrip()) + "\n"
def format_catalog_index(
reg: "Registry", parsed: list[ParsedFile], slugs: dict[Path, str]
) -> str:
overview, ov_order = _overview_category_map()
groups: dict[str, list[tuple[str, str, str]]] = {}
for pf in parsed:
cat = _catalog_category(pf.file_path, overview)
title = _catalog_title(reg, pf)
brief = _clean_catalog_links(_first_sentence(pf.header_doc))
groups.setdefault(cat, []).append((title, slugs[pf.file_path], brief))
ordered = [c for c in ov_order if c in groups]
ordered += sorted(c for c in groups if c not in ordered)
out: list[str] = list(_MD_SPDX_HEADER)
out.append(f"# {SPEC.title}")
out.append("")
noun = "widget" if SPEC.is_widget else "type"
out.append(
f"Every public {noun} in `{SPEC.crate}`, grouped by category. Each page "
"links to its full rustdoc API reference."
)
out.append("")
for cat in ordered:
out.append(f"## {cat}")
out.append("")
for title, slug, brief in sorted(groups[cat], key=lambda t: t[0].lower()):
line = f"- [{title}]({slug}.md)"
if brief:
line += f" — {brief}"
out.append(line)
out.append("")
return "\n".join(out).rstrip() + "\n"
def _summary_block(
reg: "Registry", parsed: list[ParsedFile], slugs: dict[Path, str], md_subdir: str
) -> str:
lines = [SPEC.marker_begin, f"- [Overview]({md_subdir}/index.md)"]
for pf in sorted(parsed, key=lambda p: _catalog_title(reg, p).lower()):
title = _catalog_title(reg, pf)
lines.append(f"- [{title}]({md_subdir}/{slugs[pf.file_path]}.md)")
lines.append(SPEC.marker_end)
return "\n".join(lines)
def book_subdir(out_dir: Path, book_src: Path) -> "str | None":
try:
return out_dir.resolve().relative_to(book_src.resolve()).as_posix()
except (ValueError, OSError):
return None
def patch_summary(summary_path: Path, block: str, begin: str, end: str) -> bool:
text = summary_path.read_text(encoding="utf-8")
if begin not in text or end not in text:
return False
pre = text[: text.index(begin)]
post = text[text.index(end) + len(end):]
summary_path.write_text(pre + block + post, encoding="utf-8")
return True
def merge_submodule_items(reg: "Registry", pf: ParsedFile) -> ParsedFile:
sub_dir = pf.file_path.parent / pf.file_path.stem
if not sub_dir.is_dir():
return pf
have = {item.name for item in pf.items}
extra: list[Item] = []
for sub in sorted(sub_dir.glob("*.rs")):
if sub.name in SKIP_FILES or _is_test_file(sub):
continue
if reg.widget_display.get(sub):
continue
sub_pf = parse_file(sub, sub.stem, reg.cfg_by_file.get(sub.resolve(), []))
for item in sub_pf.items:
if item.name in have or item.name not in reg.exported:
continue
have.add(item.name)
extra.append(item)
if not extra:
return pf
return ParsedFile(
module_name=pf.module_name,
file_path=pf.file_path,
header_doc=pf.header_doc,
cfg=pf.cfg,
items=pf.items + extra,
)
_PAGE_SIGNATURE = "Full rustdoc API for this module"
def _prune_stale_pages(out_dir: Path, written: "set[str]") -> "list[str]":
removed = []
for md in sorted(out_dir.glob("*.md")):
if md.name in written:
continue
try:
if _PAGE_SIGNATURE not in md.read_text(encoding="utf-8"):
continue
except (OSError, UnicodeDecodeError):
continue
md.unlink()
removed.append(md.name)
return removed
def cmd_md_dir(
reg: "Registry",
md_dir: str,
api_base: "str | None",
api_dir: "Path | None" = None,
) -> int:
out_dir = Path(md_dir)
out_dir.mkdir(parents=True, exist_ok=True)
img_dir = out_dir / "img"
target = [fp for fp in reg.files if reg.widget_display.get(fp)]
parsed = [
merge_submodule_items(
reg, parse_file(fp, fp.stem, reg.cfg_by_file.get(fp.resolve(), []))
)
for fp in target
]
slugs = _build_slugs(parsed)
for pf in parsed:
slug = slugs[pf.file_path]
page = format_catalog_markdown(
pf,
title=_catalog_title(reg, pf),
slug=slug,
api_base=api_base,
img_dir=img_dir,
api_dir=api_dir,
)
(out_dir / f"{slug}.md").write_text(page, encoding="utf-8")
(out_dir / "index.md").write_text(
format_catalog_index(reg, parsed, slugs), encoding="utf-8"
)
removed = _prune_stale_pages(
out_dir, {f"{s}.md" for s in slugs.values()} | {"index.md"}
)
live_slugs = set(slugs.values())
stale_img = (
sorted(
q.name
for q in img_dir.glob("*.png")
if _density_image_stem(q.stem) not in live_slugs
)
if img_dir.is_dir()
else []
)
book_src = REPO_ROOT / "docs"
md_subdir = book_subdir(out_dir, book_src)
summary = book_src / "SUMMARY.md"
if md_subdir is None:
note = f"SUMMARY.md untouched — {out_dir} is outside the book source ({book_src})"
elif summary.exists() and patch_summary(
summary, _summary_block(reg, parsed, slugs, md_subdir), SPEC.marker_begin, SPEC.marker_end
):
note = "patched docs/SUMMARY.md"
else:
note = (
f"SUMMARY markers not found — add this region to docs/SUMMARY.md:\n"
f"{SPEC.marker_begin}\n{SPEC.marker_end}"
)
print(
f"Wrote {len(parsed)} catalog pages + index.md to {out_dir} ({note})",
file=sys.stderr,
)
if removed:
print(
f" pruned {len(removed)} stale page(s): {', '.join(removed)}",
file=sys.stderr,
)
if stale_img:
print(
f" {len(stale_img)} image(s) with no page, left in place for "
f"--export-docs to reconcile: {', '.join(stale_img)}",
file=sys.stderr,
)
return 0
def _test_prune() -> None:
import tempfile
with tempfile.TemporaryDirectory() as td:
d = Path(td)
img = d / "img"
img.mkdir()
page = f"# X\n\n{_PAGE_SIGNATURE} link\n"
(d / "button.md").write_text(page) (d / "popover.md").write_text(page) (d / "index.md").write_text("# Index") (d / "notes.md").write_text("hand-written, no signature")
(img / "button.png").write_bytes(b"\x89PNG")
(img / "popover.png").write_bytes(b"\x89PNG")
(d / "stray.txt").write_text("not markdown")
(img / "nested.md").write_text(page)
removed = _prune_stale_pages(d, {"button.md", "index.md"})
assert removed == ["popover.md"], removed
assert not (d / "popover.md").exists()
for name in ("button.md", "index.md", "notes.md", "stray.txt"):
assert (d / name).exists(), name
assert (img / "button.png").exists()
assert (img / "popover.png").exists(), "prune must never reach into img/"
assert (img / "nested.md").exists(), "prune must not recurse below out_dir"
def _test_density_images() -> None:
import tempfile
reg = build_registry()
fp = reg.module_to_file["button"]
pf = parse_file(fp, fp.stem, reg.cfg_by_file.get(fp.resolve(), []))
with tempfile.TemporaryDirectory() as td:
img = Path(td)
(img / "button.png").write_bytes(b"\x89PNG")
page = format_catalog_markdown(
pf, title="Button", slug="button", api_base="../api", img_dir=img
)
assert "" in page
assert "## Density" not in page, "no denser image exists yet"
(img / "button-touch.png").write_bytes(b"\x89PNG")
page = format_catalog_markdown(
pf, title="Button", slug="button", api_base="../api", img_dir=img
)
assert "## Density" in page, "a -touch image must earn a Density section"
assert "" in page
assert "" in page
assert _density_image_stem("button-touch") == "button"
assert _density_image_stem("button-comfortable") == "button"
assert _density_image_stem("button") == "button"
assert _density_image_stem("touch_target") == "touch_target"
def _test_summary_scope() -> None:
import tempfile
with tempfile.TemporaryDirectory() as td:
book = Path(td) / "docs"
(book / "scene").mkdir(parents=True)
(book / "widgets" / "extra").mkdir(parents=True)
assert book_subdir(book / "scene", book) == "scene", "in-book dir rejected"
assert book_subdir(book / "widgets" / "extra", book) == "widgets/extra", "nested dir"
assert book_subdir(Path(td) / "gen", book) is None, "scratch dir accepted"
assert book_subdir(Path(td), book) is None, "the book's own parent accepted"
reg = build_registry()
summary = REPO_ROOT / "docs" / "SUMMARY.md"
before = summary.read_text(encoding="utf-8")
with tempfile.TemporaryDirectory() as td:
cmd_md_dir(reg, str(Path(td) / "gen"), None, None)
assert summary.read_text(encoding="utf-8") == before, (
"a --md-dir run outside docs/ rewrote docs/SUMMARY.md"
)
def run_self_tests() -> int:
import tempfile
_test_prune()
_test_density_images()
_test_summary_scope()
reg = build_registry()
fp = reg.module_to_file["button"]
pf = parse_file(fp, fp.stem, reg.cfg_by_file.get(fp.resolve(), []))
page = format_catalog_markdown(
pf, title="Button", slug="button", api_base="../api", img_dir=Path("/nonexistent")
)
assert page.startswith("<!-- SPDX-License-Identifier"), page[:40]
assert "\n# Button\n" in page, page[:80]
assert "## API reference" in page, "missing API reference section"
assert "Full rustdoc API" in page, "missing rustdoc deep link"
assert "teksilo_widgets/button/index.html" in page, "wrong rustdoc url"
slugs = _build_slugs([pf])
idx = format_catalog_index(reg, [pf], slugs)
assert "\n# Widget Catalog\n" in idx, idx[:80]
assert "[Button](button.md)" in idx, "index missing button link"
with tempfile.TemporaryDirectory() as td:
sp = Path(td) / "SUMMARY.md"
sp.write_text(
f"# Index\n\n{SUMMARY_BEGIN}\nstale\n{SUMMARY_END}\n\n# Tail\n",
encoding="utf-8",
)
block = _summary_block(reg, [pf], slugs, "widgets")
assert patch_summary(sp, block, SUMMARY_BEGIN, SUMMARY_END), "patch returned False"
result = sp.read_text(encoding="utf-8")
assert "stale" not in result, "stale content survived"
assert "[Overview](widgets/index.md)" in result, "catalog overview missing"
assert "[Button](widgets/button.md)" in result, "widget chapter missing"
assert "# Index" in result and "# Tail" in result, "surrounding text clobbered"
assert _rustdoc_module_url(None, WIDGETS_SRC / "button.rs") == (
"https://docs.rs/teksilo-widgets/latest/teksilo_widgets/button/index.html"
), _rustdoc_module_url(None, WIDGETS_SRC / "button.rs")
assert _rustdoc_module_url("../api", PRIMITIVES_DIR / "hstack.rs").endswith(
"teksilo_widgets/primitives/hstack/index.html"
), "rustdoc url for nested module wrong"
assert _rustdoc_module_url("../api", WIDGETS_SRC / "tab_widget" / "bar.rs").endswith(
"teksilo_widgets/tab_widget/index.html"
), "rustdoc url for private submodule should fall back to parent"
nz = _clean_catalog_links(
"[`HStack`](crate::primitives::HStack), [a](Self::alignment), [b](self), "
"[c](TreeView), [d](../crates/x.rs), [api](../api/x.html), [web](https://x.io)\n"
"see [`Ref`].\n\n[`Ref`]: crate::Ref"
)
for bad in ("](crate::", "](Self::", "](self)", "](TreeView)", "](../crates/", "[`Ref`]:"):
assert bad not in nz, f"{bad} survived: {nz}"
assert "`HStack`" in nz and "see `Ref`." in nz, nz
assert "](../api/x.html)" in nz and "](https://x.io)" in nz, nz
global SPEC
_prev = SPEC
try:
SPEC = CRATE_SPECS["data"]
dreg = build_registry()
dfp = dreg.module_to_file["list_model"]
assert dreg.widget_display.get(dfp), "list_model.rs should be a catalog entry"
dpf = parse_file(dfp, dfp.stem, dreg.cfg_by_file.get(dfp.resolve(), []))
dpage = format_catalog_markdown(
dpf, title="ListModel", slug="list_model", api_base="../api",
img_dir=Path("/nonexistent"),
)
assert "teksilo_data/list_model/index.html" in dpage, dpage[:400]
didx = format_catalog_index(dreg, [dpf], _build_slugs([dpf]))
assert "\n# Data Collections\n" in didx, didx[:80]
finally:
SPEC = _prev
_prev = SPEC
try:
SPEC = CRATE_SPECS["core"]
assert not SPEC.catalog, "'core' must stay catalog=False for this to test the right thing"
creg = build_registry()
cfp = creg.type_to_file.get("theme")
assert cfp is not None, "Theme should be resolvable by name in teksilo-core"
cpf = parse_file(cfp, cfp.stem, creg.cfg_by_file.get(cfp.resolve(), []))
assert any(item.name == "Theme" for item in cpf.items), (
"Theme struct not extracted from teksilo-core"
)
finally:
SPEC = _prev
catalog_keys = {k for k, s in CRATE_SPECS.items() if s.catalog}
assert catalog_keys == {"widgets", "data", "settings", "scene"}, (
f"--catalog-all must visit exactly the four cataloged crates, got {catalog_keys}"
)
for _name, _key in UMBRELLA_REEXPORTS.items():
assert _key in CRATE_SPECS, f"UMBRELLA_REEXPORTS[{_name!r}] -> unknown crate key {_key!r}"
assert "theme" not in reg.type_to_file, "test assumption: Theme is not itself in teksilo-widgets"
redirected_fp, used_key, _ = _resolve_across_crates(reg, "widgets", "Theme")
assert used_key == "core" and redirected_fp is not None, (
"umbrella redirect for 'Theme' should land on teksilo-core"
)
assert len(_crate_owners("Theme")) > 1, (
"test assumption: 'Theme' is defined in more than one crate"
)
assert UMBRELLA_REEXPORTS.get("Theme") == "core", (
"test assumption: the table pins Theme to teksilo-core"
)
assert "ListModel" not in UMBRELLA_REEXPORTS, (
"test assumption: ListModel is not a prelude re-export"
)
swept_fp, swept_key, swept_reg = _resolve_across_crates(reg, "widgets", "ListModel")
assert swept_key == "data" and swept_fp is not None, (
f"crate sweep for 'ListModel' should land on teksilo-data, got {swept_key!r}"
)
assert swept_reg is not reg, "sweep must return the OWNING crate's registry, not the caller's"
for _name, _expect in (
("TreeSlice", "data"),
("SelectionModel", "data"),
("MruList", "settings"),
("SceneCard", "scene"),
):
_fp, _key, _ = _resolve_across_crates(reg, "widgets", _name)
assert _fp is not None and _key == _expect, (
f"{_name} should resolve to --crate {_expect}, got {_key!r}"
)
_hints = _cross_crate_hints("LstModel", "widgets")
assert any("--crate data" in h for h in _hints), (
f"typo hints should reach teksilo-data, got {_hints}"
)
_none_fp, _, _ = _resolve_across_crates(reg, "widgets", "NoSuchTypeAnywhere")
assert _none_fp is None, "the sweep must not invent a resolution"
print("extract_widget_api.py self-tests passed.", file=sys.stderr)
return 0
def cmd_list(reg: Registry) -> int:
rows: list[tuple[str, str]] = []
for fp, names in sorted(reg.widget_display.items(), key=lambda kv: kv[0].name):
if not names:
continue
rel = fp.relative_to(REPO_ROOT) if REPO_ROOT in fp.parents else fp
cfg = reg.cfg_by_file.get(fp.resolve(), [])
cfg_s = f" {{{_fmt_cfg(cfg)}}}" if cfg else ""
rows.append((fp.stem, f" {', '.join(names)} ({rel}){cfg_s}"))
if not rows:
print("No catalog files found.", file=sys.stderr)
return 1
print(f"{len(rows)} files under {SPEC.src.relative_to(REPO_ROOT)}:\n")
for stem, body in sorted(rows):
print(f"{stem}:")
print(body)
return 0
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description=(
"Extract public API + docs from teksilo-widgets source files."
),
)
parser.add_argument(
"widgets",
nargs="*",
help="Widget names (type or module name, case-insensitive). "
"e.g. Button HStack Dialog",
)
parser.add_argument(
"--all",
action="store_true",
help="Extract every widget file.",
)
parser.add_argument(
"--list",
action="store_true",
help="List available widgets and exit.",
)
parser.add_argument(
"--format",
"-f",
choices=("md", "text", "json"),
default="md",
help="Output format (default: md).",
)
parser.add_argument(
"--output",
"-o",
help="Write output to this file instead of stdout.",
)
parser.add_argument(
"--md-dir",
metavar="DIR",
help="Generate one mdBook catalog page per widget into DIR "
"(e.g. docs/widgets), plus index.md, and patch the generated region of "
"docs/SUMMARY.md — only when DIR is inside docs/, so a scratch-directory "
"run leaves the book's table of contents alone. Ignores positional widget "
"names (always emits all).",
)
parser.add_argument(
"--api-base",
default=None,
help="Base URL/path for the rustdoc API links in catalog pages. "
"Default: the crate's docs.rs base, so a committed page resolves when "
"read on GitHub; fix_book_links.py repoints it at the book's own "
"/api/ tree at build time. Pass ../api to emit that directly.",
)
parser.add_argument(
"--api-dir",
metavar="DIR",
help="Path to the built rustdoc tree (e.g. target/doc). When given, each "
"catalog page's API link falls back to the nearest module that actually "
"has a page, so private/cfg-gated modules don't 404.",
)
parser.add_argument(
"--crate",
choices=list(CRATE_SPECS),
default="widgets",
help="Which crate to extract / catalog (default: widgets).",
)
parser.add_argument(
"--catalog-all",
action="store_true",
help="Generate the mdBook catalog for ALL crates into their default "
"docs/<dir> and patch each SUMMARY region.",
)
parser.add_argument(
"--test",
action="store_true",
help=argparse.SUPPRESS, )
args = parser.parse_args(argv)
if args.test:
return run_self_tests()
global SPEC
def _api_dir() -> "Path | None":
if args.api_dir:
return Path(args.api_dir)
_doc = REPO_ROOT / "target" / "doc"
return _doc if (_doc / SPEC.rustdoc).exists() else None
if args.catalog_all:
rc = 0
for key, spec in CRATE_SPECS.items():
if not spec.catalog:
continue
SPEC = spec
reg = build_registry()
out = REPO_ROOT / "docs" / spec.md_subdir
rc |= cmd_md_dir(reg, str(out), args.api_base, _api_dir())
return rc
SPEC = CRATE_SPECS[args.crate]
if args.widgets and not SPEC.src.exists() and not (args.all or args.list or args.md_dir):
reg = Registry([], {}, {}, {}, {}, {})
else:
reg = build_registry()
if args.list:
return cmd_list(reg)
if args.md_dir:
if not SPEC.catalog:
print(
f"error: '{args.crate}' ({SPEC.crate}) is queryable via "
"--list / --all / <Name> but is not part of the mdBook "
"catalog — CRATE_SPECS marks it catalog=False. Refusing to "
"write pages for it; flip `catalog=True` in CRATE_SPECS "
"first if this crate should join the book.",
file=sys.stderr,
)
return 2
return cmd_md_dir(reg, args.md_dir, args.api_base, _api_dir())
file_cfg: dict[Path, list[str]] = {}
if args.all:
target_files = list(reg.files)
for fp in target_files:
file_cfg[fp] = reg.cfg_by_file.get(fp.resolve(), [])
elif args.widgets:
target_files = []
seen: set[Path] = set()
for name in args.widgets:
fp, used_key, used_reg = _resolve_across_crates(reg, args.crate, name)
if fp is None:
hints = _cross_crate_hints(name, args.crate)
hint_str = (
f" Did you mean: {', '.join(hints)}?" if hints else ""
)
print(
f"error: unknown type '{name}' in any teksilo crate."
f"{hint_str}",
file=sys.stderr,
)
return 2
if used_key != args.crate:
via = (
"the teksilo umbrella prelude"
if UMBRELLA_REEXPORTS.get(name) == used_key
else "a crate sweep"
)
note = (
f"note: '{name}' isn't in {SPEC.crate}; resolved via "
f"{via} to {CRATE_SPECS[used_key].crate} "
f"(--crate {used_key})."
)
others = [
k
for k in _crate_owners(name)
if k not in (used_key, args.crate)
]
if others:
note += " Also defined in: " + ", ".join(
f"{CRATE_SPECS[k].crate} (--crate {k})" for k in others
) + "."
if not os.environ.get("TEKSILO_EXTRACTOR_QUIET"):
print(note, file=sys.stderr)
if fp not in seen:
seen.add(fp)
target_files.append(fp)
file_cfg[fp] = used_reg.cfg_by_file.get(fp.resolve(), [])
else:
parser.print_help(sys.stderr)
print(
"\nPass one or more widget names, --all, or --list.",
file=sys.stderr,
)
return 2
parsed: list[ParsedFile] = []
for fp in target_files:
pf = parse_file(fp, fp.stem, file_cfg.get(fp, []))
parsed.append(pf)
if args.format == "json":
rendered = format_json(parsed) + "\n"
elif args.format == "text":
rendered = "\n".join(format_text(pf) for pf in parsed)
else:
rendered = "\n---\n\n".join(format_markdown(pf) for pf in parsed)
if args.output:
Path(args.output).write_text(rendered, encoding="utf-8")
else:
sys.stdout.write(rendered)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))