Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 59 additions & 39 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { createSystem, createDefaultMapFromNodeModules, createVirtualTypeScriptEnvironment } from '@typescript/vfs';
import * as ts from 'typescript';

import { ParsingError } from './parsers/parsing-error.js';
import { ScriptParser } from './parsers/script-parser.js';
import { isInterface, isStaticMember } from './utils/attribute-utils.js';
import { createDefaultMapFromCDN, flatMapAnyNodes, getExportedNodes, getType, inheritsFrom, isAliasedClassDeclaration } from './utils/ts-utils.js';
import { isStaticMember } from './utils/attribute-utils.js';
import { createDefaultMapFromCDN, getExportedNodes, getType, inheritsFrom, isAliasedClassDeclaration } from './utils/ts-utils.js';

const toLowerCamelCase = str => str[0].toLowerCase() + str.substring(1);

const SUPPORTED_ATTRIBUTE_TYPES = new Set([
'Curve',
'Vec2',
'Vec3',
'Vec4',
'Color',
'number',
'string',
'boolean'
]);

const COMPILER_OPTIONS = {
noLib: true,
strict: false,
Expand Down Expand Up @@ -110,9 +122,10 @@ export class JSDocParser {
/**
* Returns all the valid ESM Scripts within a file
* @param {string} fileName - The file name in the program to check
* @param {ParsingError[]} errors - An array to store any parsing errors
* @returns {Map<string, import('typescript').Node>} - A map of valid ESM Script <names, nodes> within the file
*/
getAllEsmScripts(fileName) {
getAllEsmScripts(fileName, errors = []) {

const typeChecker = this.program.getTypeChecker();

Expand Down Expand Up @@ -163,8 +176,27 @@ export class JSDocParser {
if (scriptNameMember) {
finalName = scriptNameMember.initializer.text;
} else {
// Otherwise, convert the class name to lower camel case
finalName = toLowerCamelCase(name);
const insertOffset = node.members.pos;
const insertPos = node.getSourceFile().getLineAndCharacterOfPosition(insertOffset);

errors.push(
new ParsingError(
node,
'Missing Script Name',
'Scripts should have a static scriptName member that identifies the script.',
{
title: 'Add scriptName',
text: `\n static scriptName = '${finalName}';\n`,
range: {
startLineNumber: insertPos.line + 1,
startColumn: insertPos.character + 1,
endLineNumber: insertPos.line + 1,
endColumn: insertPos.character + 1
}
}
)
);
}

esmScripts.set(finalName, node);
Expand All @@ -189,7 +221,7 @@ export class JSDocParser {
}

// Extract all exported nodes
const nodes = this.getAllEsmScripts(fileName);
const nodes = this.getAllEsmScripts(fileName, errors);

// Extract attributes from each script
nodes.forEach((node, name) => {
Expand All @@ -214,41 +246,31 @@ export class JSDocParser {
}

const typeChecker = this.program.getTypeChecker();
const esmScripts = this.getAllEsmScripts(fileName, errors);
const [attributes] = this.parseAttributes(fileName);

// Find the Script class in the PlayCanvas namespace
const pcTypes = this.program.getSourceFile('/playcanvas.d.ts');
const esmScriptClass = pcTypes.statements.find(node => node.kind === ts.SyntaxKind.ClassDeclaration && node.name.text === 'Script')?.symbol;

// Parse the source file and pc types
const sourceFile = this.program.getSourceFile(fileName);

if (!sourceFile) {
throw new Error(`Source file ${fileName} not found`);
}

const nodes = flatMapAnyNodes(sourceFile, (node) => {
if (!ts.isClassDeclaration(node)) {
return false;
}

return inheritsFrom(node, typeChecker, esmScriptClass) || isInterface(node);
});

for (const node of nodes) {
const name = toLowerCamelCase(node.name.text);
const scripts = [];
for (const [scriptName, node] of esmScripts) {
const members = [];

for (const member of node.members) {
if (member.kind !== ts.SyntaxKind.PropertyDeclaration || isStaticMember(member)) {
continue;
if (member.kind === ts.SyntaxKind.PropertyDeclaration && !isStaticMember(member)) {
members.push(member);
}
}
scripts.push({ scriptName, members });
errors.push(...(attributes[scriptName]?.errors ?? []));
}

// Extract JSDoc tags
const tags = member.jsDoc ? member.jsDoc.map(jsdoc => jsdoc.tags?.map(tag => tag.tagName.getText()) ?? []).flat() : [];

for (const { scriptName, members } of scripts) {
const localMembers = [];
for (const member of members) {
const name = member.name.getText();
const attribute = attributes[scriptName].attributes[name];
const isAttribute = !!attribute;

const type = getType(member, typeChecker);
if (!type) {

if (!SUPPORTED_ATTRIBUTE_TYPES.has(type.name)) {
continue;
}

Expand All @@ -257,19 +279,17 @@ export class JSDocParser {
const jsdocNode = member.jsDoc && member.jsDoc[member.jsDoc.length - 1];
const jsdocPos = jsdocNode ? ts.getLineAndCharacterOfPosition(member.getSourceFile(), jsdocNode.getStart()) : null;

const data = {
localMembers.push({
name,
isAttribute,
type: type.name + (type.array ? '[]' : ''),
isAttribute: tags.includes('attribute'),
start: jsdocNode ? { line: jsdocPos.line + 1, column: jsdocPos.character + 1 } :
{ line: namePos.line + 1, column: namePos.character + 1 },
end: { line: namePos.line + 1, column: namePos.character + name.length + 1 }
};

members.push(data);
});
}

results[name] = members;
results[scriptName] = localMembers;
}

return [results, errors];
Expand Down
53 changes: 45 additions & 8 deletions src/parsers/attribute-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,21 @@ import { hasTag } from '../utils/attribute-utils.js';
import { parseTag, validateTag } from '../utils/tag-utils.js';
import { getLiteralValue, getType } from '../utils/ts-utils.js';

/**
* @typedef {Object} TagDefinition
* @property {string} type - The type of the tag
* @property {() => string} supportMessage - A function to generate a support message for the tag
* @property {string} fix - A function to fix the tag
*/

/**
* A class to parse JSDoc comments and extract attribute metadata.
*/
export class AttributeParser {
/**
* Creates a new instance of the ScriptParser class.
*
* @param {Map<string, string>} tags - An set of tag definitions and their validators to add to the parser
* @param {Map<string, TagDefinition>} tags - An set of tag definitions and their validators to add to the parser
* @param {object} env - The TypeScript environment to use
* @param {Map<string, Function>} typeSerializerMap - A map of custom serializers
*/
Expand Down Expand Up @@ -52,24 +59,54 @@ export class AttributeParser {
const jsDocTags = node.jsDoc?.[0]?.tags ?? [];
jsDocTags.forEach((tag) => {
// Only use the first line of the comment
const tagName = tag.tagName.text;
let commentText = (tag.comment?.split('\n')[0] || '').trim();

// Check if the tag is a supported tag
if (this.tagTypeAnnotations.has(tag.tagName.text)) {
if (this.tagTypeAnnotations.has(tagName)) {
const { type, supportMessage, fix } = this.tagTypeAnnotations.get(tagName);
try {
const value = parseTag(commentText);
const tagTypeAnnotation = this.tagTypeAnnotations.get(tag.tagName.text);

// Tags like @resource with string values do not need quotations, so we need to manually add them
if (typeof value === 'string') commentText = `"${commentText}"`;

validateTag(commentText, tagTypeAnnotation, this.env);
attribute[tag.tagName.text] = value;
validateTag(commentText, type, this.env);
attribute[tagName] = value;

} catch (error) {
const file = node.getSourceFile();
const { line, character } = file.getLineAndCharacterOfPosition(tag.getStart());
const parseError = new ParsingError(node, `Invalid Tag '@${tag.tagName.text}'`, `Error (${line}, ${character}): Parsing Tag '@${tag.tagName.text} ${commentText}' - ${error.message}`);

// generate a fix if one is provided
let startPos = tag.getStart();
let endPos = tag.getEnd();

const fullText = tag.getText();
const commentText = tag.comment?.split('\n')[0]?.trim() || '';

// Find the start of the comment content within the full text
const commentStart = fullText.indexOf(commentText);
if (commentStart !== -1) {
startPos = tag.getStart() + commentStart;
endPos = startPos + commentText.length;
}

const sourceFile = tag.getSourceFile();
const startLineChar = sourceFile.getLineAndCharacterOfPosition(startPos);
const endLineChar = sourceFile.getLineAndCharacterOfPosition(endPos);

const edit = fix ? {
text: fix,
title: `Fix @${tagName} tag`,
range: {
startLineNumber: startLineChar.line + 1,
startColumn: startLineChar.character + 1,
endLineNumber: endLineChar.line + 1,
endColumn: endLineChar.character + 1
}
} : null;

const errorMessage = supportMessage?.() ?? 'The tag is invalid.';
const parseError = new ParsingError(tag, `Invalid Tag '@${tagName}'`, errorMessage, edit);
errors.push(parseError);
}
}
Expand Down
17 changes: 16 additions & 1 deletion src/parsers/parsing-error.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
/**
* @typedef {Object} Fix
* @property {string} text - The text to insert at the fix location
* @property {string} title - The title of the fix
* @property {{ startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number }} range - The range of the fix
*/

/**
* A class representing a parsing error.
* @param {import('typescript').Node} node - The AST node which caused the error
* @param {string} type - The type of the error
* @param {string} message - The description of the error
* @param {Fix} [fix] - The fix for the error
*/
export class ParsingError {
constructor(node, type, message) {
constructor(node, type, message, fix) {
this.node = node; // AST node which caused the error
this.type = type; // Type of the error
this.message = message; // Description of the error
this.fix = fix; // Fix for the error
}

toString() {
Expand Down
Loading