UNPKG

@bscotch/gml-parser

Version:

A parser for GML (GameMaker Language) files for programmatic manipulation and analysis of GameMaker projects.

321 lines 15.1 kB
import { pathy } from '@bscotch/pathy'; import { GameMakerIde, GameMakerLauncher } from '@bscotch/stitch-launcher'; import { ok } from 'node:assert'; import { readFile } from 'node:fs/promises'; import { parseStringPromise } from 'xml2js'; import { parseJsdoc } from './jsdoc.js'; import { logger } from './logger.js'; import { gmlSpecSchema } from './project.spec.js'; import { Signifier } from './signifiers.js'; import { typeFromParsedJsdocs } from './types.feather.js'; import { Type } from './types.js'; import { addSpriteInfoStruct } from './types.sprites.js'; import { StitchParserError, assert } from './util.js'; export class Native { globalSelf; types; specs; objectInstanceBase; constructor(globalSelf, types) { this.globalSelf = globalSelf; this.types = types; } load() { assert(this.specs.length, 'No specs to load!'); // Prepare the base object instance type. this.objectInstanceBase = new Type('Struct'); const idInstance = new Type('Id.Instance'); this.types.set('Id.Instance', idInstance); idInstance.extends = this.objectInstanceBase; const assetGmObject = new Type('Asset.GMObject'); assetGmObject.extends = this.objectInstanceBase; this.types.set('Asset.GMObject', assetGmObject); // The `throw` function is not in the spec, so add it manually. const throwsType = new Type('Function').named('throw'); throwsType.addParameter(0, 'message', { type: Type.Any, optional: false }); const throws = new Signifier(this.globalSelf, 'throw', throwsType); this.globalSelf.addMember(throws); this.types.set('Function.throw', throwsType); throws.def = {}; throws.native = 'Base'; // The `static_get` function just returns a blank Struct type instead // of inferring the static struct from the argument. This function // replaces that signature to make it more useful. const staticGetType = typeFromParsedJsdocs(parseJsdoc(` /// @template {Function|Struct} T /// @param {T} target /// @returns {StaticType<T>} `), this.types, false)[0]; staticGetType.named('static_get'); const staticGet = new Signifier(this.globalSelf, 'static_get', staticGetType); this.globalSelf.addMember(staticGet); this.types.set('Function.static_get', staticGetType); staticGet.def = {}; staticGet.native = 'Base'; // The `display_get_frequency` function is not in the spec, so add it manually. const displayGetFrequencyType = new Type('Function').named('display_get_frequency'); displayGetFrequencyType.addReturnType(Type.Real); const displayGetFrequency = new Signifier(this.globalSelf, 'display_get_frequency', displayGetFrequencyType); this.globalSelf.addMember(displayGetFrequency); this.types.set('Function.display_get_frequency', displayGetFrequencyType); displayGetFrequency.def = {}; displayGetFrequency.native = 'Base'; // Process all of the found specs for (const spec of this.specs) { logger.info(`Loading spec for module ${spec.module}`); this.loadConstants(spec); this.loadVariables(spec); this.loadStructs(spec); this.loadFunctions(spec); } // Update the base instance type using instance variables. for (const member of this.globalSelf.listMembers()) { if (member.instance) { this.objectInstanceBase.addMember(member); } } this.objectInstanceBase.isReadonly = true; // Have the base Id.Instance and Asset.GmObject types // use the object instance base as their parent, and make them readonly. idInstance.isReadonly = true; assetGmObject.isReadonly = true; } loadVariables(spec) { for (const variable of spec.variables) { assert(variable, 'Variable must be defined'); const type = Type.fromFeatherString(variable.type, this.types, true); const symbol = new Signifier(this.globalSelf, variable.name, type) .describe(variable.description) .deprecate(variable.deprecated); symbol.writable = variable.writable; symbol.native = variable.module; symbol.global = !variable.instance; symbol.instance = variable.instance; this.globalSelf.addMember(symbol); } } loadFunctions(spec) { for (const func of spec.functions) { const existing = this.globalSelf.getMember(func.name); if (existing) { logger.warn(`Native function ${func.name} already exists, skipping.`); if (!existing.description && func.description) { existing.describe(func.description); } continue; } const typeName = `Function.${func.name}`; // Need a type and a symbol for each function. const functionType = (this.types.get(typeName) || new Type('Function').named(func.name)).describe(func.description); this.types.set(typeName, functionType); // Create a new generic type for this function (in particular, to be re-used by types that contain it!) const genericType = new Type('ArgumentIdentity'); genericType.isGeneric = true; const generics = { ArgumentIdentity: [genericType] }; const usesGenerics = func.parameters?.some((param) => param.name.includes('ArgumentIdentity')) || func.returnType?.includes('ArgumentIdentity'); const addGenericToContainer = (typeString) => { if (!usesGenerics) return typeString; const replaced = typeString.replace(/^(id.ds[a-z]+|array)(<\w+>)?/i, `$1<ArgumentIdentity>`); return replaced; }; // Add parameters to the type. assert(func.parameters, 'Function must have parameters'); for (let i = 0; i < func.parameters.length; i++) { const param = func.parameters[i]; assert(param, 'Parameter must be defined'); const paramType = Type.fromFeatherString(addGenericToContainer(param.type), [generics, this.types], true); functionType .addParameter(i, param.name, { type: paramType, optional: param.optional, }) .describe(param.description); } // Add return type to the type. functionType.addReturnType(Type.fromFeatherString(addGenericToContainer(func.returnType), [generics, this.types], true)); const symbol = new Signifier(this.globalSelf, func.name, functionType).deprecate(func.deprecated); symbol.writable = false; symbol.native = func.module; functionType.signifier = symbol; this.globalSelf.addMember(symbol); } } loadConstants(spec) { // Handle the constants. // Each constant value represents a unique expression // of its type (e.g. it's not just a Real, it's the Real // value 7 or whatever). Unlike the structs section of // the spec, which are *only* used for types, constants // are referenceabled in the code. Therefore we need // a unique symbol and type for each constant value, // along with a type that collects all of those types. // First group them all by "class". The empty-string // class represents the absence of a class. const constantsByClass = new Map(); for (const constant of spec.constants) { const klass = constant.class || ''; constantsByClass.set(klass, constantsByClass.get(klass) || []); constantsByClass.get(klass).push(constant); } // Then create a type for each class and a symbol for each constant. for (const [klass, constants] of constantsByClass) { if (!klass) { for (const constant of constants) { assert(constant, 'Constant must be defined'); const symbol = this.globalSelf.getMember(constant.name) || new Signifier(this.globalSelf, constant.name, Type.fromFeatherString(constant.type, this.types, true)).describe(constant.description); symbol.writable = false; symbol.native = constant.module; this.globalSelf.addMember(symbol); } continue; } // Figure out what types are in use by this class. const typeNames = new Set(); for (const constant of constants) { assert(constant, 'Constant must be defined'); typeNames.add(constant.type); } ok(typeNames.size, `Class ${klass} has no types`); // Create the base type for the class. const classTypeName = `Constant.${klass}`; const typeString = [...typeNames.values()].join('|'); const classType = this.types.get(classTypeName) || Type.fromFeatherString(typeString, this.types, true)[0].named(klass); this.types.set(classTypeName, classType); // Create symbols for each class member. for (const constant of constants) { const symbol = this.globalSelf.getMember(constant.name) || new Signifier(this.globalSelf, constant.name).describe(constant.description); symbol.writable = false; symbol.native = constant.module; symbol.setType(classType); const typeName = `${classTypeName}.${constant.name}`; this.types.set(typeName, classType); this.globalSelf.addMember(symbol); } } } loadStructs(spec) { // Create struct types. Each one extends the base Struct type. addSpriteInfoStruct(this.types); for (const struct of spec.structures) { if (!struct.name) { continue; } const typeName = `Struct.${struct.name}`; const structType = this.types.get(typeName) || new Type('Struct').named(struct.name); ok(!structType.listMembers().length, `Type ${typeName} already exists`); this.types.set(typeName, structType); for (const prop of struct.properties) { if (prop && !structType.getMember(prop.name)) { const type = Type.fromFeatherString(prop.type, this.types, true); structType .addMember(prop.name, { type, writable: prop.writable }) .describe(prop.description); } } } // Set all native structs as read-only for (const struct of spec.structures) { if (!struct.name) { continue; } const typeName = `Struct.${struct.name}`; const structType = this.types.get(typeName); ok(structType, `Type ${typeName} does not exist`); structType.isReadonly = true; } } static async from(filePaths, globalSelf, types) { filePaths = filePaths || [Native.fallbackGmlSpecPath]; const parsed = (await Promise.all(filePaths.map((path) => { try { return Native.parse(path.absolute); } catch (err) { logger.error(err); } return; }))).filter((x) => !!x); const native = new Native(globalSelf, types); native.specs = parsed; // Ensure the base module is always first native.specs.sort((a, b) => { if (a.module.toLowerCase() === 'base') { return -1; } else if (b.module.toLowerCase() === 'base') { return 1; } return 0; }); native.load(); return native; } static async parse(specFilePath) { const specRaw = await readFile(specFilePath, 'utf8'); const asJson = await parseStringPromise(specRaw.replace('\ufeff', ''), { trim: true, normalize: true, emptyTag() { return {}; }, // Prevent surprises if modules provide single entries explicitArray: true, }); // Prevent possible errors: "Non-white space before first tag" try { return gmlSpecSchema.parse(asJson); } catch (zodError) { const err = new StitchParserError(`Error parsing spec file "${specFilePath}"`); err.cause = zodError; logger.error(err); throw err; } } static fallbackGmlSpecPath = pathy(import.meta.url).resolveTo('../../assets/GmlSpec.xml'); static async findHelpLinksFile(ideVersion) { const ides = await GameMakerIde.listInstalled(); const ide = ides.find((ide) => ide.version === ideVersion) || ides[0]; return ide?.directory.join('RoboHelp/helpdocs_keywords.json'); } static async listSpecFiles(options) { if (!options.runtimeVersion && options.ideVersion) { logger.warn('No stitch config found, looking up runtime version'); // Look up the runtime version that matches the project's IDE version. const usingRelease = await GameMakerIde.findRelease({ ideVersion: options.ideVersion, }); options.runtimeVersion = usingRelease?.runtime.version; } if (options.runtimeVersion) { // Find the locally installed runtime folder const installedRuntime = await GameMakerLauncher.findInstalledRuntime({ version: options.runtimeVersion, }); if (installedRuntime) { logger.info(`Looking for spec files in "${installedRuntime.directory?.absolute}"`); const specs = await pathy(installedRuntime.directory).listChildrenRecursively({ filter(path) { return path.basename === 'GmlSpec.xml' || undefined; }, maxDepth: 3, }); if (specs.length) { return specs; } else { logger.warn('Found runtime, but could not find any GmlSpec.xml files!'); } } else { logger.warn(`Could not find runtime version ${options.runtimeVersion} locally!`); } } logger.warn('Falling back to default GmlSpec.xml included with Stitch.'); return [Native.fallbackGmlSpecPath]; } } //# sourceMappingURL=project.native.js.map