UNPKG

s2cfgtojson

Version:

Converts Stalker 2 Cfg file into a POJO

226 lines (206 loc) 7.03 kB
export type Value = Omit<Struct, "toTs"> | string | boolean | number; type DefaultEntries = { _isArray?: boolean; _useAsterisk?: boolean }; export type Entries = Record<string | number, Value> & DefaultEntries; /** * This file is part of the Stalker 2 Modding Tools project. * This is a base class for all structs. */ export abstract class Struct<T extends Entries = {}> { isRoot?: boolean; refurl?: string; refkey?: string | number; bskipref?: boolean; abstract _id: string; entries: T; static TAB = " "; static pad(text: string): string { return `${Struct.TAB}${text.replace(/\n+/g, `\n${Struct.TAB}`)}`; } static WILDCARD = "_wildcard"; static isNumber(ref: string): boolean { return Number.isInteger(parseInt(ref)) || typeof ref === "number"; } static isArrayKey(key: string) { return key.includes("[") && key.includes("]"); } static renderKeyName(ref: string, useAsterisk?: boolean): string { if (`${ref}`.startsWith("_")) { return Struct.renderKeyName(ref.slice(1), useAsterisk); // Special case for indexed structs } if (`${ref}`.includes("*") || useAsterisk) { return "[*]"; // Special case for wildcard structs } if (`${ref}`.includes("_dupe_")) { return Struct.renderKeyName(ref.slice(0, ref.indexOf("_dupe_"))); } if (Struct.isNumber(ref)) { return `[${parseInt(ref)}]`; } return ref; } static renderStructName(name: string): string { if (name === Struct.WILDCARD) { return "[*]"; // Special case for wildcard structs } if (`${name}`.startsWith("_")) { return Struct.renderStructName(name.slice(1)); // Special case for indexed structs } if (Struct.isNumber(name)) { return `[${parseInt(name)}]`; } return name; } static extractKeyFromBrackets(key: string) { if (/\[(.+)]/.test(key)) { return key.match(/\[(.+)]/)[1]; } return ""; } static parseStructName(name: string): string { if (Struct.extractKeyFromBrackets(name) === "*") { return Struct.WILDCARD; // Special case for wildcard structs } if (Struct.isNumber(Struct.extractKeyFromBrackets(name))) { return `_${name.match(/\[(\d+)]/)[1]}`; // Special case for indexed structs } const replacer = (text: string) => text.replace(/\W/g, "_").replace(/_+/g, "_").replace(/^_/, ""); return `${replacer(name[0]).replace(/\d/, "")}${replacer(name.slice(1))}`; } static createDynamicClass = (name: string): new () => Struct => new Function("parent", `return class ${name} extends parent {}`)(Struct); toString(): string { let text: string; text = this.isRoot ? `${this._id} : ` : ""; text += "struct.begin"; const refs = ["refurl", "refkey", "bskipref"] .map((k) => [k, this[k]]) .filter(([_, v]) => v !== "" && v !== undefined && v !== false) .map(([k, v]) => { if (v === true) return k; return `${k}=${Struct.renderKeyName(v)}`; }) .join(";"); if (refs) { text += ` {${refs}}`; } text += "\n"; // Add all keys text += Object.entries(this.entries || {}) .filter(([key]) => key !== "_useAsterisk" && key !== "_isArray") .map(([key, value]) => { const keyOrIndex = Struct.renderKeyName(key, this.entries._useAsterisk); const equalsOrColon = value instanceof Struct ? ":" : "="; const spaceOrNoSpace = value === "" ? "" : " "; return Struct.pad( `${keyOrIndex} ${equalsOrColon}${spaceOrNoSpace}${value}`, ); }) .join("\n"); text += "\nstruct.end"; return text; } toTs(): string { return JSON.stringify(this, null, 2); } static addEntry( parent: Struct<DefaultEntries>, key: string, value: Value, index: number, ) { parent.entries ||= {}; const getKey = () => { let normKey: string | number = key; if (Struct.isArrayKey(key)) { parent.entries._isArray = true; normKey = Struct.extractKeyFromBrackets(key); if (normKey === "*") { parent.entries._useAsterisk = true; return Object.keys(parent.entries).length; } if (parent.entries[normKey] !== undefined) { return `${normKey}_dupe_${index}`; } return normKey; } if (parent.entries[normKey] !== undefined) { return `${normKey}_dupe_${index}`; } return normKey; }; parent.entries[getKey()] = value; } static fromString<IntendedType extends Partial<Struct> = Struct>( text: string, ): IntendedType[] { const lines = text.trim().split("\n"); const parseHead = (line: string): Struct => { const match = line.match( /^(.*)\s*:\s*struct\.begin\s*({\s*((refurl|refkey|bskipref)\s*(=.+)?)\s*})?/, ); if (!match) { throw new Error(`Invalid struct head: ${line}`); } let name = Struct.parseStructName(match[1].trim()); const dummy = new (Struct.createDynamicClass(name))(); dummy._id = match[1]; if (match[3]) { const refs = match[3] .split(";") .map((ref) => ref.trim()) .filter(Boolean) .reduce( (acc, ref) => { const [key, value] = ref.split("="); acc[key.trim()] = value ? value.trim() : true; return acc; }, {} as { refurl?: string; refkey?: string; bskipref?: boolean }, ); if (refs.refurl) dummy.refurl = refs.refurl; if (refs.refkey) dummy.refkey = refs.refkey; if (refs.bskipref) dummy.bskipref = refs.bskipref; } return dummy as Struct; }; const parseKeyValue = (line: string, parent: Struct): void => { const match = line.match(/^(.*?)(\s*:\s*|\s*=\s*)(.*)$/); if (!match) { throw new Error(`Invalid key-value pair: ${line}`); } const key = match[1].trim(); const value = match[3].trim(); Struct.addEntry(parent, key, value, index); }; let index = 0; const walk = () => { const roots: Struct[] = []; const stack = []; while (index < lines.length) { const line = lines[index++].trim(); if (line.startsWith("#") || line.startsWith("//")) { continue; // Skip comments } const current = stack[stack.length - 1]; if (line.includes("struct.begin")) { const newStruct = parseHead(line); if (current) { const key = Struct.renderStructName(newStruct.constructor.name); Struct.addEntry(current, key, newStruct, index); } else { newStruct.isRoot = true; roots.push(newStruct); } stack.push(newStruct); } else if (line.includes("struct.end")) { stack.pop(); } else if (line.includes("=") && current) { parseKeyValue(line, current); } } return roots; }; return walk() as IntendedType[]; } }