#!/usr/bin/env python3 """Deterministically generate the small language-neutral Phase 3A bindings.""" from __future__ import annotations import argparse import hashlib import json import pathlib import re import subprocess import sys from typing import Any ROOT = pathlib.Path(__file__).resolve().parents[1] SCHEMA_PATH = ROOT / "schemas/control-v1.schema.json" VERSION_PATH = ROOT / "VERSION" COMPATIBILITY_PATH = ROOT / "compatibility.json" def load_schema() -> tuple[dict[str, Any], str, str, dict[str, Any]]: raw = SCHEMA_PATH.read_bytes() schema = json.loads(raw) version = VERSION_PATH.read_text(encoding="utf-8").strip() if not re.fullmatch(r"\d+\.\d+\.\d+", version): raise ValueError("VERSION must be semantic version text") compatibility = json.loads(COMPATIBILITY_PATH.read_text(encoding="utf-8")) for key in ("current", "n_minus_1", "n_minus_2"): if not isinstance(compatibility.get(key), str): raise ValueError("compatibility declaration is incomplete") return schema, hashlib.sha256(raw).hexdigest(), version, compatibility def pascal(name: str) -> str: return "".join(part[:1].upper() + part[1:] for part in re.split(r"[_-]", name)) def go_field(name: str) -> str: initialisms = {"api": "API", "http": "HTTP", "id": "ID", "ip": "IP", "sha": "SHA", "ttl": "TTL", "url": "URL", "uuid": "UUID"} return "".join( initialisms.get(part, part[:1].upper() + part[1:]) for part in re.split(r"[_-]", name) ) def camel(name: str) -> str: parts = re.split(r"[_-]", name) return parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:]) def swift_field(name: str) -> str: value = camel(name) if value in {"protocol", "class", "struct", "enum", "extension", "private", "public", "internal", "fileprivate", "open", "func", "let", "var", "import", "switch", "case", "default", "operator", "where", "repeat", "return", "throw", "throws", "try", "catch", "defer", "in", "is", "as"}: return value + "Value" return value RUST_KEYWORDS = { "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final", "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "union", } def rust_field(name: str) -> str: value = camel(name) return value + "Value" if value in RUST_KEYWORDS else value def ref_name(value: Any) -> str | None: if isinstance(value, dict) and isinstance(value.get("$ref"), str): return value["$ref"].split("/")[-1] return None def prop_type(prop: dict[str, Any], language: str) -> str: reference = ref_name(prop) if reference: return reference if language == "go" else (reference if language == "rust" else reference) if prop.get("type") == "array": item = prop.get("items", {}) item_type = prop_type(item, language) if language == "go": return f"[]{item_type}" if language == "rust": return f"Vec<{item_type}>" return f"[{item_type}]" if prop.get("type") == "integer": return "int64" if language == "go" else ("i64" if language == "rust" else "Int64") if prop.get("type") == "boolean": return "bool" if language != "swift" else "Bool" if prop.get("type") == "object": return "map[string]any" if language == "go" else ("JsonObject" if language == "rust" else "JSONObject") return "string" if language == "go" else ("String" if language == "rust" else "String") def go_zero(prop: dict[str, Any], field: str) -> str: typ = prop_type(prop, "go") if typ in {"string", "int64", "bool"}: zero = {"string": '""', "int64": "0", "bool": "false"}[typ] return f"v.{field} == {zero}" if typ.startswith("[]"): return f"v.{field} == nil" if typ == "map[string]any": return f"v.{field} == nil" return f"reflect.DeepEqual(v.{field}, {typ}{{}})" def go_validation(definition: dict[str, Any]) -> list[str]: lines: list[str] = [] name = definition["name"] required = set(definition.get("required", [])) for prop_name, prop in definition.get("properties", {}).items(): field = go_field(prop_name) required_zero_is_valid = prop.get("type") == "integer" and prop.get("minimum") == 0 if prop_name in required and prop.get("type") != "boolean" and not required_zero_is_valid: zero = go_zero(prop, field) lines.append(f"\tif {zero} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"required\"}}) }}") if prop.get("type") == "string": if "minLength" in prop: lines.append(f"\tif len(v.{field}) < {prop['minLength']} && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_length\"}}) }}") if "maxLength" in prop: lines.append(f"\tif len(v.{field}) > {prop['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_length\"}}) }}") if "const" in prop: lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}") if "enum" in prop: allowed = " || ".join(f'v.{field} == "{value}"' for value in prop["enum"]) lines.append(f"\tif v.{field} != \"\" && !({allowed}) {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}") if prop.get("format") == "date-time": lines.append( '\tif v.%s != "" { if parsed, err := time.Parse(time.RFC3339Nano, v.%s); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.%s { violations = append(violations, FieldViolation{Field: "%s", Code: "invalid_time"}) } }' % (field, field, field, prop_name) ) if prop.get("type") == "integer": if "minimum" in prop: lines.append(f"\tif v.{field} != 0 && v.{field} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}") if "maximum" in prop: lines.append(f"\tif v.{field} > {prop['maximum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"maximum\"}}) }}") if prop.get("type") == "array": if "minItems" in prop: lines.append(f"\tif len(v.{field}) < {prop['minItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_items\"}}) }}") if "maxItems" in prop: lines.append(f"\tif len(v.{field}) > {prop['maxItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_items\"}}) }}") item_ref = ref_name(prop.get("items", {})) if item_ref: lines.append(f"\tfor index := range v.{field} {{ if err := v.{field}[index].Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: fmt.Sprintf(\"{prop_name}[%d]\", index), Code: \"invalid_item\"}}) }} }}") reference = ref_name(prop) if reference: lines.append(f"\tif err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}") return lines def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, compatibility: dict[str, Any]) -> str: out = [ "// Code generated by tools/generate.py; DO NOT EDIT.", "package protocol", "", "import (", "\"bytes\"", "\"encoding/json\"", "\"errors\"", "\"fmt\"", "\"reflect\"", "\"time\"", ")", "", f'const SchemaSHA256 = "{schema_hash}"', f'const ProtocolVersion = "{version}"', f'const CurrentWireVersion = "{compatibility["current"]}"', f'const NMinus1WireVersion = "{compatibility["n_minus_1"]}"', f'const NMinus2WireVersion = "{compatibility["n_minus_2"]}"', "", "type FieldViolation struct {", "\tField string `json:\"field\"`", "\tCode string `json:\"code\"`", "}", "", "type ValidationError struct {", "\tViolations []FieldViolation", "}", "", "func (e ValidationError) Error() string { return \"protocol validation failed\" }", "", ] for name in sorted(defs): if name == "FieldViolation": continue definition = defs[name] out.append(f"type {name} struct {{") required = set(definition.get("required", [])) for prop_name, prop in definition.get("properties", {}).items(): tag = prop_name + (",omitempty" if prop_name not in required else "") out.append(f"\t{go_field(prop_name)} {prop_type(prop, 'go')} `json:\"{tag}\"`") out.extend(["}", ""]) for name in sorted(defs): out.append(f"func (v {name}) Validate() error {{") out.append("\tvar violations []FieldViolation") out.extend(go_validation(defs[name])) out.append("\tif len(violations) > 0 { return ValidationError{Violations: violations} }") out.append("\treturn nil") out.append("}") out.append("") out.append(f"func Decode{name}(data []byte) ({name}, error) {{") out.append(f"\tvar value {name}") out.append("\tif len(data) > 1024*1024 { return value, errors.New(\"protocol payload exceeds limit\") }") out.append("\tvar fields map[string]json.RawMessage") out.append("\tif err := json.Unmarshal(data, &fields); err != nil { return value, err }") required_fields = sorted(defs[name].get("required", [])) for prop_name in required_fields: out.append( '\tif raw, ok := fields["%s"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "required"}}} }' % (prop_name, prop_name) ) for prop_name, prop in defs[name].get("properties", {}).items(): if "x-max-bytes" in prop: out.append( '\tif raw, ok := fields["%s"]; ok && len(raw) > %d { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "max_bytes"}}} }' % (prop_name, prop["x-max-bytes"], prop_name) ) out.append("\tdecoder := json.NewDecoder(bytes.NewReader(data))") out.append("\tdecoder.DisallowUnknownFields()") out.append("\tif err := decoder.Decode(&value); err != nil { return value, err }") out.append("\tvar trailing any") out.append("\tif err := decoder.Decode(&trailing); err != io.EOF { if err == nil { return value, errors.New(\"trailing JSON value\") }; return value, err }") out.append("\tif err := value.Validate(); err != nil { return value, err }") out.append("\treturn value, nil") out.append("}") out.append("") out.append(f"func Encode{name}(value {name}) ([]byte, error) {{") out.append("\tif err := value.Validate(); err != nil { return nil, err }") out.append("\treturn json.Marshal(value)") out.append("}") out.append("") # Use io.EOF in generated code without making every generated decoder depend on # error-string comparison; replace the deliberately compact placeholder. text = "\n".join(out).replace('"errors"\n"fmt"', '"errors"\n"fmt"\n\"io"') text = text.replace('!errors.Is(err, errors.New("EOF")) && err != nil', '!errors.Is(err, io.EOF)') return text + "\n" def rust_type(prop: dict[str, Any]) -> str: reference = ref_name(prop) if reference: return reference if prop.get("type") == "array": return f"Vec<{rust_type(prop.get('items', {}))}>" if prop.get("type") == "integer": return "i64" if prop.get("type") == "boolean": return "bool" if prop.get("type") == "object": return "JsonObject" return "String" def swift_type(prop: dict[str, Any]) -> str: reference = ref_name(prop) if reference: return reference if prop.get("type") == "array": return f"[{swift_type(prop.get('items', {}))}]" if prop.get("type") == "integer": return "Int64" if prop.get("type") == "boolean": return "Bool" if prop.get("type") == "object": return "JSONObject" return "String" def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str: out = [ "// Code generated by tools/generate.py; DO NOT EDIT.", "#![allow(non_snake_case)]", "pub const SCHEMA_SHA256: &str = \"" + schema_hash + "\";", f'pub const CURRENT_WIRE_VERSION: &str = "{compatibility["current"]}";', f'pub const N_MINUS_1_WIRE_VERSION: &str = "{compatibility["n_minus_1"]}";', f'pub const N_MINUS_2_WIRE_VERSION: &str = "{compatibility["n_minus_2"]}";', "pub type JsonObject = std::collections::BTreeMap;", "", ] for name in sorted(defs): definition = defs[name] out.extend(["#[derive(Debug, Clone, PartialEq, Eq)]", f"pub struct {name} {{"]) required = set(definition.get("required", [])) for prop_name, prop in definition.get("properties", {}).items(): field = rust_field(prop_name) typ = rust_type(prop) if prop_name not in required: typ = f"Option<{typ}>" out.append(f" pub {field}: {typ},") out.extend(["}", ""]) return "\n".join(out) def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str: out = [ "// Code generated by tools/generate.py; DO NOT EDIT.", "import Foundation", "public typealias JSONObject = [String: String]", f"public let schemaSHA256 = \"{schema_hash}\"", f'public let currentWireVersion = "{compatibility["current"]}"', f'public let nMinus1WireVersion = "{compatibility["n_minus_1"]}"', f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"', "", ] for name in sorted(defs): definition = defs[name] required = set(definition.get("required", [])) out.extend(["public struct " + name + ": Codable, Equatable {",]) for prop_name, prop in definition.get("properties", {}).items(): typ = swift_type(prop) if prop_name not in required: typ += "?" out.append(f" public let {swift_field(prop_name)}: {typ}") out.append(" enum CodingKeys: String, CodingKey {") for prop_name in definition.get("properties", {}): out.append(f" case {swift_field(prop_name)} = \"{prop_name}\"") out.extend([" }", "", " public init(from decoder: Decoder) throws {",]) out.append(" let c = try decoder.container(keyedBy: CodingKeys.self)") for prop_name, prop in definition.get("properties", {}).items(): field = swift_field(prop_name) typ = swift_type(prop) if prop_name in required: out.append(f" {field} = try c.decode({typ}.self, forKey: .{field})") else: out.append(f" {field} = try c.decodeIfPresent({typ}.self, forKey: .{field})") out.extend([" }", "}", ""]) return "\n".join(out) def write_or_check(path: pathlib.Path, content: str, check: bool) -> None: if check: if not path.exists() or path.read_text(encoding="utf-8") != content: raise ValueError(f"generated output differs: {path.relative_to(ROOT)}") return path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") def format_go(content: str) -> str: result = subprocess.run(["gofmt"], input=content, text=True, capture_output=True, check=False) if result.returncode != 0: raise ValueError(f"gofmt failed: {result.stderr.strip()}") return result.stdout def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--check", action="store_true") args = parser.parse_args() schema, schema_hash, version, compatibility = load_schema() generator_hash = hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest() defs = schema.get("$defs") if not isinstance(defs, dict) or not defs: raise ValueError("schema must contain non-empty $defs") normalized = {name: dict(value, name=name) for name, value in defs.items()} outputs = { ROOT / "gen/go/protocol/protocol.go": format_go(generate_go(normalized, schema_hash, version, compatibility)), ROOT / "gen/rust/protocol.rs": generate_rust(normalized, schema_hash, compatibility).rstrip() + "\n", ROOT / "gen/swift/Protocol.swift": generate_swift(normalized, schema_hash, compatibility).rstrip() + "\n", ROOT / "gen/manifest.json": json.dumps({ "generator_sha256": generator_hash, "schema_sha256": schema_hash, "protocol_version": version, "compatibility": compatibility, }, sort_keys=True, indent=2) + "\n", } for path, content in outputs.items(): write_or_check(path, content, args.check) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, ValueError, json.JSONDecodeError) as exc: print(f"generate: {exc}", file=sys.stderr) raise SystemExit(1)