@bscotch/gml-parser
Version:
A parser for GML (GameMaker Language) files for programmatic manipulation and analysis of GameMaker projects.
570 lines • 22 kB
JavaScript
import { __decorate, __metadata } from "tslib";
import { sequential } from '@bscotch/utility';
import MagicString from 'magic-string';
import { getEventFromFilename } from './lib.objects.js';
import { logger } from './logger.js';
import { parser } from './parser.js';
import { Diagnostic, } from './project.diagnostics.js';
import { Position, Range, Scope, } from './project.location.js';
import { getTypeOfKind } from './types.checks.js';
import { Type } from './types.js';
import { assert, isBeforeRange, isInRange } from './util.js';
import { registerGlobals } from './visitor.globals.js';
import { registerSignifiers } from './visitor.js';
/** Represenation of a GML code file. */
export class Code {
asset;
path;
$tag = 'gmlFile';
scopes = [];
jsdocs = [];
diagnostics;
/** List of all symbol references in this file, in order of appearance. */
_refs = [];
/** Ranges representing function call arguments,
* in order of appearance. Useful for signature help. */
_functionArgRanges = [];
/** Ranges representing locations where new struct
* members could be added. Useful for autocomplete. */
_structNewMemberRanges = [];
/** List of function calls, where each root item is a list
* of argument ranges for that call. Useful for diagnostics.*/
_functionCalls = [];
_rangesAreSorted = false;
content;
_parsed;
// Metadata
/** For object events, whether or not `event_inherited` is unambiguously being called */
callsSuper = false;
constructor(asset, path) {
this.asset = asset;
this.path = path;
this.clearAllDiagnostics();
}
/**
* If this is the Create event for an object, that object's
* variables. Else undefined. Used for determining the initial
* "definitive self" during code processing.
*/
get definitiveSelf() {
if (this.isCreateEvent) {
return this.asset.variables;
}
return;
}
/** When set to `true`, this file will be flagged for reprocessing. */
set dirty(value) {
if (value) {
this.project.queueDirtyFileUpdate(this);
}
}
get isScript() {
return this.asset.assetKind === 'scripts';
}
get isObjectEvent() {
return this.asset.assetKind === 'objects';
}
get isCreateEvent() {
return this.name === 'Create_0';
}
get isStepEvent() {
return this.name.startsWith('Step_');
}
get project() {
return this.asset.project;
}
get startPosition() {
return Position.fromFileStart(this);
}
/**
* If this is an object event and Stitch knows about
* its type, return the info about that event.
*/
get objectEventInfo() {
if (!this.isObjectEvent)
return undefined;
return getEventFromFilename(this.path.absolute);
}
/** A zero-length range at the start of the file. */
get startRange() {
return new Range(this.startPosition, this.startPosition);
}
isInRange(range, offset) {
return isInRange(range, offset);
}
isBeforeRange(range, offset) {
return isBeforeRange(range, offset);
}
getTextAt(offsetStart, offsetEnd) {
return this.content.slice(offsetStart, offsetEnd + 1);
}
getReferenceAt(offset, column) {
if (typeof offset === 'number' && typeof column === 'number') {
offset = { line: offset, column };
}
assert(this.refs, 'Refs must be an array');
for (let i = 0; i < this.refs.length; i++) {
const ref = this.refs[i];
if (this.isInRange(ref, offset)) {
return ref;
}
else if (this.isBeforeRange(ref, offset)) {
return undefined;
}
}
return undefined;
}
getJsdocAt(offset, column) {
if (typeof offset === 'number' && typeof column === 'number') {
offset = { line: offset, column };
}
assert(this.jsdocs, 'Jsdocs must be an array');
for (let i = 0; i < this.jsdocs.length; i++) {
const jsdoc = this.jsdocs[i];
if (this.isInRange(jsdoc, offset)) {
return jsdoc;
}
else if (this.isBeforeRange(jsdoc, offset)) {
return undefined;
}
}
return undefined;
}
getFunctionArgRangeAt(offset, column) {
if (typeof offset === 'number' && typeof column === 'number') {
offset = { line: offset, column };
}
let match;
const ranges = this.functionArgRanges;
assert(ranges, 'Function arg ranges must be an array');
for (let i = 0; i < ranges.length; i++) {
const argRange = ranges[i];
if (this.isInRange(argRange, offset)) {
// These could be nested, so an outer arg range might contain an inner one.
// Since these are sorted by start offset, we can return the *last* one to ensure that we're in the innermost range.
match = argRange;
continue;
}
else if (this.isBeforeRange(argRange, offset)) {
return match;
}
}
return match;
}
getStructNewMemberRangeAt(offset, column) {
if (typeof offset === 'number' && typeof column === 'number') {
offset = { line: offset, column };
}
const ranges = this.structNewMemberRanges;
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
if (this.isInRange(range, offset)) {
return range;
}
else if (this.isBeforeRange(range, offset)) {
return undefined;
}
}
return undefined;
}
getScopeRangeAt(offset, column) {
if (typeof offset === 'number' && typeof column === 'number') {
offset = { line: offset, column };
}
for (const scopeRange of this.scopes) {
if (this.isInRange(scopeRange, offset)) {
return scopeRange;
}
}
// Default to the last scope,
// since we can end up with wonkiness with EOF trailing whitespace
return this.scopes.at(-1);
}
getInScopeSymbolsAt(offset, column) {
if (typeof offset === 'number' && typeof column === 'number') {
offset = { line: offset, column };
}
const scopeRange = this.getScopeRangeAt(offset);
if (!scopeRange) {
return [];
}
if (scopeRange.isDotAccessor) {
// Then only return self variables
// Filter out those that cannot be dot-accessed on global
// (native functions, etc.)
if (scopeRange.self === this.project.self) {
return this.project.self.listMembers().filter((x) => {
// Only stuff defined in the project could appear as an autocomplete
const definedInProject = !x.native && !!x.def?.file;
const canLiveOnGlobal = !x.macro && !getTypeOfKind(x, ['Enum']);
return definedInProject && canLiveOnGlobal;
});
}
else {
return scopeRange.self.listMembers().filter((x) => x.def || x.native);
}
}
// Add to a flat list, and remove all entries that don't have
// a definition if they are not native
const allSignifiers = [
// Local variables
...(scopeRange.local.listMembers() || []),
// Self variables, if not global
...((scopeRange.self !== this.project.self
? scopeRange.self.listMembers()
: []) || []),
// Project globals
...(this.project.self.listMembers() || []),
];
// Signifiers were added in order of precedence, so we can remove
// non-uniques by just keeping the first one we find.
const uniqueSignifiers = new Map();
for (const signifier of allSignifiers) {
if (!uniqueSignifiers.has(signifier.name) &&
(signifier.def || signifier.native)) {
uniqueSignifiers.set(signifier.name, signifier);
}
}
return [...uniqueSignifiers.values()];
}
getDiagnostics() {
return { ...this.diagnostics };
}
get refs() {
this.sortRanges();
return [...this._refs];
}
get functionArgRanges() {
this.sortRanges();
return [...this._functionArgRanges];
}
get structNewMemberRanges() {
this.sortRanges();
return [...this._structNewMemberRanges];
}
get name() {
return this.path.name;
}
get basename() {
return this.path.basename;
}
get cst() {
return this._parsed.cst;
}
/**
* Load the file and parse it, resulting in an updated
* CST for future steps. If content is directly provided,
* it will be used instead of reading from disk. This
* is useful for editors that want to provide a live preview.
*/
async parse(content) {
this.clearAllDiagnostics();
this.content =
typeof content === 'string' ? content : await this.path.read();
this._parsed = parser.parse(this.content);
for (const diagnostic of this._parsed.errors) {
const fromToken = isNaN(diagnostic.token.startOffset)
? diagnostic.previousToken
: diagnostic.token;
logger.debug('SYNTAX ERROR', diagnostic?.message, this.path?.absolute, fromToken);
this.diagnostics.SYNTAX_ERROR.push(Diagnostic.error(diagnostic.message, Range.fromCst(this, fromToken || diagnostic.token), diagnostic));
}
}
/**
* Replace all refs for a signifier with a new name,
* followed by fully reprocessing the file.
*/
async renameSignifier(signifier, newName) {
const renameableRefs = this.refs.filter((ref) => ref.item === signifier && ref.isRenameable);
// Rename using magic-string so we don't have to track changed positions
const updated = new MagicString(this.content);
for (const ref of renameableRefs) {
updated.update(ref.start.offset, ref.end.offset + 1, ref.toRenamed(newName));
}
// Save to disk and reprocess
this.content = updated.toString();
await this.path.write(this.content);
await this.reload(this.content);
}
clearDiagnosticCollection(collection) {
this.diagnostics[collection] = [];
}
clearAllDiagnostics() {
this.diagnostics = {
GLOBAL_SELF: [],
INVALID_OPERATION: [],
JSDOC_MISMATCH: [],
MISSING_EVENT_INHERITED: [],
MISSING_REQUIRED_ARGUMENT: [],
SYNTAX_ERROR: [],
TOO_MANY_ARGUMENTS: [],
UNDECLARED_GLOBAL_REFERENCE: [],
UNDECLARED_VARIABLE_REFERENCE: [],
JSDOC: [],
UNUSED: [],
};
}
addDiagnostic(collection, diagnostic) {
this.diagnostics[collection].push(diagnostic);
}
addRef(ref) {
this._refs.push(ref);
}
addStructNewMemberRange(range) {
this._structNewMemberRanges.push(range);
}
addFunctionArgRange(range) {
this._functionArgRanges.push(range);
}
addFunctionCall(call) {
this._functionCalls.push(call);
}
sortRanges() {
if (this._rangesAreSorted)
return;
const sorter = (a, b) => {
assert(a, 'Ref a does not exist');
assert(b, 'Ref b does not exist');
assert(a.start && b.start, 'Ref does not have a start');
return a.start.offset - b.start.offset;
};
this._refs.sort(sorter);
this._functionArgRanges.sort(sorter);
this._structNewMemberRanges.sort(sorter);
this._rangesAreSorted = true;
}
initializeScopeRanges() {
const self = this.asset.variables || this.project.self;
// Re-use the root local scope if it exists
const local = this.scopes[0]?.local || new Type('Struct');
this.scopes.length = 0;
this.jsdocs.length = 0;
const position = Position.fromFileStart(this);
this.scopes.push(new Scope(position, local, self));
}
reset() {
this.initializeScopeRanges();
// Remove each reference in *this file* from its symbol.
const cleared = new Set();
for (const ref of this._refs) {
const signifier = ref.item;
if (cleared.has(signifier)) {
continue;
}
// If the symbol was declared in this file, remove its location
// to flag it as undeclared.
const isDefinedInThisFile = this === signifier.def?.file;
if (isDefinedInThisFile) {
signifier.unsetDef();
}
// Remove all references to this symbol found in this file.
// Flag all other files as being dirty so they get reprocessed.
for (const symbolRef of signifier.refs) {
if (this === symbolRef.file) {
signifier.refs.delete(symbolRef);
}
else {
symbolRef.file.dirty = true;
}
}
cleared.add(signifier);
}
// Reset this file's refs list
this._refs = [];
this._functionArgRanges = [];
this._structNewMemberRanges = [];
this._functionCalls = [];
this._rangesAreSorted = false;
}
async removeFromYy() {
if (!this.asset.isObject)
return;
const eventInfo = this.objectEventInfo;
if (!eventInfo) {
logger.warn(`Stitch does not know about the ${this.name} event type!`);
return;
}
// find the match for this event
const yy = this.asset.yy;
const eventIdx = yy.eventList.findIndex((event) => event.eventNum === eventInfo.eventNum &&
event.eventType === eventInfo.eventType);
if (eventIdx > -1) {
yy.eventList.splice(eventIdx, 1);
await this.asset.saveYy();
}
}
async remove() {
// update yy file
await this.removeFromYy();
// remove from asset's list of files
this.asset.gmlFiles.delete(this.path.absolute.toLocaleLowerCase());
// remove file
await this.path.delete();
// reset to clear refs and diagnostics
this.reset();
}
/**
* Reprocess after a modification to the file. Optionally
* provide new content to use instead of reading from disk.
*/
async reload(content, options) {
await this.parse(content);
this.updateGlobals();
this.updateAllSymbols();
this.updateDiagnostics();
// Re-run diagnostics on everything that ended up dirty due to the changes
if (options?.reloadDirty) {
this.project.drainDirtyFileUpdateQueue();
}
}
discoverEventInheritanceWarnings() {
this.diagnostics.MISSING_EVENT_INHERITED = [];
if (this.asset.assetKind !== 'objects' || !this.asset.parent) {
return;
}
// Then the type will have been set up to inherit from the parent.
// BUT. If this event does not call `event_inherited()`, then we
// need to unlink the type.
if (!this.callsSuper) {
// TODO: Provide this as an option?
// this.diagnostics.MISSING_EVENT_INHERITED.push({
// $tag: 'diagnostic',
// message: `Event does not call \`event_inherited()\`, so it will not inherit from its parent.`,
// severity: 'warning',
// location: this.startRange,
// });
if (this.isCreateEvent) {
// Unlink the type from the parent.
// (If there is no create event, then event_inherited is implicit)
this.asset.variables.extends = this.project.native.objectInstanceBase;
}
}
else if (this.isCreateEvent) {
// Ensure that the type is set as the parent by re-assigning it.
// eslint-disable-next-line no-self-assign
this.asset.parent = this.asset.parent;
}
}
computeFunctionCallDiagnostics() {
// Look through the function call ranges to see if we have too many or too few arguments.
assert(this._functionCalls, 'Function calls must be initialized');
this.diagnostics.MISSING_REQUIRED_ARGUMENT = [];
this.diagnostics.TOO_MANY_ARGUMENTS = [];
calls: for (let i = 0; i < this._functionCalls.length; i++) {
const args = this._functionCalls[i];
assert(args, 'Function call args must be initialized');
const func = args[0].type;
if (!func.signifier) {
// Then this was a generic function type and we don't know
// how many args it takes or what it returns.
continue;
}
const params = func.listParameters() || [];
// Handle missing arguments
for (let j = 0; j < params.length; j++) {
const param = params[j];
const arg = args[j];
const argIsEmpty = !arg?.hasExpression;
if (param && !param.optional && argIsEmpty) {
this.diagnostics.MISSING_REQUIRED_ARGUMENT.push(Diagnostic.error(`Missing required argument \`${param.name}\` for function \`${func.name}\`.`, arg || args[0]));
// There may be more missing args but
// that just starts to get noisy.
continue calls;
}
}
if (params.at(-1)?.name === '...') {
// Then we can't have too many arguments
continue;
}
if (!params.length && args.length === 1 && !args[0].hasExpression) {
// Then this is a zero-arg function and we aren't providing any args.
continue;
}
// Handle extra arguments.
for (let j = params.length; j < args.length; j++) {
const arg = args[j];
this.diagnostics.TOO_MANY_ARGUMENTS.push(Diagnostic.warn(`Extra argument for function \`${func.name}\`.`, arg));
}
}
}
computeUndeclaredSymbolDiagnostics() {
this.diagnostics.UNDECLARED_VARIABLE_REFERENCE = [];
const undeclaredSymbols = new Set();
outer: for (const ref of this._refs) {
if (ref.item.def || ref.item.native || undeclaredSymbols.has(ref.item)) {
continue;
}
// Handle global prefixes setting
const prefixes = this.project.options?.settings?.autoDeclareGlobalsPrefixes || [];
for (const prefix of prefixes) {
if (ref.item.name.startsWith(prefix)) {
// Then mark it as *global* and *declared*
ref.item.global = true;
ref.item.local = false;
ref.item.instance = false;
ref.item.def = {};
ref.item.describe(`Auto-declared by global prefix \`${prefix}\``);
continue outer;
}
}
this.diagnostics.UNDECLARED_VARIABLE_REFERENCE.push(Diagnostic.error(`Undeclared symbol \`${ref.item.name}\``, ref, 'warn'));
undeclaredSymbols.add(ref.item);
}
}
computeJsdocDiagnostics() {
this.diagnostics.JSDOC = [];
for (const jsdoc of this.jsdocs) {
for (const diagnostic of jsdoc.diagnostics) {
this.diagnostics.JSDOC.push(Diagnostic.warn(diagnostic.message, new Range(Position.from(this, diagnostic.start), Position.from(this, diagnostic.end))));
}
}
}
computeUnusedSymbolDiagnostics() {
this.diagnostics.UNUSED = [];
const unused = new Set();
for (const ref of this.refs) {
if (unused.has(ref.item) ||
!ref.isDef ||
ref.item.native ||
!ref.item.getTypeByKind('Function') ||
!ref.item.global // For now restrict to global functions. The rest requires some nuance!
) {
continue;
}
// Are all refs to the definition?
const hasNonDefRefs = [...ref.item.refs.values()].some((r) => !r.isDef);
if (!hasNonDefRefs) {
unused.add(ref.item);
this.diagnostics.UNUSED.push(Diagnostic.info(`Unused function \`${ref.item.name}\``, ref));
}
}
}
/** Update and emit diagnostics */
updateDiagnostics() {
this.discoverEventInheritanceWarnings();
this.computeFunctionCallDiagnostics();
this.computeUndeclaredSymbolDiagnostics();
this.computeJsdocDiagnostics();
this.computeUnusedSymbolDiagnostics();
const allDiagnostics = [];
for (const items of Object.values(this.diagnostics)) {
allDiagnostics.push(...items);
}
this.project.emitDiagnostics(this, allDiagnostics);
return allDiagnostics;
}
updateGlobals() {
this.reset();
return registerGlobals(this);
}
updateAllSymbols() {
registerSignifiers(this);
}
}
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Function, String]),
__metadata("design:returntype", Promise)
], Code.prototype, "renameSignifier", null);
//# sourceMappingURL=project.code.js.map