landsandboat/tools/codegen/data_codegen.py

265 lines
12 KiB
Python
Raw Permalink Normal View History

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
import yaml
from jsonschema import Draft202012Validator, exceptions as jsonschema_exceptions
from .common import CASE_FNS, ENUMS_DIR, ENV, ROOT, snake_to_pascal
from .yaml_loaders import FastLoader
PopulatorKind = Literal["scalar", "enum_ref", "bitflags", "enum_list", "record", "record_list"]
@dataclass(slots=True)
class Field:
"""`populator` drives the template dispatch. enum_* set on enum_ref/bitflags/enum_list; defs_*/struct_* on record/record_list."""
yaml_name: str
cpp_name: str
cpp_type: str
populator: PopulatorKind
enum_name: str = ""
enum_class: str = ""
defs_name: str = ""
struct_name: str = ""
default: str = "" # C++-literal form of schema `default`, empty when not declared.
class SchemaWalker:
"""One instance per codegen run; caches each enum YAML lookup so we open the file at most once."""
def __init__(self) -> None:
self.enum_cls_cache: dict[str, str] = {}
self.enum_flags_cache: dict[str, bool] = {}
self.enum_case_cache: dict[str, str] = {}
def _load_enum_meta(self, name: str) -> dict[str, Any]:
yaml_path = ENUMS_DIR / f"{name}.yaml"
if not yaml_path.exists():
return {}
with yaml_path.open(encoding="utf-8") as f:
return (yaml.load(f, Loader=FastLoader) or {}).get("meta") or {}
def enum_is_flags(self, name: str) -> bool:
if name not in self.enum_flags_cache:
self.enum_flags_cache[name] = bool(self._load_enum_meta(name).get("flags", False))
return self.enum_flags_cache[name]
def enum_case(self, name: str) -> str:
if name not in self.enum_case_cache:
self.enum_case_cache[name] = (self._load_enum_meta(name).get("cpp") or {}).get("case", "pascal")
return self.enum_case_cache[name]
def enum_default(self, enum_name: str, cls: str, value: str) -> str:
case_fn = CASE_FNS.get(self.enum_case(enum_name), snake_to_pascal)
return f"xi::{cls}::{case_fn(value)}"
def enum_ref_to_class(self, ref: str) -> tuple[str, str]:
"""`enums/item_flag.codegen.json` -> `("item_flag", "ItemFlag")`. Honors meta.cpp.class when set."""
enum_name = ref.rsplit("/", 1)[-1].replace(".codegen.json", "")
if enum_name not in self.enum_cls_cache:
yaml_path = ENUMS_DIR / f"{enum_name}.yaml"
cls: str | None = None
if yaml_path.exists():
with yaml_path.open(encoding="utf-8") as f:
doc = yaml.load(f, Loader=FastLoader) or {}
cls = ((doc.get("meta") or {}).get("cpp") or {}).get("class")
self.enum_cls_cache[enum_name] = cls or snake_to_pascal(enum_name)
return enum_name, self.enum_cls_cache[enum_name]
@staticmethod
def defs_ref_to_struct(ref: str) -> tuple[str, str]:
"""`#/$defs/item_weapon` -> `("item_weapon", "ItemWeaponData")`"""
defs_name = ref.rsplit("/", 1)[-1]
return defs_name, snake_to_pascal(defs_name) + "Data"
@staticmethod
def int_cpp_type(schema: dict[str, Any]) -> str:
"""Narrowest C++ int type that covers [minimum, maximum]."""
lo, hi = schema.get("minimum"), schema.get("maximum")
if lo is not None and lo < 0:
if lo >= -128 and hi is not None and hi <= 127:
return "int8"
if lo >= -32768 and hi is not None and hi <= 32767:
return "int16"
return "int32"
if hi is not None and hi <= 255:
return "uint8"
if hi is not None and hi <= 65535:
return "uint16"
return "uint32"
@staticmethod
def scalar_default(schema: dict[str, Any]) -> str:
"""Format the schema's `default` as a C++ literal. Empty string when no default is declared."""
if "default" not in schema:
return ""
v = schema["default"]
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(v)
if isinstance(v, str):
return f'"{v}"'
return ""
def field_from_schema(self, name: str, schema: dict[str, Any]) -> Field:
cpp_name = snake_to_pascal(name)
ref = schema.get("$ref")
if ref and ref.startswith("enums/"):
enum_name, cls = self.enum_ref_to_class(ref)
default = self.enum_default(enum_name, cls, schema["default"]) if isinstance(schema.get("default"), str) else ""
return Field(yaml_name=name, cpp_name=cpp_name, cpp_type=f"xi::{cls}",
populator="enum_ref", enum_name=enum_name, enum_class=cls, default=default)
if ref and ref.startswith("#/$defs/"):
defs_name, struct = self.defs_ref_to_struct(ref)
return Field(yaml_name=name, cpp_name=cpp_name, cpp_type=struct,
populator="record", defs_name=defs_name, struct_name=struct)
items = schema.get("items") if schema.get("type") == "array" else None
if isinstance(items, dict) and "$ref" in items:
iref = items["$ref"]
if iref.startswith("enums/"):
enum_name, cls = self.enum_ref_to_class(iref)
if self.enum_is_flags(enum_name):
# Flag enums collapse to a single OR'd value, not a list.
default = self.enum_default(enum_name, cls, schema["default"]) if isinstance(schema.get("default"), str) else ""
return Field(yaml_name=name, cpp_name=cpp_name, cpp_type=f"xi::{cls}",
populator="bitflags", enum_name=enum_name, enum_class=cls, default=default)
return Field(yaml_name=name, cpp_name=cpp_name, cpp_type=f"std::vector<xi::{cls}>",
populator="enum_list", enum_name=enum_name, enum_class=cls)
if iref.startswith("#/$defs/"):
defs_name, struct = self.defs_ref_to_struct(iref)
return Field(yaml_name=name, cpp_name=cpp_name, cpp_type=f"std::vector<{struct}>",
populator="record_list", defs_name=defs_name, struct_name=struct)
t = schema.get("type")
default = self.scalar_default(schema)
if t == "integer":
return Field(yaml_name=name, cpp_name=cpp_name, cpp_type=self.int_cpp_type(schema), populator="scalar", default=default)
if t == "number":
return Field(yaml_name=name, cpp_name=cpp_name, cpp_type="float", populator="scalar", default=default)
if t == "boolean":
return Field(yaml_name=name, cpp_name=cpp_name, cpp_type="bool", populator="scalar", default=default)
if t == "string":
return Field(yaml_name=name, cpp_name=cpp_name, cpp_type="std::string", populator="scalar", default=default)
raise ValueError(f"unsupported property '{name}' schema: {schema}")
def variant_arms_from_oneof(self, one_of: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Bodyless arm when the branch has no `required`; otherwise `required[0]` names the body field whose `$ref` points at the subtype `$defs`."""
arms: list[dict[str, Any]] = []
for branch in one_of:
props = branch.get("properties") or {}
type_prop = props.get("type") or {}
required = branch.get("required") or []
type_values = [type_prop["const"]] if "const" in type_prop else type_prop.get("enum", [])
if not required:
for tv in type_values:
arms.append(
{"type_value": tv, "cpp_name": snake_to_pascal(tv), "yaml_key": None, "struct_name": None,
"defs_name": None})
continue
yaml_key = required[0]
body_schema = props.get(yaml_key) or {}
if "$ref" not in body_schema:
raise ValueError(f"oneOf branch's '{yaml_key}' is not a $ref to $defs")
defs_name, struct = self.defs_ref_to_struct(body_schema["$ref"])
for tv in type_values:
arms.append(
{"type_value": tv, "cpp_name": snake_to_pascal(tv), "yaml_key": yaml_key, "struct_name": struct,
"defs_name": defs_name})
return arms
@staticmethod
def find_main_defs(schema: dict[str, Any]) -> str | None:
"""The main record's `$defs` key: the section's `additionalProperties.$ref`, or the lone `$defs` entry if there's only one."""
section_schema = next(
(s for s in (schema.get("properties") or {}).values() if
isinstance(s, dict) and "additionalProperties" in s),
None,
)
if section_schema:
addl = section_schema["additionalProperties"]
if isinstance(addl, dict) and "$ref" in addl:
return SchemaWalker.defs_ref_to_struct(addl["$ref"])[0]
defs = schema.get("$defs") or {}
return next(iter(defs.keys())) if len(defs) == 1 else None
def emit(self, schema_path: Path) -> dict[str, Any] | None:
"""Returns None when the schema has no main `$defs` (e.g. shared _meta). Sub-PODs emit before the main one so it can reference them."""
with schema_path.open(encoding="utf-8") as f:
schema = json.load(f)
try:
Draft202012Validator.check_schema(schema)
except jsonschema_exceptions.SchemaError as e:
raise SystemExit(f"{schema_path.relative_to(ROOT)}: not a valid JSON Schema 2020-12: {e.message}")
defs = schema.get("$defs") or {}
main_name = self.find_main_defs(schema) if defs else None
if not main_name or main_name not in defs:
return None
name = schema_path.stem.replace(".schema", "")
source = str(schema_path.relative_to(ROOT)).replace("\\", "/")
pods: list[dict[str, Any]] = []
main_pod: dict[str, Any] | None = None
for defs_name in [n for n in defs if n != main_name] + [main_name]:
props = defs[defs_name].get("properties") or {}
fields = [self.field_from_schema(name, fs) for name, fs in props.items()]
pod = {
"struct_name": snake_to_pascal(defs_name) + "Data",
"fields": fields,
"required": set(defs[defs_name].get("required") or []),
"cpp_type_width": max((len(f.cpp_type) for f in fields), default=0),
"is_main": defs_name == main_name,
"variant_arms": [],
"discriminator_field": None,
"discriminator_enum": None,
}
if pod["is_main"]:
main_pod = pod
pods.append(pod)
one_of = defs[main_name].get("oneOf")
if one_of and main_pod is not None:
main_pod["variant_arms"] = self.variant_arms_from_oneof(one_of)
disc = next((f for f in main_pod["fields"] if f.populator == "enum_ref"), None)
if disc is not None:
main_pod["discriminator_field"] = disc.cpp_name
main_pod["discriminator_enum"] = disc.enum_class
# id: on the record => driver reads it; otherwise the YAML key is the integer id.
has_id_field = any(f.yaml_name == "id" for f in main_pod["fields"])
main_pod["key_source"] = "yaml_field" if has_id_field else "yaml_key"
if main_pod["key_source"] == "yaml_key":
main_pod["fields"].insert(0, Field(yaml_name="id", cpp_name="Id", cpp_type="uint16", populator="scalar"))
main_pod["cpp_type_width"] = max(len(f.cpp_type) for f in main_pod["fields"])
main_pod["name_default_from_key"] = main_pod["key_source"] == "yaml_field" and any(
f.yaml_name == "name" for f in main_pod["fields"])
enum_includes = {f.enum_name for pod in pods for f in pod["fields"] if f.enum_name}
ctx = dict(
source=source,
name=name,
section_name=name,
main_pod=main_pod,
pods=pods,
enum_includes=sorted(enum_includes),
)
return {
"name": name,
"pod": ENV.get_template("data_pod.h.j2").render(**ctx),
"populator": ENV.get_template("data_populator.h.j2").render(**ctx),
}