Files
Ra3ModXmlExt/tools/xsd-to-model.mjs
T
2026-08-01 14:00:17 +02:00

431 lines
13 KiB
JavaScript

// Generates a compact JSON schema model from the RA3 Mod SDK XSD files.
//
// Usage:
// node tools/xsd-to-model.mjs [path-to-CnC3Types.xsd] [output.json]
//
// The model is used at runtime by the extension for completions, hover
// documentation and diagnostics. It is regenerated by developers, not by
// end users.
import { XMLParser } from "fast-xml-parser";
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
statSync,
} from "node:fs";
import { dirname, resolve, basename, relative } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ROOT =
process.env.RA3_SDK_XSD ??
"C:\\Apps\\RA3-MODSDK-X\\Schemas\\xsd\\CnC3Types.xsd";
const rootXsdPath = resolve(process.argv[2] ?? DEFAULT_ROOT);
const outPath = resolve(
process.argv[3] ?? resolve(__dirname, "..", "src", "model", "schema-model.json"),
);
if (!existsSync(rootXsdPath)) {
console.error(`Root XSD not found: ${rootXsdPath}`);
console.error("Pass a path or set RA3_SDK_XSD.");
process.exit(1);
}
// ── XML parsing ──────────────────────────────────────────────────────
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
removeNSPrefix: true,
parseTagValue: false,
parseAttributeValue: false,
trimValues: true,
ignoreDeclaration: true,
});
const asArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]);
// ── Registry ─────────────────────────────────────────────────────────
const loadedFiles = new Map(); // absolute path -> parsed document
const complexTypes = new Map(); // name -> raw node
const simpleTypes = new Map(); // name -> raw node
const globalElements = new Map(); // name -> {type, doc}
function loadXsd(absPath, seen = new Set()) {
const key = absPath.toLowerCase();
if (loadedFiles.has(key)) return loadedFiles.get(key);
if (seen.has(key)) return null; // include cycle
seen.add(key);
let text;
try {
text = readFileSync(absPath, "utf8");
} catch (err) {
console.warn(` ! cannot read ${absPath}: ${err.message}`);
return null;
}
const doc = parser.parse(text);
loadedFiles.set(key, doc);
const schema = doc?.schema ?? doc?.["xs:schema"] ?? {};
for (const inc of asArray(schema.include)) {
const loc = inc?.["@_schemaLocation"];
if (!loc) continue;
loadXsd(resolve(dirname(absPath), loc), seen);
}
for (const ct of asArray(schema.complexType)) {
const name = ct?.["@_name"];
if (name) complexTypes.set(name, ct);
}
for (const st of asArray(schema.simpleType)) {
const name = st?.["@_name"];
if (name) simpleTypes.set(name, st);
}
for (const el of asArray(schema.element)) {
const name = el?.["@_name"];
if (!name) continue;
if (el.complexType) {
// Inline complexType for a global element (e.g. AssetDeclaration).
const keyName = `@global:${name}`;
complexTypes.set(keyName, el.complexType);
globalElements.set(name, { type: keyName, doc: docOf(el) });
} else if (el["@_type"]) {
globalElements.set(name, { type: el["@_type"], doc: docOf(el) });
}
}
return doc;
}
function docOf(node) {
const doc = node?.annotation?.documentation;
if (doc == null) return "";
return String(doc).trim();
}
// ── Attribute / simple type helpers ──────────────────────────────────
function normalizeTypeName(raw) {
if (!raw) return null;
return String(raw).replace(/^xs:/, "").trim();
}
const BUILTIN = new Set([
"string",
"boolean",
"integer",
"int",
"long",
"short",
"byte",
"unsignedInt",
"unsignedShort",
"unsignedByte",
"decimal",
"float",
"double",
"anyURI",
"date",
"dateTime",
"duration",
"token",
"NMTOKEN",
"Name",
"ID",
]);
function patternAllowsDefine(pattern) {
return /\(=\[_a-zA-Z]/.test(pattern) || /=\$/.test(pattern);
}
function enumOfSimpleType(node) {
const restriction = node?.restriction;
if (!restriction) return [];
const enums = asArray(restriction.enumeration).map((e) => e?.["@_value"]);
return enums.filter((v) => v != null);
}
function patternOfSimpleType(node) {
const restriction = node?.restriction;
if (!restriction) return null;
const pats = asArray(restriction.pattern).map((p) => p?.["@_value"]);
return pats.length ? pats : null;
}
/**
* Resolve an attribute type reference to a normalized descriptor.
* Returns { kind: "builtin"|"simple"|"complex", name, refType, enumValues,
* allowsDefine, isBoolean, base, doc }
*/
function resolveTypeDescriptor(typeName, memo = new Map()) {
const name = normalizeTypeName(typeName);
if (name == null) return null;
if (memo.has(name)) return memo.get(name);
if (BUILTIN.has(name)) {
const desc = {
kind: "builtin",
name,
refType: null,
enumValues: [],
allowsDefine: false,
isBoolean: name === "boolean",
base: null,
doc: "",
};
memo.set(name, desc);
return desc;
}
const st = simpleTypes.get(name);
if (st) {
memo.set(name, null); // guard against cycles
const base = normalizeTypeName(st?.restriction?.["@_base"]);
const baseDesc = base ? resolveTypeDescriptor(base, memo) : null;
const enumValues = enumOfSimpleType(st);
const patterns = patternOfSimpleType(st);
const refType =
st?.["@_refType"] ??
(baseDesc?.refType ?? null);
const desc = {
kind: "simple",
name,
refType: normalizeTypeName(refType) || baseDesc?.refType || null,
enumValues: enumValues.length ? enumValues : baseDesc?.enumValues ?? [],
allowsDefine:
(patterns ? patterns.some(patternAllowsDefine) : false) ||
baseDesc?.allowsDefine ||
false,
isRef:
st?.["@_isRef"] === "true" ||
st?.["@_isWeakRef"] === "true" ||
baseDesc?.isRef ||
false,
isBoolean: name === "SageBool" || baseDesc?.isBoolean || false,
base,
doc: docOf(st),
};
memo.set(name, desc);
return desc;
}
if (complexTypes.has(name) || name.startsWith("@")) {
const desc = {
kind: "complex",
name,
refType: null,
enumValues: [],
allowsDefine: false,
isRef: false,
isBoolean: false,
base: null,
doc: "",
};
memo.set(name, desc);
return desc;
}
const desc = {
kind: "unknown",
name,
refType: null,
enumValues: [],
allowsDefine: false,
isRef: false,
isBoolean: false,
base: null,
doc: "",
};
memo.set(name, desc);
return desc;
}
// ── Complex type expansion ───────────────────────────────────────────
function collectChildren(node, out = []) {
if (!node || typeof node !== "object") return out;
for (const el of asArray(node.element)) {
const name = el?.["@_name"];
if (!name) continue;
const child = {
name,
type: normalizeTypeName(el["@_type"]),
min: parseInt(el["@_minOccurs"] ?? "1", 10),
max: el["@_maxOccurs"] === "unbounded" ? -1 : parseInt(el["@_maxOccurs"] ?? "1", 10),
doc: docOf(el),
};
if (el.complexType) {
const inlineKey = `@inline:${name}`;
if (!complexTypes.has(inlineKey)) complexTypes.set(inlineKey, el.complexType);
child.type = inlineKey;
}
out.push(child);
}
for (const key of ["sequence", "choice", "all"]) {
for (const container of asArray(node[key])) {
collectChildren(container, out);
}
}
return out;
}
function collectAttributes(node, out = []) {
if (!node) return out;
for (const attr of asArray(node.attribute)) {
const name = attr?.["@_name"];
if (!name) continue;
const use = attr?.["@_use"];
let desc = null;
let inlineEnum = [];
if (attr["@_type"]) {
desc = resolveTypeDescriptor(attr["@_type"]);
} else if (attr.simpleType) {
inlineEnum = enumOfSimpleType(attr.simpleType);
const pats = patternOfSimpleType(attr.simpleType);
desc = {
kind: "simple",
name: `@attr:${name}`,
refType: attr.simpleType?.["@_refType"] ?? null,
enumValues: inlineEnum,
allowsDefine: pats ? pats.some(patternAllowsDefine) : false,
isBoolean: false,
base: normalizeTypeName(attr.simpleType?.restriction?.["@_base"]),
doc: docOf(attr.simpleType),
};
}
const entry = {
name,
required: use === "required",
default: attr["@_Default"] ?? attr["@_default"] ?? null,
doc: docOf(attr),
kind: desc?.kind ?? "unknown",
type: desc?.name ?? null,
refType: normalizeTypeName(desc?.refType) ?? null,
enumValues: desc?.enumValues ?? inlineEnum ?? [],
allowsDefine: desc?.allowsDefine ?? false,
isRef: desc?.isRef ?? false,
isBoolean: desc?.isBoolean ?? false,
base: normalizeTypeName(desc?.base) ?? null,
};
out.push(entry);
}
return out;
}
const expandedTypes = new Map(); // typeName -> {children, attributes, base, doc}
function expandComplexType(name, chain = []) {
if (expandedTypes.has(name)) return expandedTypes.get(name);
if (chain.includes(name)) {
// inheritance cycle (should not happen) - bail out
const empty = { children: [], attributes: [], base: null, doc: "" };
expandedTypes.set(name, empty);
return empty;
}
const node = complexTypes.get(name);
if (!node) return null;
const extension = node?.complexContent?.extension;
const baseName = normalizeTypeName(extension?.["@_base"] ?? node?.simpleContent?.extension?.["@_base"]);
const base = baseName ? expandComplexType(baseName, [...chain, name]) : null;
const ownChildren = collectChildren(extension ?? node);
const ownAttributes = collectAttributes(extension ?? node);
const children = [...(base?.children ?? []), ...ownChildren];
const attributes = [...(base?.attributes ?? [])];
for (const attr of ownAttributes) {
const idx = attributes.findIndex((a) => a.name === attr.name);
if (idx >= 0) attributes[idx] = attr;
else attributes.push(attr);
}
const result = {
children,
attributes,
base: baseName,
doc: docOf(node),
};
expandedTypes.set(name, result);
return result;
}
// ── Build model ──────────────────────────────────────────────────────
console.log(`Loading XSD tree from ${rootXsdPath} ...`);
const start = Date.now();
loadXsd(rootXsdPath);
console.log(
`Loaded ${loadedFiles.size} XSD files in ${((Date.now() - start) / 1000).toFixed(1)}s` +
` (${complexTypes.size} complexTypes, ${simpleTypes.size} simpleTypes, ${globalElements.size} global elements)`,
);
// Top-level elements come from the AssetDeclaration inline choice plus any
// global elements registered directly.
const assetDecl = globalElements.get("AssetDeclaration");
const topLevelElements = new Set();
if (assetDecl) {
const declType = expandComplexType(assetDecl.type);
for (const child of declType?.children ?? []) {
topLevelElements.add(child.name);
}
}
// Serialize resolved complex types.
const typesOut = {};
for (const name of complexTypes.keys()) {
const resolved = expandComplexType(name);
if (resolved) {
typesOut[name] = {
kind: "complex",
...resolved,
};
}
}
for (const [name, node] of simpleTypes) {
const desc = resolveTypeDescriptor(name);
typesOut[name] = {
kind: "simple",
base: desc?.base ?? normalizeTypeName(node?.restriction?.["@_base"]) ?? null,
refType: desc?.refType ?? node?.["@_refType"] ?? null,
isRef: node?.["@_isRef"] === "true" || desc?.refType != null,
enumValues: desc?.enumValues ?? [],
allowsDefine: desc?.allowsDefine ?? false,
doc: docOf(node),
};
}
// Inheritance map: subtype -> [base names], used to match refType against
// concrete asset types (a ref to BaseAudioEventInfo should also match
// AudioEvent).
const subTypesOf = {};
for (const [name, node] of complexTypes) {
const base = normalizeTypeName(node?.complexContent?.extension?.["@_base"]);
if (base) {
(subTypesOf[name] ??= []).push(base);
}
}
const model = {
version: 2,
rootXsd: basename(rootXsdPath),
targetNamespace: "uri:ea.com:eala:asset",
topLevelElements: [...topLevelElements].sort(),
elements: Object.fromEntries(
[...globalElements.entries()].map(([name, info]) => [
name,
{ type: normalizeTypeName(info.type), doc: info.doc },
]),
),
types: typesOut,
subTypesOf,
};
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(model));
const sizeKb = Math.round(statSync(outPath).size / 1024);
console.log(
`Wrote ${outPath} (${sizeKb} KB, ${topLevelElements.size} top-level elements, ` +
`${Object.keys(typesOut).length} types)`,
);