UNPKG

@bscotch/gml-parser

Version:

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

320 lines 14 kB
import { StitchImportError, assertStitchImportClaim } from './modules.util.js'; import { Asset, isAssetOfKind } from './project.asset.js'; import { findYyFile, groupPathToPosix, neither, xor } from './util.js'; /** * @param sourceFolder Full folder path to import from (e.g. `Scripts/MainMenu`) */ export async function importAssets(sourceProject, targetProject, options = {}) { const logFile = targetProject.dir.join('stitch-import.log.yaml'); assertStitchImportClaim(neither(options.sourceFolder, options.sourceAsset) || xor(options.sourceAsset, options.sourceFolder), 'Can specify either sourceFolder or sourceAsset, or neither, but not both.'); // Identify all assets we want to import const intendedImports = new Map(); if (options.sourceAsset) { const asset = sourceProject.getAssetByName(options.sourceAsset, { assertExists: true, }); intendedImports.set(asset.name, asset); } else { for (const [name, asset] of sourceProject.assets) { if (!options.sourceFolder || asset.isInFolder(options.sourceFolder)) { intendedImports.set(name, asset); } } } // Remove any assets that are not of the specified types if (options.types?.length) { for (const [name, asset] of intendedImports) { if (!options.types.includes(asset.assetKind)) { intendedImports.delete(name); } } } // Identify all dependencies of those assets that would be // missing in the target project *after import* const sourceDeps = computeAssetDeps(sourceProject); const missingDeps = new Set(); const conflictingDeps = new Set(); let newMissingAssets = []; for (const depList of sourceDeps.values()) { newMissingAssets.push(...updateMissingDeps(depList, intendedImports, missingDeps, conflictingDeps, targetProject, sourceProject)); } // We now have our first-layer deep of missing deps. To recursively // populate *all* missing deps we need to repeat this process on the // missing deps we find until we have no more missing deps. Preventing // infinite loops here is essential! // To do this, create a new list of "intended imports" representing // if we were to import *everything* connected to the originally-desired // imports. const derivedImports = new Map(); // Start with the original intended imports for (const [name, asset] of intendedImports) { derivedImports.set(name, asset); } while (newMissingAssets.length) { for (const asset of newMissingAssets) { derivedImports.set(asset.name, asset); } const newMissingDeps = newMissingAssets .map((a) => sourceDeps.get(a)) .flat(); // Find the *newly* missing deps from this new list newMissingAssets = updateMissingDeps(newMissingDeps, derivedImports, missingDeps, conflictingDeps, targetProject, sourceProject); } // Conflicting deps should not be allowed at all. if (conflictingDeps.size > 0) { await logFile.write({ conflicts: conflictingDeps, }); throw new StitchImportError(`Cannot import because of conflicting dependencies. See the logs for details: "${logFile}"`); } // By default, missing deps should cause an error const { onMissingDependency } = options; if (missingDeps.size && (!onMissingDependency || onMissingDependency === 'error')) { await logFile.write({ missing: missingDeps, }); throw new StitchImportError(`Cannot import because of conflicting dependencies. See the logs for details: "${logFile}"`); } const sourceFolder = groupPathToPosix(options.sourceFolder || ''); const targetFolder = groupPathToPosix(options.targetFolder || sourceFolder); const waits = []; const summary = { created: [], updated: [], errors: [], skipped: [], }; const newAssets = []; for (const [name, asset] of derivedImports) { const skip = onMissingDependency !== 'include' && !intendedImports.has(asset.name); if (skip) { summary.skipped.push(asset.name); continue; } // Ensure we have the target folder let folder = groupPathToPosix(asset.folder); if (sourceFolder && targetFolder) { folder = folder === sourceFolder ? targetFolder : folder.replace(sourceFolder + '/', targetFolder + '/'); } // Copy over the asset files const targetDir = targetProject.dir.join(asset.dir.relativeFrom(sourceProject.dir)); waits.push(targetDir .ensureDir() .then(() => targetDir.rm({ recursive: true, maxRetries: 5 })) .then(() => asset.dir.copy(targetDir)) .then(() => targetProject.createFolder(folder, { skipSave: true })) .then(async () => { let existingAsset = targetProject.getAssetByName(asset.name); if (!existingAsset) { const yyFile = await findYyFile(targetDir); const info = await targetProject.addAssetToYyp(yyFile.absolute, { skipSave: true, }); // Create and add the asset existingAsset = await Asset.from(targetProject, info); if (existingAsset) { newAssets.push(existingAsset); targetProject.registerAsset(existingAsset); summary.created.push(existingAsset.name); } else { summary.errors.push(`Failed to create asset for ${asset.name}`); } } else { await existingAsset.reload(); } // Ensure the correct folder await existingAsset?.moveToFolder(folder); return existingAsset; }) .catch((err) => { summary.errors.push(`Failed to import asset ${asset.name}: ${err.message}`); })); } await Promise.all(waits); await targetProject.saveYyp(); targetProject.initiallyParseAssetCode(newAssets); await logFile.write(summary); return summary; } function updateMissingDeps(depList, intendedImports, missingDeps, conflictingDeps, targetProject, sourceProject) { const newMissingDeps = []; const addMissingDep = (dep) => { if (!missingDeps.has(dep)) { newMissingDeps.push(sourceProject.getAssetByName(dep.requirement.name, { assertExists: true, })); missingDeps.add(dep); } }; for (const dep of depList) { // Wrap this in a function so it can recurse on missing deps if (!intendedImports.has(dep.requiredBy.name)) { // Then it doesn't matter what is being required, since // we're not trying to import this. continue; } if (intendedImports.has(dep.requirement.name)) { // Then we're already intending to import this, so it's not missing. BUT. It may create conflicts if it replaces something! // Is there a globalvar with the same name? const targetVar = targetProject.self.getMember(dep.requirement.name); if (targetVar && !targetVar.asset) { // Then this is a variable that is being replaced by an asset. This is not allowed. conflictingDeps.add(dep); continue; } // Is there an asset with the same name but different type? const targetAsset = targetProject.getAssetByName(dep.requirement.name); if (targetAsset && targetAsset.assetKind !== dep.requirement.kind) { conflictingDeps.add(dep); continue; } // If we replace the target asset, will we lose anything // that is required by something else in the target? if (targetAsset?.gmlFiles.size) { // Then we're replacing an asset that has code, so we need to check if any of the things it defines are required by other *target* assets and *not* included in the import. const varsDefinedByTarget = listGlobalvarsDefinedByAsset(targetAsset); for (const targetVar of varsDefinedByTarget) { // Do any intended imports include this? const inSource = sourceProject.self.getMember(targetVar.name); const fromSourceAsset = inSource && inSource.def?.file?.asset; if (!fromSourceAsset || !intendedImports.has(fromSourceAsset.name)) { // Then we're losing a variable that is required by something else in the target project. conflictingDeps.add(dep); } } } } // If this is a parent/child/ref relationship, we just // need to ensure that the target has an asset of the same // type with that name. if (['parent', 'child', 'ref'].includes(dep.relationship)) { const targetAsset = targetProject.getAssetByName(dep.requirement.name); if (!targetAsset) { addMissingDep(dep); } else if (targetAsset.assetKind !== dep.requirement.kind) { conflictingDeps.add(dep); } } else if (dep.relationship === 'code') { // For simplicity, just see if any global entity with the // same name exists (could check types in the future). const existsInTarget = targetProject.self.getMember(dep.signifier) || targetProject.getAssetByName(dep.signifier); if (!existsInTarget) { addMissingDep(dep); } } } return newMissingDeps; } /** * Create a map listing all assets in a project and how they * depend on other assets in same. */ export function computeAssetDeps(project) { const dependencies = new Map(); for (const [, asset] of project.assets) { const deps = []; dependencies.set(asset, deps); // Handle object-specific dependencies (parent objects and sprite assignments) if (isAssetOfKind(asset, 'objects')) { if (asset.parent) { deps.push({ relationship: 'parent', requiredBy: { name: asset.name, kind: 'objects' }, requirement: { name: asset.parent.name, kind: 'objects' }, }); } if (asset.sprite) { deps.push({ relationship: 'child', requiredBy: { name: asset.name, kind: 'objects' }, requirement: { name: asset.sprite.name, kind: 'sprites' }, }); } } // Handle code dependencies (referenced signifiers) for (const gmlFile of asset.gmlFiles.values()) { for (const ref of gmlFile.refs) { // This could be a child of something, so get to the root-most // signifier. const seenItems = new Set(); let item = ref.item; while (item.parent) { if (item.parent.signifier?.name === 'global') break; item = item.parent.signifier || item; // Prevent circularity! if (!item.parent.signifier || seenItems.has(item)) break; seenItems.add(item); } // Skip native, local, and janky signifiers if (item.native || item.local || !item.def?.file) continue; const itemAsset = item.def.file.asset; // Skip intra-asset references if (itemAsset === asset) continue; if (item.asset) { // Then this is a reference to an asset, not a code signifier deps.push({ relationship: 'ref', requiredBy: { name: asset.name, kind: asset.assetKind }, requirement: { name: itemAsset.name, kind: itemAsset.assetKind, }, }); } else { // It's a code signifier deps.push({ relationship: 'code', requiredBy: { name: asset.name, kind: asset.assetKind }, requirement: { name: itemAsset.name, kind: itemAsset.assetKind }, signifier: item.name, def: { file: item.def.file.path.relative, line: item.def.start.line, column: item.def.start.column, }, ref: { file: ref.file.path.relative, line: ref.start.line, column: ref.start.column, }, }); } } } } return dependencies; } function listGlobalvarsDefinedByAsset(asset) { const vars = new Set(); for (const gmlFile of asset.gmlFiles.values()) { for (const ref of gmlFile.refs) { const { item } = ref; if (item.local || item.native || !item.global || !item.def?.file || item.def.file.asset !== asset) { continue; } vars.add(item); } } return vars; } //# sourceMappingURL=modules.js.map