Skip to content

Commit

Permalink
Merge branch 'main' into typescript
Browse files Browse the repository at this point in the history
  • Loading branch information
marklundin committed Sep 26, 2024
2 parents ceb8f9f + 1d01c9e commit 926a780
Show file tree
Hide file tree
Showing 12 changed files with 199 additions and 52 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
20 changes: 4 additions & 16 deletions src/parsers/attribute-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 });
}
});
Expand Down
15 changes: 11 additions & 4 deletions src/parsers/script-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
]);

/**
Expand Down Expand Up @@ -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;

Expand Down
40 changes: 24 additions & 16 deletions src/utils/tag-utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DiagnosticCategory } from 'typescript';

import { parseNumber, parseStringToNumericalArray } from './ts-utils.js';

/**
Expand Down Expand Up @@ -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;
}
136 changes: 136 additions & 0 deletions src/utils/ts-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Check failure on line 521 in src/utils/ts-utils.js

View workflow job for this annotation

GitHub Actions / Unit Test

Trailing spaces not allowed
if(ts.isEnumMember(declaration)) {

Check failure on line 522 in src/utils/ts-utils.js

View workflow job for this annotation

GitHub Actions / Unit Test

Expected space(s) after "if"
return declaration.initializer ? getLiteralValue(declaration.initializer, typeChecker) : declaration.name.getText()

Check failure on line 523 in src/utils/ts-utils.js

View workflow job for this annotation

GitHub Actions / Unit Test

Missing semicolon
}

}
}

// 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
Expand Down
9 changes: 4 additions & 5 deletions test/fixtures/enum.valid.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {

Check failure on line 15 in test/fixtures/enum.valid.js

View workflow job for this annotation

GitHub Actions / Unit Test

'StringEnum' is assigned a value but never used
A: 'a',
B: 'b',
Expand All @@ -34,7 +33,7 @@ class Example extends Script {
* @attribute
* @type {NumberEnum}
*/
e;
e = NumberEnum.A;

/**
* @attribute
Expand Down
8 changes: 4 additions & 4 deletions test/fixtures/enum.valid.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -22,7 +22,7 @@ class Example extends Script {
/**
* @attribute
*/
e : NumberEnum;
e : NumberEnum = NumberEnum.A;

/**
* @attribute
Expand Down
8 changes: 6 additions & 2 deletions test/fixtures/program.valid.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}

Expand Down
Loading

0 comments on commit 926a780

Please sign in to comment.