437 lines
13 KiB
JavaScript
437 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,
|
|
// xas:refType may be declared on the attribute itself (e.g.
|
|
// <xs:attribute name="id" type="Poid" xas:refType="ModuleData" />) or
|
|
// on its simple type. The attribute-level declaration wins.
|
|
refType:
|
|
normalizeTypeName(attr["@_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)`,
|
|
);
|