UNPKG

@bscotch/gml-parser

Version:

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

605 lines 26.4 kB
// CST Visitor for creating an AST etc import { arrayWrapped } from '@bscotch/utility'; import { gmlLinesByGroup, parseJsdoc } from './jsdoc.js'; import { logger } from './logger.js'; import { GmlVisitorBase, withCtxKind } from './parser.js'; import { functionFromRhs, identifierFrom, sortChildren, stringLiteralAsString, } from './parser.utility.js'; import { Position, Range, StructNewMemberRange, } from './project.location.js'; import { Signifier } from './signifiers.js'; import { getTypeOfKind, getTypes, normalizeType } from './types.checks.js'; import { typeFromParsedJsdocs } from './types.feather.js'; import { Type, } from './types.js'; import { withableTypes } from './types.primitives.js'; import { StitchParserError, assert } from './util.js'; import { assignVariable, ensureDefinitive } from './visitor.assign.js'; import { visitFunctionExpression } from './visitor.functionExpression.js'; import { visitIdentifierAccessor } from './visitor.identifierAccessor.js'; import { SignifierProcessor, diagnosticCollections, } from './visitor.processor.js'; export function registerSignifiers(file) { try { // Clear diagnostics managed by the processor for (const group of diagnosticCollections) { file.clearDiagnosticCollection(group); } const processor = new SignifierProcessor(file); const visitor = new GmlSignifierVisitor(processor); visitor.UPDATE_SIGNIFIERS(file.cst); } catch (parseErr) { const err = new StitchParserError(`Error identifying locals in ${file.path}`); err.cause = parseErr; logger.error(err); } } export class GmlSignifierVisitor extends GmlVisitorBase { PROCESSOR; static validated = false; constructor(PROCESSOR) { super(); this.PROCESSOR = PROCESSOR; this.validateVisitor(); } get FILE() { return this.PROCESSOR.file; } get ASSET() { return this.PROCESSOR.asset; } /** Entrypoint */ UPDATE_SIGNIFIERS(input) { this.visit(input, { ctxKindStack: [] }); this.PROCESSOR.setLastScopeEnd(input.location); return this.PROCESSOR; } visit(cstNode, ctx) { return super.visit(cstNode, ctx); } FIND_ITEM_BY_NAME(name, options) { const scope = this.PROCESSOR.fullScope; // Find matches from all scopes, then return the first declared one. // If none are declared, return the first found. const matches = [ // Local scope scope.local.getMember(name, false), // Self scope scope.selfIsGlobal ? undefined : scope.self.getMember(name, options?.excludeParents), // Global scope options?.excludeGlobal ? undefined : this.FIND_GLOBAL_BY_NAME(name, options), ].filter((i) => i !== undefined); return matches.find((i) => i.def) || matches[0]; } FIND_GLOBAL_BY_NAME(name, options) { const scope = this.PROCESSOR.fullScope; const item = this.PROCESSOR.globalSelf.getMember(name, options?.excludeParents); // If the current scope is an instance allow for instance variables // (but skip `id` since we're doing special things with that). // Otherwise instance variables should be skipped. const isInstance = !scope.selfIsGlobal && (['Id.Instance', 'Asset.GMObject'].includes(scope.self.kind) || scope.self.signifier?.asset); if (!isInstance && item?.instance) { return undefined; } else if (isInstance && item?.instance && name === 'id') { // Then this is a native "instance" variable. Ignore it // to allow falling back on the self scope. return undefined; } return item; } /** Given an identifier in the current scope, find the corresponding item. */ FIND_ITEM(children, options) { const identifier = identifierFrom(children); if (!identifier) { return; } const scope = this.PROCESSOR.fullScope; let item; const range = this.PROCESSOR.range(identifier.token); switch (identifier.type) { case 'Global': // Global is a special case, it's a keyword and also // a globalvar. item = scope.global; break; case 'Self': // Then we're referencing our current self context item = scope.self; // If this self scope is also global, emit a diagnostic // (should not use self to refer to global) if (scope.selfIsGlobal) { this.PROCESSOR.addDiagnostic('GLOBAL_SELF', children.Self[0], '`self` refers to the global scope here, which is probably unintentional.'); } else { item.signifier?.addRef(range); } break; case 'Other': // Then we're referencing the self-scope upstream of this one. item = this.PROCESSOR.outerSelf; // If this self scope is also global, emit a diagnostic // (should not use self to refer to global) if (this.PROCESSOR.outerSelf === scope.global) { this.PROCESSOR.addDiagnostic('GLOBAL_SELF', children.Other[0], '`other` refers to the global scope here, which is probably unintentional.'); } else { item.signifier?.addRef(range); } break; default: const { name } = identifier; item = this.FIND_ITEM_BY_NAME(name, options); break; } if (item) { return { item, range, }; } return; } get ANY() { return new Type('Any'); } get BOOLEAN() { return new Type('Bool'); } get REAL() { return new Type('Real'); } get UNDEFINED() { return new Type('Undefined'); } /** * Given parsed JSDocs, convert into a Type and store * it for use by the next symbol. */ PREPARE_JSDOC(jsdoc) { const type = typeFromParsedJsdocs(jsdoc, this.PROCESSOR.project.types, false); this.PROCESSOR.unusedJsdoc = { jsdoc, type, }; this.PROCESSOR.file.jsdocs.push(jsdoc); // Add references to types where appropriate for (const loc of jsdoc.typeRanges) { const signifier = this.PROCESSOR.project.types.get(loc.content) ?.signifier; if (!signifier) continue; signifier.addRef(Range.from(this.PROCESSOR.file, loc)); } // If we're documenting a variable, then we need to // go ahead and consume the doc. // globalvars should have already been handled and can be skipped if (!['localvar', 'instancevar', 'globalvar'].includes(jsdoc.kind)) { return; } const info = this.PROCESSOR.consumeJsdoc(); if (jsdoc.kind === 'globalvar') { return; } const container = jsdoc.kind === 'localvar' ? this.PROCESSOR.currentLocalScope : this.PROCESSOR.currentSelf; if (container === this.PROCESSOR.globalSelf) { // Then this is being used improperly this.PROCESSOR.addDiagnostic('GLOBAL_SELF', jsdoc.name, `Invalid variable documentation. Did you mean to use @globalvar?`, 'error'); return; } let signifier = container.getMember(jsdoc.name.content); const nameRange = Range.from(this.PROCESSOR.file, jsdoc.name); if (!signifier) { signifier = new Signifier(container, jsdoc.name.content); container.addMember(signifier); if (container === this.PROCESSOR.currentDefinitiveSelf) { signifier.definitive = true; } signifier.addRef(nameRange, true); } else { const ref = signifier.addRef(nameRange); ensureDefinitive(container, this.PROCESSOR.currentDefinitiveSelf, signifier, ref); } signifier.describe(jsdoc.description); signifier.setType(info.type); signifier.definedAt(nameRange); if (jsdoc.kind === 'localvar') { signifier.local = true; } else { signifier.instance = true; } } jsdocJs(children) { this.PREPARE_JSDOC(parseJsdoc(children.JsdocJs[0])); } jsdocGml(children) { // This *could* actually be several JSDocs, const jsdocGroups = gmlLinesByGroup(children.JsdocGmlLine); for (const group of jsdocGroups) { const parsed = parseJsdoc(group); this.PREPARE_JSDOC(parsed); } } withStatement(children, context) { const blockLocation = children.blockableStatement[0].location; // With statements change the self scope to // whatever their expression evaluates to. // Evaluate the expression and try to use its type as the self scope const docs = this.PROCESSOR.consumeJsdoc(); const contextExpression = this.expression(children.expression[0].children, withCtxKind(context, 'withCondition')); const contextFromDocs = docs?.jsdoc.kind === 'self' ? docs.type[0] : undefined; const self = getTypeOfKind(contextFromDocs, withableTypes) || getTypeOfKind(contextExpression, withableTypes) || this.PROCESSOR.createStruct(blockLocation); this.PROCESSOR.scope.setEnd(children.expression[0].location, true); this.PROCESSOR.pushSelfScope(blockLocation, self, false); this.visit(children.blockableStatement, withCtxKind(context, 'withBody')); this.PROCESSOR.scope.setEnd(blockLocation, true); this.PROCESSOR.popSelfScope(blockLocation, true); return; } catchStatement(children, ctx) { // Catch statements are weird because they add a new variable // the the current localscope, but only within themselves. We // can get a reasonable approximation of this behavior by creating // a new localscope that has the current localscope as a parent. const catchLocal = Type.Struct; catchLocal.extends = this.PROCESSOR.currentLocalScope; this.PROCESSOR.pushLocalScope(children.Catch[0], true, catchLocal); // Add the identifier to the new localscope const identifier = identifierFrom(children); if (identifier) { const range = this.PROCESSOR.range(identifier.token); const type = this.PROCESSOR.project.types.get('Struct.Exception')?.derive() || new Type('Any'); const signifier = this.PROCESSOR.currentLocalScope.addMember(identifier.name, { type }); signifier.addRef(range, true); signifier.definedAt(range); signifier.local = true; } this.visit(children.blockStatement, ctx); this.PROCESSOR.popLocalScope(children.blockStatement[0].location, true); } functionStatement(children, ctx) { this.functionExpression(children.functionExpression[0].children, withCtxKind(ctx, 'functionStatement')); } functionExpression(children, context) { return visitFunctionExpression.call(this, children, context); } returnStatement(children, ctx) { const returnType = children.assignmentRightHandSide ? this.assignmentRightHandSide(children.assignmentRightHandSide[0].children, withCtxKind(ctx, 'functionReturn')) : this.UNDEFINED; ctx.returns?.push(...arrayWrapped(returnType)); return arrayWrapped(returnType); } /** Called on *naked* identifiers and those that have accessors/suffixes of various sorts. */ identifierAccessor(children, context) { return arrayWrapped(visitIdentifierAccessor.call(this, children, context)); } macroStatement(children, ctx) { // Macros are just references to some expression, so set their // type the the type of that expression. // Macros are defined during global parsing, so we can assume // that they exist. const signifier = this.FIND_ITEM_BY_NAME(children.Identifier[0].image); assert(signifier, 'Macro should exist'); // If the macro ends with a ';' then it's a statement, otherwise // we can treat it like a variable. const isStatement = children.expressionStatement?.[0]?.children.Semicolon; const expression = children.expressionStatement?.[0]?.children.expression?.[0]?.children; const expressionType = expression && this.expression(expression, ctx); const inferredType = isStatement ? Type.Undefined : normalizeType(expressionType, this.PROCESSOR.project.types); signifier.setType(inferredType); } /** Static params are unambiguously defined. */ staticVarDeclarations(children, ctx) { // The same as a regular non-var assignment, except we // need to indicate that it is static. return this.variableAssignment(children, { ...ctx, isStatic: true }); } globalVarDeclaration(children) { // Allow overriding the type with JSDocs const identity = identifierFrom(children); const docs = this.PROCESSOR.consumeJsdoc(); if (!identity) { return; } const signifier = this.PROCESSOR.globalSelf.getMember(identity.name); assert(signifier, `Global var ${identity.name} should exist`); // Get the reference added const ref = signifier.addRef(this.PROCESSOR.range(identity.token), true); // This signifier should already be registered via the global pass // so we just need to update its type. if (docs?.jsdoc.kind && ['type', 'description'].includes(docs.jsdoc.kind)) { signifier.describe(docs.jsdoc.description); signifier.setType(docs.type); } return { item: signifier, ref, }; } localVarDeclaration(children, ctx) { const docs = this.PROCESSOR.consumeJsdoc(); const local = this.PROCESSOR.currentLocalScope; const range = this.PROCESSOR.range(children.Identifier[0]); const name = children.Identifier[0].image; return assignVariable(this, { container: local, name, range }, children.assignmentRightHandSide, { ctx, docs, local: true }); } variableAssignment(children, ctx) { // Determine the args for ASSIGN const name = children.Identifier[0].image; const range = this.PROCESSOR.range(children.Identifier[0]); const rhs = children.assignmentRightHandSide; const docs = this.PROCESSOR.consumeJsdoc(); // Determine the container for this variable const { isStatic } = ctx; ctx.isStatic = false; // Reset to prevent downstream confusion const assignedToFunction = !!functionFromRhs(rhs); const excludeGlobal = !!(isStatic || assignedToFunction); const excludeParents = !!(isStatic || assignedToFunction); let container = this.FIND_ITEM(children, { excludeGlobal, excludeParents, })?.item?.parent; if (!container) { const fullScope = this.PROCESSOR.fullScope; // Add to the self-scope unless it's a static inside a non-constructor function, and if that scope is not global. const outerFunction = fullScope.self.signifier?.getTypeByKind('Function'); container = isStatic && !outerFunction?.isConstructor ? fullScope.local : fullScope.self; } return assignVariable(this, { name, range, container }, rhs, { static: isStatic, docs, ctx, }); } structLiteral(children, ctx) { // We may already have a struct type attached to a signfier, // which should be updated instead of replaced. const structFromDocs = ctx.docs?.type[0]?.kind === 'Struct' ? ctx.docs?.type[0] : getTypeOfKind(ctx.type, 'Struct')?.derive(); const struct = ctx.signifier?.getTypeByKind('Struct') || structFromDocs || this.PROCESSOR.createStruct(children.StartBrace[0], children.EndBrace[0]); ctx.signifier?.setType(struct); ctx.signifier = undefined; ctx.docs = undefined; // TODO // // If we are creating a struct literal to match a doc-described // // struct, we should *extend* that underlying struct so we don't // // mutate the parent (and so we can differientiate between) // if (structFromDocs) { // struct = structFromDocs.derive(); // } // Create the newMember ranges, to help with autocompletes const sortedParts = sortChildren(children); let nextRange; for (let i = 0; i < sortedParts.length; i++) { const part = sortedParts[i]; const isStart = 'image' in part && [',', '{'].includes(part.image); if (isStart) { // Then we're starting a range! const startPosition = Position.fromCstEnd(this.PROCESSOR.file, part); // Loop through the next parts until we find nextRange = new StructNewMemberRange(struct, startPosition); } else if (nextRange) { const endPosition = Position.fromCstStart(this.PROCESSOR.file, 'image' in part ? part : part.location); nextRange.end = endPosition; this.PROCESSOR.file.addStructNewMemberRange(nextRange); nextRange = undefined; } } // Manage the struct members // The self-scope remains unchanged for struct literals! for (const entry of children.structLiteralEntry || []) { const parts = entry.children; // Visit the JSDocs, if there are any if (parts.jsdoc) { this.visit(parts.jsdoc, ctx); } const docs = this.PROCESSOR.consumeJsdoc(); // The name is either a direct variable name or a string literal. let name; let range; if (parts.Identifier) { name = parts.Identifier[0].image; range = this.PROCESSOR.range(parts.Identifier[0]); } else { name = stringLiteralAsString(parts.stringLiteral[0].children); range = this.PROCESSOR.range(parts.stringLiteral[0].children.StringStart[0], parts.stringLiteral[0].children.StringEnd[0]); } if (parts.assignmentRightHandSide) { assignVariable(this, { name, range, container: struct }, parts.assignmentRightHandSide, { docs, ctx: { ...ctx, type: struct.getMember(name)?.type }, instance: true, }); } else { // Then we're in short-hand mode, where the RHS has the same // name but refers to a local variable. const matchingVariable = this.FIND_ITEM_BY_NAME(name); if (!matchingVariable) { // Add an error message this.PROCESSOR.addDiagnostic('INVALID_OPERATION', parts.Identifier[0], `Struct literal shorthand requires an existing variable named "${name}"`); } else { struct.addMember(matchingVariable, { override: true }); matchingVariable.addRef(range); } } } return struct; } /** * Fallback identifier handler. Figure out what a given * identifier is referencing, and create appropriate references * to make that work.*/ identifier(children) { const item = this.FIND_ITEM(children); if (item) { const ref = item.item.addRef(item.range); return { item: item.item, ref, }; } return; } //#region LITERALS and TYPES assignmentRightHandSide(children, context) { if (children.expression) { return this.expression(children.expression[0].children, context); } else if (children.structLiteral) { return [this.structLiteral(children.structLiteral[0].children, context)]; } else if (children.functionExpression) { return [ this.functionExpression(children.functionExpression[0].children, context) || this.ANY, ]; } return [this.ANY]; } expression(children, context) { const lhs = this.primaryExpression(children.primaryExpression[0].children, context); if (children.binaryExpression) { // TODO: Check the rhs type and the operator and emit a diagnostic if needed. For now just return the lhs since any operator shouldn't change the type. this.assignmentRightHandSide(children.binaryExpression[0].children.assignmentRightHandSide[0] .children, context); const operator = children.binaryExpression[0].children.BinaryOperator[0].image; const isNumeric = operator.match(/^([*/%^&|-]|<<|>>)$/); const isBoolean = !isNumeric && operator.match(/^([><]=?|\|\||&&|!=|==)$/); if (isNumeric) { return [this.REAL]; } else if (isBoolean) { return [this.BOOLEAN]; } else { return lhs; } } else if (children.ternaryExpression) { // Get the types of the two expression and create a union const ternary = children.ternaryExpression[0].children.assignmentRightHandSide; const leftType = this.assignmentRightHandSide(ternary[0].children, context); const rightType = this.assignmentRightHandSide(ternary[1].children, context); return [...arrayWrapped(leftType), ...arrayWrapped(rightType)]; } else if (children.assignment) { // We shouldn't really end up here since well-formed code // should have assignments that get caught by other rules. return [this.ANY]; } return lhs; // Shouldn't happpen unless the parser gets changed. } primaryExpression(children, context) { let type; if (children.BooleanLiteral) { type = new Type('Bool'); } else if (children.NumericLiteral) { type = new Type('Real'); } else if (children.NaN) { type = new Type('Real'); } else if (children.PointerLiteral) { type = new Type('Pointer'); } else if (children.Undefined) { type = new Type('Undefined'); } else if (children.arrayLiteral) { type = this.arrayLiteral(children.arrayLiteral[0].children, context); } else if (children.identifierAccessor) { type = this.identifierAccessor(children.identifierAccessor[0].children, context); } else if (children.stringLiteral) { type = this.stringLiteral(children.stringLiteral[0].children, context); } else if (children.multilineDoubleStringLiteral) { type = this.multilineDoubleStringLiteral(children.multilineDoubleStringLiteral[0].children, context); } else if (children.multilineSingleStringLiteral) { type = this.multilineSingleStringLiteral(children.multilineSingleStringLiteral[0].children, context); } else if (children.templateLiteral) { type = this.templateLiteral(children.templateLiteral[0].children, context); } else if (children.parenthesizedExpression) { type = this.parenthesizedExpression(children.parenthesizedExpression[0].children, context); } if (!type) { logger.warn('No type found for primary expression'); } type ||= this.ANY; // Override the type if we have a unary operator const prefixOperator = children.UnaryPrefixOperator?.[0].image; if (prefixOperator?.match(/^[~+-]|\+\+|--$/)) { type = this.REAL; } else if (prefixOperator?.match(/^!$/)) { type = this.BOOLEAN; } return arrayWrapped(type); } parenthesizedExpression(children, context) { return this.expression(children.expression[0].children, context); } stringLiteral(children, context) { return new Type('String'); } multilineDoubleStringLiteral(children, context) { return new Type('String'); } multilineSingleStringLiteral(children, context) { return new Type('String'); } templateLiteral(children, context) { // Make sure that the code content is still visited for (const exp of children.expression || []) { this.expression(exp.children, withCtxKind(context, 'template')); } return new Type('String'); } arrayLiteral(children, ctx) { // Infer the content type of the array // Make sure that the content is visited const types = []; const arrayType = new Type('Array'); for (const item of children.assignmentRightHandSide || []) { const itemTypes = this.assignmentRightHandSide(item.children, withCtxKind(ctx, 'arrayMember')); for (const itemType of getTypes(itemTypes)) { if (!types.find((t) => t.kind === itemType.kind && t.name === itemType.name)) { types.push(itemType); arrayType.addItemType(itemType); } } } if (ctx.signifier) { ctx.signifier.setType(ctx.docs?.type || arrayType); } return arrayType; } } //# sourceMappingURL=visitor.js.map