OpenSpace/openspace/protocol/codegen.py
2026-07-17 11:43:42 +08:00

289 lines
9.4 KiB
Python

"""Sync generated TypeScript protocol surfaces from canonical manifests."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from .events import load_event_manifest
from .slash_commands import load_slash_command_manifest
ROOT = Path(__file__).resolve().parents[2]
TUI_PROTOCOL_PATH = ROOT / "apps" / "tui" / "src" / "bridge" / "protocol.ts"
TUI_REGISTRY_PATH = ROOT / "apps" / "tui" / "src" / "commands" / "registry.ts"
EVENTS_BEGIN = "// <openspace-protocol-events:generated>"
EVENTS_END = "// </openspace-protocol-events:generated>"
EVENT_MAP_BEGIN = "// <openspace-protocol-event-data-map:generated>"
EVENT_MAP_END = "// </openspace-protocol-event-data-map:generated>"
COMMANDS_BEGIN = "// <openspace-slash-commands:generated>"
COMMANDS_END = "// </openspace-slash-commands:generated>"
def _quote(value: str) -> str:
return json.dumps(value)
def _ts_string_array(values: list[str], *, indent: str = " ") -> str:
if not values:
return "[]"
lines = ["["]
lines.extend(f"{indent}{_quote(value)}," for value in values)
lines.append("]")
return "\n".join(lines)
def _ts_type_union(values: list[str], *, indent: str = " ") -> str:
if not values:
return "never"
return "\n".join(f"{indent}| {_quote(value)}" for value in values)
def _unique(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value in seen:
continue
seen.add(value)
result.append(value)
return result
def _replace_section(source: str, begin: str, end: str, rendered: str) -> str:
start = source.find(begin)
finish = source.find(end)
if start == -1 or finish == -1 or finish < start:
raise RuntimeError(f"missing generated section markers {begin!r} / {end!r}")
finish += len(end)
return source[:start] + rendered.rstrip() + source[finish:]
def _command_literal(command: dict[str, Any]) -> dict[str, Any]:
handler = str(command["handler"])
literal: dict[str, Any] = {
"handler": handler,
"availability": [handler],
"implemented": bool(command.get("implemented", True)),
"name": str(command["name"]),
"description": str(command["description"]),
"summary": str(command["summary"]),
"usage": str(command["usage"]),
"category": str(command["category"]),
}
aliases = [str(alias) for alias in command.get("aliases", [])]
if aliases:
literal["aliases"] = aliases
args = command.get("args", [])
if args:
literal["args"] = [
{
"name": str(arg["name"]),
"required": bool(arg["required"]),
"description": str(arg["description"]),
}
for arg in args
]
if not bool(command.get("tui_visible", True)):
literal["isHidden"] = True
return literal
def render_protocol_events_section() -> str:
manifest = load_event_manifest()
tui_to_core = [str(value) for value in manifest["tui_to_core"]]
core_to_tui = [str(value) for value in manifest["core_to_tui"]]
all_events = _unique(tui_to_core + core_to_tui)
policy = manifest.get("unknown_event", {})
warning_event = str(policy.get("event") or "notification")
warning_level = str(policy.get("level") or "warn")
warning_title = str(policy.get("title") or "Protocol warning")
return "\n".join(
[
EVENTS_BEGIN,
"// Generated by python -m openspace.protocol.codegen --write",
"export const TUI_TO_CORE_MSG_TYPES = "
+ _ts_string_array(tui_to_core)
+ " as const;",
"",
"export const CORE_TO_TUI_MSG_TYPES = "
+ _ts_string_array(core_to_tui)
+ " as const;",
"",
"export type TuiToCoreMsgType = typeof TUI_TO_CORE_MSG_TYPES[number];",
"",
"export type CoreToTuiMsgType = typeof CORE_TO_TUI_MSG_TYPES[number];",
"",
"export type EventType = TuiToCoreMsgType | CoreToTuiMsgType;",
"",
"const KNOWN_EVENT_TYPES: ReadonlySet<string> = new Set("
+ _ts_string_array(all_events)
+ ");",
"",
"export function isKnownEventType(value: string): value is EventType {",
" return KNOWN_EVENT_TYPES.has(value);",
"}",
"",
"export function makeProtocolWarningMessage(",
" eventType: string,",
" reason: string,",
"): IPCMessage<\"notification\"> {",
" return {",
f" type: {_quote(warning_event)},",
" data: {",
f" level: {_quote(warning_level)},",
f" title: {_quote(warning_title)},",
" message: `${reason}: ${eventType}`,",
" event_type: eventType,",
" },",
" };",
"}",
EVENTS_END,
]
)
def render_protocol_event_map_section() -> str:
manifest = load_event_manifest()
payload_types = manifest["payload_types"]
lines = [
EVENT_MAP_BEGIN,
"// Generated by python -m openspace.protocol.codegen --write",
"export type EventDataMap = {",
]
for event_type in _unique([*manifest["tui_to_core"], *manifest["core_to_tui"]]):
lines.append(f" {event_type}: {payload_types[event_type]};")
lines.extend(["};", EVENT_MAP_END])
return "\n".join(lines)
def render_registry_section() -> str:
manifest = load_slash_command_manifest()
commands = manifest["commands"]
categories = manifest.get("categories", {})
command_names = [str(command["name"]) for command in commands]
category_names = [str(name) for name in categories]
registry_json = json.dumps(
[_command_literal(command) for command in commands],
indent=2,
ensure_ascii=False,
)
category_json = json.dumps(categories, indent=2, ensure_ascii=False)
return "\n".join(
[
COMMANDS_BEGIN,
"// Generated by python -m openspace.protocol.codegen --write",
"export type SlashCommandName =",
_ts_type_union(command_names)
+ ";",
"",
"export type SlashCommandCategory =",
_ts_type_union(category_names)
+ ";",
"",
"export type SlashCommandArg = {",
" name: string;",
" required: boolean;",
" description: string;",
"};",
"",
"export type SlashCommandDefinition = Command & {",
" name: SlashCommandName;",
" summary: string;",
" usage: string;",
" category: SlashCommandCategory;",
" args?: SlashCommandArg[];",
" hidden?: boolean;",
" implemented?: boolean;",
"};",
"",
"export type ParsedSlashCommand = SlashCommandData & {",
" args: string[];",
" raw: string;",
" definition: SlashCommandDefinition | null;",
" isSupported: boolean;",
"};",
"",
"const SLASH_COMMAND_REGISTRY: readonly SlashCommandDefinition[] = "
+ registry_json
+ " as const;",
"",
"const CATEGORY_LABELS: Record<SlashCommandCategory, string> = "
+ category_json
+ ";",
COMMANDS_END,
]
)
def render_tui_protocol(source: str) -> str:
source = _replace_section(
source,
EVENTS_BEGIN,
EVENTS_END,
render_protocol_events_section(),
)
return _replace_section(
source,
EVENT_MAP_BEGIN,
EVENT_MAP_END,
render_protocol_event_map_section(),
)
def render_tui_registry(source: str) -> str:
return _replace_section(
source,
COMMANDS_BEGIN,
COMMANDS_END,
render_registry_section(),
)
def sync_generated_files(*, check: bool) -> list[Path]:
targets = {
TUI_PROTOCOL_PATH: render_tui_protocol(TUI_PROTOCOL_PATH.read_text(encoding="utf-8")),
TUI_REGISTRY_PATH: render_tui_registry(TUI_REGISTRY_PATH.read_text(encoding="utf-8")),
}
changed: list[Path] = []
for path, rendered in targets.items():
current = path.read_text(encoding="utf-8")
if current == rendered:
continue
changed.append(path)
if not check:
path.write_text(rendered, encoding="utf-8")
return changed
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Sync generated TypeScript protocol files from OpenSpace manifests."
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--check", action="store_true", help="fail if generated files are stale")
mode.add_argument("--write", action="store_true", help="update generated files")
args = parser.parse_args(argv)
check = not args.write
changed = sync_generated_files(check=check)
if not changed:
print("protocol generated files are up to date")
return 0
for path in changed:
print(path.relative_to(ROOT).as_posix())
if check:
print("generated files are stale; run python -m openspace.protocol.codegen --write")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())