UNPKG

@bscotch/gml-parser

Version:

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

468 lines 17.4 kB
import { keysOf } from '@bscotch/utility'; import { flattenFeatherTypes } from './jsdoc.feather.js'; import { assert } from './util.js'; const patterns = { param: `@(param(eter)?|arg(ument)?)\\b`, description: `@desc(ription)?\\b`, function: `@func(tion)?\\b`, returns: `@returns?\\b`, pure: `@pure\\b`, mixin: `@mixin\\b`, ignore: `@ignore\\b`, deprecated: `@deprecated\\b`, self: `@(context|self)\\b`, type: `@type\\b`, localvar: `@(localvar|var)\\b`, globalvar: `@globalvar\\b`, instancevar: `@(instancevar|prop(erty)?)\\b`, template: `@template\\b`, unknown: `@\\w+\\b`, }; const typeGroupPattern = `(?<typeGroup>{\\s*(?<typeUnion>[^}]*?)?\\s*})`; const linePrefixPattern = `^(\\s*(?<delim>///|\\*)\\s*)?`; const descriptionPattern = `(?:\\s*-\\s*)?(?<info>.*)`; const paramNamePattern = `(?<name>[a-zA-Z_][a-zA-Z_0-9]*)`; const paramDefaultPattern = `(?:\\s*=\\s*(?<default>[^\\]]+?)\\s*)`; const dotdotdot = `\\.\\.\\.`; const optionalParamNamePattern = `\\[\\s*(?<optionalName>(?:[a-zA-Z_][a-zA-Z_0-9]*|${dotdotdot}))\\s*${paramDefaultPattern}?\\]`; const names = keysOf(patterns); for (const tagName of names) { // Add the line prefix and a tag capture group patterns[tagName] = `${linePrefixPattern}(?<tag>(?<${tagName}>${patterns[tagName]}))`; } // Types with required typeGroups const typeTags = ['returns', 'type']; for (const tagName of typeTags) { patterns[tagName] = `${patterns[tagName]}\\s*${typeGroupPattern}`; } // Params patterns.param = `${patterns.param}(\\s+${typeGroupPattern})?\\s+(${paramNamePattern}|${optionalParamNamePattern})`; // Variable declaration patterns for (const tagName of [ 'localvar', 'globalvar', 'instancevar', 'template', ]) { patterns[tagName] = `${patterns[tagName]}(\\s+${typeGroupPattern})?\\s+${paramNamePattern}`; } // Self (has a type but no group. Make brackets optional to be more forgiving) patterns.self = `${patterns.self}\\s+(?<extraBracket>\\{\\s*)?(?<type>[a-zA-Z_][a-zA-Z_0-9.]*)(?:\\s*\\})?`; // Descriptions for (const tagName of names) { patterns[tagName] = `${patterns[tagName]}(\\s+${descriptionPattern})?`; } const descriptionLine = `${linePrefixPattern}\\s*${descriptionPattern}`; const regexes = names.reduce((acc, tagName) => { // The 'd' flag is only supported in Node 18+, which VSCode doesn't support yet. acc[tagName] = new RegExp(patterns[tagName]); return acc; }, {}); /** * Given an IToken containing an entire JSDoc block, * convert it to a list of lines, each with its own position. */ function jsdocBlockToLines(raw) { raw = { ...raw }; // Clone so we don't mutate the original const startPosition = { line: raw.startLine, column: raw.startColumn, offset: raw.startOffset, }; // If this starts with a /**, remove that. raw.image = raw.image.replace(/^\/\*\*/, ' '); // If this ends with a */, remove that. raw.image = raw.image.replace(/\*\/$/, ' '); const rawLines = raw.image.split(/(\r?\n)/); const jsdocLines = []; let jsdocLine = { content: '', start: startPosition, }; for (let l = 0; l < rawLines.length; l++) { const line = rawLines[l]; if (!line) continue; if (line.match(/\r?\n/)) { // Then we need to add more space to the current jsdocline // start position jsdocLine.start.line++; jsdocLine.start.column = 1; jsdocLine.start.offset += line.length; continue; } jsdocLine.content += line; jsdocLines.push(jsdocLine); // Create the next jsdoc line jsdocLine = { content: '', start: { line: jsdocLine.start.line, column: jsdocLine.start.column + line.length, offset: jsdocLine.start.offset + line.length, }, }; } return jsdocLines; } /** * Given an array of ITokens, each containing a single line * from a block of ///-style JSDoc comments, convert it to * a common format. */ function jsdocGmlToLines(raw) { // We already have lines as required, so just convert formats return raw.map((token) => ({ content: token.image, start: { line: token.startLine, column: token.startColumn, offset: token.startOffset, }, })); } /** * Given a raw string containing a JSDoc block in either GML * or JS style, convert it * to a list of lines, each with its own position. */ function jsdocStringToLines(raw, startPosition) { const asIToken = { image: raw, startLine: startPosition?.line || 1, startColumn: startPosition?.column || 1, startOffset: startPosition?.offset || 0, }; return jsdocBlockToLines(asIToken); } /** * Since single-line style comments make it impossible to * tell when we're in a NEW doc, we need to break lines into * groups */ export function gmlLinesByGroup(gmlLines) { const lines = jsdocGmlToLines(gmlLines); const groups = [[]]; let currentGroup = groups[0]; lines: for (const line of lines) { if (!line) { continue; } let match = null; for (const tagName of names) { match = line.content.match(regexes[tagName]); if (!match) { continue; } const parts = match.groups; // Consider simpler groups and let the rest fall together if (parts.localvar || parts.globalvar || parts.instancevar) { // Then this is a new block! currentGroup = []; groups.push(currentGroup); } currentGroup.push(line); continue lines; // Matches are exclusive, so go to the next line } // If we make it here then we didn't fine a match. Just add it to the currentgruop currentGroup.push(line); } return groups.filter((g) => g.length); } export function parseJsdoc(raw, /** * The position of the first character of the jsdoc string, * if it has been parsed out of a larger document. This is * used to offset the positions of discovered tag components. */ startPosition) { let lines; if (typeof raw === 'string') { lines = jsdocStringToLines(raw, startPosition); } else if (Array.isArray(raw)) { if (raw[0] && 'image' in raw[0]) { // Then this is a list of ITokens lines = jsdocGmlToLines(raw); } else { // Then this is a list of JsdocLines lines = raw; } } else { // Then this is a single IToken lines = jsdocBlockToLines(raw); } assert(lines, 'Lines must be an array'); if (!lines.length) return undefined; assert(lines[0], 'No lines found in jsdoc block'); const start = lines[0].start; const end = { ...lines.at(-1).start }; end.column += lines.at(-1).content.length; end.offset += lines.at(-1).content.length; // Default to a description-only doc, and update its type // if we can infer it based on the tags. const doc = { kind: 'description', description: '', start, end, tags: [], diagnostics: [], typeRanges: [], }; const addTypeRanges = (component) => { if (!component) return; const types = flattenFeatherTypes(component.content); for (const type of types) { // Convert the offset to a range and add it to typeRanges const start = { line: component.start.line, column: component.start.column + type.name.offset, offset: component.start.offset + type.name.offset, }; const end = { ...start }; end.column += type.name.content.length - 1; end.offset += type.name.content.length - 1; doc.typeRanges.push({ content: type.name.content, start, end, }); } }; let describing = doc; const appendDescription = (currentDescription, newDescription) => { newDescription ||= ''; if (currentDescription) { return `${currentDescription}\n${newDescription}`; } return newDescription; }; for (const line of lines) { assert(line, 'Line does not exist'); // Check for a match against each of the tag patterns // until we fined one. If we don't then `match` will // stay null, and we can use the line as a description. let match = null; for (const tagName of names) { match = line.content.match(regexes[tagName]); const parts = match?.groups; // TODO: In Node <18, this will be undefined. Once VSCode // updates to use Node 18+ we can remove the `undefined` union. // const indices = match?.indices?.groups as MatchIndices | undefined; if (!match) { // Then we haven't found a tag yet continue; } // Add the tag to the list of tags const tagMatch = line.content.match(/@\w+\b/); assert(tagMatch, 'Tag match should exist'); assert(tagMatch[0], 'Tag match must be an array'); const tagIndices = [ tagMatch.index, tagMatch.index + tagMatch[0].length, ]; doc.tags.push({ content: parts.tag, ...matchIndexToRange(line.start, tagIndices), }); // Based on the tag type, update the doc const impliesFunction = parts.function || parts.param || parts.returns || parts.pure || parts.template; if (impliesFunction) { doc.kind = 'function'; } const matchIndices = [ match.index, match.index + match[0].length, ]; const entireMatchRange = matchIndexToRange(line.start, matchIndices); // If this uses an @description tag, then apply that description // to the root doc. if (parts.description) { doc.description = appendDescription(doc.description, parts.info); } // If it's an unfamiliar tag, just skip it else if (parts.unknown) { // Unset the describe target describing = null; } // Handle params, templates, and variables else if (parts.param || parts.template || parts.localvar || parts.globalvar || parts.instancevar) { // Until VSCode ships Node 18, and therefore has group // indices, we'll have to get positions with a simple search // per group. const kind = parts.param ? 'param' : parts.template ? 'template' : parts.localvar ? 'localvar' : parts.globalvar ? 'globalvar' : 'instancevar'; const typeString = substringRange(line.content, parts.typeUnion, line.start); addTypeRanges(typeString); const entity = { kind, name: substringRange(line.content, parts.name || parts.optionalName, line.start), optional: !!parts.optionalName, type: typeString, description: parts.info || '', ...entireMatchRange, }; if (kind === 'param') { doc.params = doc.params || []; doc.params.push(entity); } else if (kind === 'template') { doc.templates = doc.templates || []; doc.templates.push(entity); } else { doc.kind = kind; doc.type = entity.type; doc.name = entity.name; doc.description = entity.description; } // Update the current describing object in case the next line is a description describing = entity; } // Handle returns else if (parts.returns) { if (doc.returns) { // Then we don't want to overwrite. break; } const typeString = substringRange(line.content, parts.typeUnion, line.start); addTypeRanges(typeString); const returns = { kind: 'returns', type: typeString, description: parts.info || '', ...entireMatchRange, }; doc.returns = returns; // Update the current describing object in case the next line is a description describing = returns; } // Handle Self else if (parts.self) { doc.self = substringRange(line.content, parts.type, line.start); addTypeRanges(doc.self); if (parts.extraBracket) { doc.diagnostics.push({ message: '@self types should not be wrapped in brackets', start: doc.self.start, end: doc.self.end, }); } } // Handle Type else if (parts.type) { doc.kind = 'type'; doc.type = substringRange(line.content, parts.typeUnion, line.start); addTypeRanges(doc.type); doc.description = appendDescription(doc.description, parts.info); } // Handle modifiers else if (parts.deprecated) { doc.deprecated = true; } else if (parts.ignore) { doc.ignore = true; } else if (parts.mixin) { doc.mixin = true; } break; } // If we haven't found a tag, then this is a description line // Then this is a description-only line (or something invalid). // Apply it to the current describing object. if (!match && describing) { const descriptionMatch = line.content.match(descriptionLine); if (descriptionMatch) { describing.description = appendDescription(describing.description, descriptionMatch.groups?.info); } } } if (doc.kind === 'description' && doc.self) { // Then we don't know for sure what this context is for, // but it's useful to call it a self doc. doc.kind = 'self'; } // Ensure that there are no redundantly-named params, since that // is both not allowed and likely to cause weird problems later. // The safest thing to do if we see a duplicate is to skip it and all // subsequent params. const paramNames = new Set(); for (let i = 0; i < (doc.params?.length || 0); i++) { const param = doc.params[i]; if (paramNames.has(param.name.content)) { doc.diagnostics.push({ message: `Duplicate param name: ${param.name.content}`, start: param.name.start, end: param.name.end, }); // Add diagnostics for all subsequent params for (let j = i + 1; j < doc.params.length; j++) { const laterParam = doc.params[j]; doc.diagnostics.push({ message: `Skipping due to previous duplicate param`, start: laterParam.name.start, end: laterParam.name.end, }); } doc.params.splice(i); break; } paramNames.add(param.name.content); } return doc; } function substringRange(string, substring, start) { if (!substring) { return undefined; } start = { ...start }; const index = string.indexOf(substring); if (index < 0) { return undefined; } start.column += index; start.offset += index; return { start, end: { column: start.column + substring.length - 1, line: start.line, offset: start.offset + substring.length - 1, }, content: substring, }; } function matchIndexToRange(startPosition, index) { // Note that the IRange uses column and line indexes that start at 1, while the offset starts at 0. const range = { start: { ...startPosition }, end: { ...startPosition }, }; range.start.column += index?.[0] || 0; range.start.offset += index?.[0] || 0; range.end.column += index?.[1] || 0; range.end.offset += index?.[1] || 0; return range; } //# sourceMappingURL=jsdoc.js.map