diff --git a/package-lock.json b/package-lock.json index 0fb6e2f..3eee914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@playcanvas/attribute-parser", - "version": "1.0.7", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@playcanvas/attribute-parser", - "version": "1.0.7", + "version": "1.1.0", "dependencies": { "@microsoft/tsdoc": "^0.15.0", "@playcanvas/eslint-config": "^1.7.4", diff --git a/package.json b/package.json index efcaed0..1789894 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "engines": { "node": ">=18.0.0" }, - "version": "1.0.7", + "version": "1.1.0", "dependencies": { "@microsoft/tsdoc": "^0.15.0", "@playcanvas/eslint-config": "^1.7.4", diff --git a/src/index.js b/src/index.js index 0761c04..f8a1429 100644 --- a/src/index.js +++ b/src/index.js @@ -209,7 +209,7 @@ export class JSDocParser { const namePos = ts.getLineAndCharacterOfPosition(member.getSourceFile(), member.name.getStart()); - const jsdocNode = member.jsDoc[member.jsDoc.length - 1]; + const jsdocNode = member.jsDoc && member.jsDoc[member.jsDoc.length - 1]; const jsdocPos = jsdocNode ? ts.getLineAndCharacterOfPosition(member.getSourceFile(), jsdocNode.getStart()) : null; const data = { diff --git a/src/parsers/attribute-parser.js b/src/parsers/attribute-parser.js index 85b4e0e..ade50d4 100644 --- a/src/parsers/attribute-parser.js +++ b/src/parsers/attribute-parser.js @@ -4,7 +4,7 @@ import * as ts from 'typescript'; import { ParsingError } from './parsing-error.js'; import { hasTag } from '../utils/attribute-utils.js'; import { parseTag, validateTag } from '../utils/tag-utils.js'; -import { extractTextFromDocNode, getLeadingBlockCommentRanges, getType } from '../utils/ts-utils.js'; +import { extractTextFromDocNode, getLeadingBlockCommentRanges, getLiteralValue, getType } from '../utils/ts-utils.js'; /** * A class to parse JSDoc comments and extract attribute metadata. @@ -170,7 +170,7 @@ export class AttributeParser { const serializer = !array && this.typeSerializerMap.get(typeName); if (serializer) { try { - value = serializer(node.initializer ?? node); + value = serializer(node.initializer ?? node, this.typeChecker); } catch (error) { errors.push(error); return; @@ -225,7 +225,7 @@ export class AttributeParser { // Check if the declaration is a TypeScript enum if (ts.isEnumDeclaration(declaration)) { - members = declaration.members.map(member => member.name.getText()); + members = declaration.members.map(member => ({ [member.name.getText()]: member.initializer.text })); } // Additionally check for JSDoc enum tag @@ -259,19 +259,7 @@ export class AttributeParser { if (ts.isPropertyAssignment(property)) { const name = property.name && ts.isIdentifier(property.name) && property.name.text; - let value; - - const node = property.initializer; - - // Enums can only contain primitives (string|number|boolean) - if (ts.isNumericLiteral(node)) { - value = parseFloat(node.getText()); - } else if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) { - value = node.kind === ts.SyntaxKind.TrueKeyword; - } else { - value = node.getText(); - } - + const value = getLiteralValue(property.initializer, this.typeChecker); members.push({ [name]: value }); } }); diff --git a/src/parsers/script-parser.js b/src/parsers/script-parser.js index feacc40..f6bb31b 100644 --- a/src/parsers/script-parser.js +++ b/src/parsers/script-parser.js @@ -5,7 +5,7 @@ import { AttributeParser } from './attribute-parser.js'; import { ParsingError } from './parsing-error.js'; import { hasTag } from '../utils/attribute-utils.js'; import { zipArrays } from '../utils/generic-utils.js'; -import { flatMapAnyNodes, getJSDocCommentRanges, parseArrayLiteral, parseBooleanNode, parseFloatNode, parseStringNode } from '../utils/ts-utils.js'; +import { flatMapAnyNodes, getJSDocCommentRanges, getLiteralValue, parseArrayLiteral, parseFloatNode } from '../utils/ts-utils.js'; /** * @typedef {object} Attribute @@ -77,9 +77,9 @@ const SUPPORTED_INITIALIZABLE_TYPE_NAMES = new Map([ ['Vec3', createNumberArgumentParser('Vec3', [0, 0, 0])], ['Vec4', createNumberArgumentParser('Vec4', [0, 0, 0, 0])], ['Color', createNumberArgumentParser('Color', [1, 1, 1, 1])], - ['number', parseFloatNode], - ['string', parseStringNode], - ['boolean', parseBooleanNode] + ['number', getLiteralValue], + ['string', getLiteralValue], + ['boolean', getLiteralValue] ]); /** @@ -188,6 +188,13 @@ const mapAttributesToOutput = (attribute) => { // remove enum if it's empty if (attribute.enum.length === 0) delete attribute.enum; + // If the attribute has no default value then set it + if (attribute.value === undefined) { + if (attribute.type === 'string') attribute.value = ''; + if (attribute.type === 'number') attribute.value = 0; + if (attribute.type === 'boolean') attribute.value = false; + } + // set the default value if (attribute.value !== undefined) attribute.default = attribute.default ?? attribute.value; diff --git a/src/utils/tag-utils.js b/src/utils/tag-utils.js index ace641e..ba0b167 100644 --- a/src/utils/tag-utils.js +++ b/src/utils/tag-utils.js @@ -1,3 +1,5 @@ +import { DiagnosticCategory } from 'typescript'; + import { parseNumber, parseStringToNumericalArray } from './ts-utils.js'; /** @@ -35,31 +37,37 @@ export function parseTag(input = '') { /** * Validates that a tag value matches the expected type - * @throws {Error} - If the tag value is not the expected type * - * @param {String} value - The string representation of the value to type check - * @param {string} typeAnnotation - The expected type - * @param {import('@typescript/vfs').VirtualTypeScriptEnvironment} env - The environment to validate in - * @returns {boolean} - Whether the tag value is valid + * @param {String} value - The value to be validated. + * @param {String} typeAnnotation - The TypeScript type annotation as a string. + * @param {import('@typescript/vfs').VirtualTypeScriptEnvironment} env - The environment containing the language service and file creation utilities. + * @throws Will throw an error if the value does not conform to the typeAnnotation. + * @returns {true} if validation passes without type errors. */ export function validateTag(value, typeAnnotation, env) { - + const virtualFileName = '/___virtual__.ts'; const sourceText = `let a: ${typeAnnotation} = ${value};`; - env.createFile('/___virtual__.ts', sourceText); - // Get the program and check for semantic errors - const program = env.languageService.getProgram(); - const errors = program.getSemanticDiagnostics(); + // Create or overwrite the virtual file with the new source text + env.createFile(virtualFileName, sourceText); + + // Retrieve the language service from the environment + const languageService = env.languageService; - // Filter against the type errors we're concerned with - const typeErrors = errors.filter(error => error.category === 1 && error.code === 2322); + // Fetch semantic diagnostics only for the virtual file + const errors = languageService.getSemanticDiagnostics(virtualFileName); - // return first error - const typeError = typeErrors[0]; + // Filter for type assignment errors (Error Code 2322: Type 'X' is not assignable to type 'Y') + const typeErrors = errors.filter( + error => error.code === 2322 && error.category === DiagnosticCategory.Error + ); - if (typeError) { - throw new Error(`${typeError.messageText}`); + // If any type error is found, throw an error with the diagnostic message + if (typeErrors.length > 0) { + const errorMessage = typeErrors[0].messageText.toString(); + throw new Error(`Type Validation Error: ${errorMessage}`); } + // If no type errors are found, return true indicating successful validation return true; } diff --git a/src/utils/ts-utils.js b/src/utils/ts-utils.js index 4a54fef..392726f 100644 --- a/src/utils/ts-utils.js +++ b/src/utils/ts-utils.js @@ -486,6 +486,142 @@ export const parseBooleanNode = (node) => { return node.kind === ts.SyntaxKind.TrueKeyword; }; +function resolveIdentifier(node, typeChecker) { + const symbol = typeChecker.getSymbolAtLocation(node); + if (symbol && symbol.declarations) { + for (const declaration of symbol.declarations) { + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + return getLiteralValue(declaration.initializer, typeChecker); + } + // Handle other kinds of declarations if needed + } + } + return undefined; +} + +/** + * Resolve the value of a property access expression. Limited to simple cases like + * object literals and variable declarations. + * + * @param {import('typescript').Node} node - The property access expression node + * @param {import('typescript')} typeChecker - The TypeScript type checker + * @returns {any} - The resolved value of the property access + */ +const resolvePropertyAccess = (node, typeChecker) => { + const symbol = typeChecker.getSymbolAtLocation(node); + if (symbol && symbol.declarations) { + for (const declaration of symbol.declarations) { + if (ts.isPropertyAssignment(declaration) && declaration.initializer) { + return getLiteralValue(declaration.initializer, typeChecker); + } + + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + return getLiteralValue(declaration.initializer, typeChecker); + } + + if(ts.isEnumMember(declaration)) { + return declaration.initializer ? getLiteralValue(declaration.initializer, typeChecker) : declaration.name.getText() + } + + } + } + + // If symbol not found directly, attempt to resolve the object first + const objValue = getLiteralValue(node.expression, typeChecker); + if (objValue && typeof objValue === 'object') { + const propName = node.name.text; + return objValue[propName]; + } + + return undefined; +}; + +/** + * Evaluates unary prefixes like +, -, !, ~, and returns the result. + * @param {import('typescript').Node} node - The AST node to evaluate + * @param {import('typescript').TypeChecker} typeChecker - The TypeScript type checker + * @returns {number | boolean | undefined} - The result of the evaluation + */ +const evaluatePrefixUnaryExpression = (node, typeChecker) => { + const operandValue = getLiteralValue(node.operand, typeChecker); + if (operandValue !== undefined) { + switch (node.operator) { + case ts.SyntaxKind.PlusToken: + return +operandValue; + case ts.SyntaxKind.MinusToken: + return -operandValue; + case ts.SyntaxKind.ExclamationToken: + return !operandValue; + case ts.SyntaxKind.TildeToken: + return ~operandValue; + } + } + return undefined; +}; + +function handleObjectLiteral(node, typeChecker) { + const obj = {}; + node.properties.forEach((prop) => { + if (ts.isPropertyAssignment(prop)) { + const key = prop.name.getText(); + const value = getLiteralValue(prop.initializer, typeChecker); + obj[key] = value; + } else if (ts.isShorthandPropertyAssignment(prop)) { + const key = prop.name.getText(); + const value = resolveIdentifier(prop.name, typeChecker); + obj[key] = value; + } + }); + return obj; +} + +/** + * Attempts to extract a literal value from a TypeScript node. This function + * supports various types of literals and expressions, including object literals, + * array literals, identifiers, and unary expressions. + * + * @param {import('typescript').Node} node - The AST node to evaluate + * @param {import('typescript').TypeChecker} typeChecker - The TypeScript type checker + * @returns {any} - The extracted literal value + */ +export function getLiteralValue(node, typeChecker) { + if (!node) return undefined; + + if (ts.isLiteralExpression(node) || ts.isBooleanLiteral(node)) { + if (ts.isStringLiteral(node)) { + return node.text; + } + if (ts.isNumericLiteral(node)) { + return Number(node.text); + } + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } + if (node.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } + } + + switch (node.kind) { + case ts.SyntaxKind.NullKeyword: + return null; + case ts.SyntaxKind.ArrayLiteralExpression: + return (node).elements.map(element => getLiteralValue(element, typeChecker)); + case ts.SyntaxKind.ObjectLiteralExpression: + return handleObjectLiteral(node, typeChecker); + case ts.SyntaxKind.Identifier: + return resolveIdentifier(node, typeChecker); + case ts.SyntaxKind.PropertyAccessExpression: + return resolvePropertyAccess(node, typeChecker); + case ts.SyntaxKind.ParenthesizedExpression: + return getLiteralValue((node).expression, typeChecker); + case ts.SyntaxKind.PrefixUnaryExpression: + return evaluatePrefixUnaryExpression(node, typeChecker); + default: + return undefined; + } +} + /** * If the given node is a string literal, returns the parsed string. * @param {import('typescript').Node} node - The node to check diff --git a/test/fixtures/enum.valid.js b/test/fixtures/enum.valid.js index 3a14f0f..89a3bd8 100644 --- a/test/fixtures/enum.valid.js +++ b/test/fixtures/enum.valid.js @@ -4,15 +4,14 @@ import { Script } from 'playcanvas'; * @enum {number} */ const NumberEnum = { - A: 0, - B: 1, - C: 2 + A: 13, + B: 14, + C: 23 }; /** * @enum {string} */ -// eslint-disable-next-line const StringEnum = { A: 'a', B: 'b', @@ -34,7 +33,7 @@ class Example extends Script { * @attribute * @type {NumberEnum} */ - e; + e = NumberEnum.A; /** * @attribute diff --git a/test/fixtures/enum.valid.ts b/test/fixtures/enum.valid.ts index a721a07..a110c22 100644 --- a/test/fixtures/enum.valid.ts +++ b/test/fixtures/enum.valid.ts @@ -1,9 +1,9 @@ import { Script, Vec3 } from 'playcanvas'; enum NumberEnum { - A = 0, - B = 1, - C = 2 + A = 13, + B = 14, + C = 23 }; enum StringEnum { @@ -22,7 +22,7 @@ class Example extends Script { /** * @attribute */ - e : NumberEnum; + e : NumberEnum = NumberEnum.A; /** * @attribute diff --git a/test/fixtures/program.valid.js b/test/fixtures/program.valid.js index 2cb91ed..6ff593d 100644 --- a/test/fixtures/program.valid.js +++ b/test/fixtures/program.valid.js @@ -8,13 +8,17 @@ export const MyEnum = { value: 0 }; class Example extends Script { /** * @attribute - * @type {boolean} + * @precision 1 + * @type {number} */ - a = false; + a; initialize() { confetti(); new TWEEN.Tween({ x: 0 }).to({ x: 100 }, 1000).start(); + + // This is an intentional type error, but the parser should ignore these + this.a = 'string'; } } diff --git a/test/tests/valid/enum.test.js b/test/tests/valid/enum.test.js index 6c36021..f91bbcb 100644 --- a/test/tests/valid/enum.test.js +++ b/test/tests/valid/enum.test.js @@ -31,7 +31,7 @@ function runTests(fileName) { expect(data[0].example.attributes.e.name).to.equal('e'); expect(data[0].example.attributes.e.type).to.equal('number'); expect(data[0].example.attributes.e.array).to.equal(false); - expect(data[0].example.attributes.e.default).to.equal(0); + expect(data[0].example.attributes.e.default).to.equal(13); }); it('f: should be a enum attribute with a default value', function () { @@ -56,6 +56,10 @@ function runTests(fileName) { expect(data[0].example.attributes.h.name).to.equal('h'); expect(data[0].example.attributes.h.type).to.equal('string'); expect(data[0].example.attributes.h.array).to.equal(false); + expect(data[0].example.attributes.h.enum).to.be.an('array').with.lengthOf(3); + expect(data[0].example.attributes.h.enum[0]).to.deep.equal({ A: 'a' }); + expect(data[0].example.attributes.h.enum[1]).to.deep.equal({ B: 'b' }); + expect(data[0].example.attributes.h.enum[2]).to.deep.equal({ C: 'c' }); expect(data[0].example.attributes.h.default).to.equal(''); }); diff --git a/test/tests/valid/program.test.js b/test/tests/valid/program.test.js index 427b4d7..0d03ea3 100644 --- a/test/tests/valid/program.test.js +++ b/test/tests/valid/program.test.js @@ -13,5 +13,6 @@ describe('VALID: Program ', function () { expect(data).to.exist; expect(data[0]).to.not.be.empty; expect(data[1]).to.be.empty; + expect(data[0].example.errors).to.be.empty; }); });