diff --git a/package.json b/package.json index 93f53d6..a1ba511 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@types/jest": "^23.3.9", "@types/lodash.isplainobject": "^4.0.4", "@types/lodash.unescape": "^4.0.4", + "@types/node": "^10.12.2", "@types/semver": "^5.5.0", "@types/shelljs": "^0.8.0", "babel-code-frame": "6.26.0", diff --git a/src/ast-converter.ts b/src/ast-converter.ts index f46ca74..04df089 100644 --- a/src/ast-converter.ts +++ b/src/ast-converter.ts @@ -5,9 +5,10 @@ * @copyright jQuery Foundation and other contributors, https://jquery.org/ * MIT License */ -import { convert } from './convert'; +import convert, { getASTMaps, resetASTMaps } from './convert'; import { convertComments } from './convert-comments'; import nodeUtils from './node-utils'; +import ts from 'typescript'; import { Extra } from './temp-types-based-on-js-source'; /** @@ -23,13 +24,17 @@ function convertError(error: any) { ); } -export default (ast: any, extra: Extra) => { +export default ( + ast: ts.SourceFile, + extra: Extra, + shouldProvideParserServices: boolean +) => { /** * The TypeScript compiler produced fundamental parse errors when parsing the * source. */ - if (ast.parseDiagnostics.length) { - throw convertError(ast.parseDiagnostics[0]); + if ((ast as any).parseDiagnostics.length) { + throw convertError((ast as any).parseDiagnostics[0]); } /** @@ -41,7 +46,8 @@ export default (ast: any, extra: Extra) => { ast, additionalOptions: { errorOnUnknownASTType: extra.errorOnUnknownASTType || false, - useJSXTextNode: extra.useJSXTextNode || false + useJSXTextNode: extra.useJSXTextNode || false, + shouldProvideParserServices } }); @@ -59,5 +65,11 @@ export default (ast: any, extra: Extra) => { estree.comments = convertComments(ast, extra.code); } - return estree; + let astMaps = undefined; + if (shouldProvideParserServices) { + astMaps = getASTMaps(); + resetASTMaps(); + } + + return { estree, astMaps }; }; diff --git a/src/convert.ts b/src/convert.ts index 13ead9d..ce612fd 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -12,6 +12,31 @@ import { ESTreeNode } from './temp-types-based-on-js-source'; const SyntaxKind = ts.SyntaxKind; +let esTreeNodeToTSNodeMap = new WeakMap(); +let tsNodeToESTreeNodeMap = new WeakMap(); + +export function resetASTMaps() { + esTreeNodeToTSNodeMap = new WeakMap(); + tsNodeToESTreeNodeMap = new WeakMap(); +} + +export function getASTMaps() { + return { esTreeNodeToTSNodeMap, tsNodeToESTreeNodeMap }; +} + +interface ConvertAdditionalOptions { + errorOnUnknownASTType: boolean; + useJSXTextNode: boolean; + shouldProvideParserServices: boolean; +} + +interface ConvertConfig { + node: ts.Node; + parent?: ts.Node | null; + ast: ts.SourceFile; + additionalOptions: ConvertAdditionalOptions; +} + /** * Converts a TypeScript node into an ESTree node * @param {Object} config configuration options for the conversion @@ -21,7 +46,7 @@ const SyntaxKind = ts.SyntaxKind; * @param {Object} config.additionalOptions additional options for the conversion * @returns {ESTreeNode|null} the converted ESTreeNode */ -export function convert(config: any): ESTreeNode | null { +export default function convert(config: ConvertConfig): ESTreeNode | null { const node = config.node as ts.Node; const parent = config.parent; const ast = config.ast; @@ -39,7 +64,7 @@ export function convert(config: any): ESTreeNode | null { */ let result: Partial = { type: '', - range: [node.getStart(), node.end], + range: [node.getStart(ast), node.end], loc: nodeUtils.getLoc(node, ast) }; @@ -108,8 +133,12 @@ export function convert(config: any): ESTreeNode | null { typeArgumentsParent.kind === SyntaxKind.TypeReference) ) { const lastTypeArgument = typeArguments[typeArguments.length - 1]; - const greaterThanToken = nodeUtils.findNextToken(lastTypeArgument, ast); - end = greaterThanToken.end; + const greaterThanToken = nodeUtils.findNextToken( + lastTypeArgument, + ast, + ast + ); + end = greaterThanToken!.end; } } return { @@ -120,7 +149,7 @@ export function convert(config: any): ESTreeNode | null { if (nodeUtils.isTypeKeyword(typeArgument.kind)) { return { type: AST_NODE_TYPES[`TS${SyntaxKind[typeArgument.kind]}`], - range: [typeArgument.getStart(), typeArgument.getEnd()], + range: [typeArgument.getStart(ast), typeArgument.getEnd()], loc: nodeUtils.getLoc(typeArgument, ast) }; } @@ -134,7 +163,7 @@ export function convert(config: any): ESTreeNode | null { } return { type: AST_NODE_TYPES.TSTypeReference, - range: [typeArgument.getStart(), typeArgument.getEnd()], + range: [typeArgument.getStart(ast), typeArgument.getEnd()], loc: nodeUtils.getLoc(typeArgument, ast), typeName: convertChild(typeArgument.typeName || typeArgument), typeParameters: typeArgument.typeArguments @@ -156,14 +185,18 @@ export function convert(config: any): ESTreeNode | null { const firstTypeParameter = typeParameters[0]; const lastTypeParameter = typeParameters[typeParameters.length - 1]; - const greaterThanToken = nodeUtils.findNextToken(lastTypeParameter, ast); + const greaterThanToken = nodeUtils.findNextToken( + lastTypeParameter, + ast, + ast + ); return { type: AST_NODE_TYPES.TSTypeParameterDeclaration, - range: [firstTypeParameter.pos - 1, greaterThanToken.end], + range: [firstTypeParameter.pos - 1, greaterThanToken!.end], loc: nodeUtils.getLocFor( firstTypeParameter.pos - 1, - greaterThanToken.end, + greaterThanToken!.end, ast ), params: typeParameters.map(typeParameter => { @@ -189,7 +222,7 @@ export function convert(config: any): ESTreeNode | null { return { type: AST_NODE_TYPES.TSTypeParameter, - range: [typeParameter.getStart(), typeParameter.getEnd()], + range: [typeParameter.getStart(ast), typeParameter.getEnd()], loc: nodeUtils.getLoc(typeParameter, ast), name, constraint, @@ -258,7 +291,7 @@ export function convert(config: any): ESTreeNode | null { const expression = convertChild(decorator.expression); return { type: AST_NODE_TYPES.Decorator, - range: [decorator.getStart(), decorator.end], + range: [decorator.getStart(ast), decorator.end], loc: nodeUtils.getLoc(decorator, ast), expression }; @@ -336,8 +369,10 @@ export function convert(config: any): ESTreeNode | null { (result as any)[key] = (node as any)[key].map(convertChild); } else if ( (node as any)[key] && - typeof (node as any)[key] === 'object' + typeof (node as any)[key] === 'object' && + (node as any)[key].kind ) { + // need to check node[key].kind to ensure we don't try to convert a symbol (result as any)[key] = convertChild((node as any)[key]); } else { (result as any)[key] = (node as any)[key]; @@ -475,7 +510,7 @@ export function convert(config: any): ESTreeNode | null { (result as any).range[1] = (node as any).endOfFileToken.end; result.loc = nodeUtils.getLocFor( - node.getStart(), + node.getStart(ast), (result as any).range[1], ast ); @@ -873,7 +908,7 @@ export function convert(config: any): ESTreeNode | null { } case SyntaxKind.ComputedPropertyName: - if (parent.kind === SyntaxKind.ObjectLiteralExpression) { + if (parent!.kind === SyntaxKind.ObjectLiteralExpression) { Object.assign(result, { type: AST_NODE_TYPES.Property, key: convertChild((node as any).name), @@ -949,11 +984,12 @@ export function convert(config: any): ESTreeNode | null { return false; } return nodeUtils.getTextForTokenKind(token.kind) === '('; - } + }, + ast ); const methodLoc = ast.getLineAndCharacterOfPosition( - (openingParen as any).getStart() + (openingParen as any).getStart(ast) ), nodeIsMethod = node.kind === SyntaxKind.MethodDeclaration, method = { @@ -977,7 +1013,7 @@ export function convert(config: any): ESTreeNode | null { (method as any).returnType = convertTypeAnnotation((node as any).type); } - if (parent.kind === SyntaxKind.ObjectLiteralExpression) { + if (parent!.kind === SyntaxKind.ObjectLiteralExpression) { (method as any).params = (node as any).parameters.map(convertChild); Object.assign(result, { @@ -1063,7 +1099,7 @@ export function convert(config: any): ESTreeNode | null { node ), firstConstructorToken = constructorIsStatic - ? nodeUtils.findNextToken((node as any).getFirstToken(), ast) + ? nodeUtils.findNextToken((node as any).getFirstToken(), ast, ast) : node.getFirstToken(), constructorLoc = ast.getLineAndCharacterOfPosition( (node as any).parameters.pos - 1 @@ -1087,10 +1123,10 @@ export function convert(config: any): ESTreeNode | null { }; const constructorIdentifierLocStart = ast.getLineAndCharacterOfPosition( - (firstConstructorToken as any).getStart() + (firstConstructorToken as any).getStart(ast) ), constructorIdentifierLocEnd = ast.getLineAndCharacterOfPosition( - (firstConstructorToken as any).getEnd() + (firstConstructorToken as any).getEnd(ast) ), constructorIsComputed = !!(node as any).name && @@ -1104,7 +1140,7 @@ export function convert(config: any): ESTreeNode | null { value: 'constructor', raw: (node as any).name.getText(), range: [ - (firstConstructorToken as any).getStart(), + (firstConstructorToken as any).getStart(ast), (firstConstructorToken as any).end ], loc: { @@ -1123,7 +1159,7 @@ export function convert(config: any): ESTreeNode | null { type: AST_NODE_TYPES.Identifier, name: 'constructor', range: [ - (firstConstructorToken as any).getStart(), + (firstConstructorToken as any).getStart(ast), (firstConstructorToken as any).end ], loc: { @@ -1210,7 +1246,7 @@ export function convert(config: any): ESTreeNode | null { break; case SyntaxKind.BindingElement: - if (parent.kind === SyntaxKind.ArrayBindingPattern) { + if (parent!.kind === SyntaxKind.ArrayBindingPattern) { const arrayItem = convert({ node: (node as any).name, parent, @@ -1232,7 +1268,7 @@ export function convert(config: any): ESTreeNode | null { } else { return arrayItem; } - } else if (parent.kind === SyntaxKind.ObjectBindingPattern) { + } else if (parent!.kind === SyntaxKind.ObjectBindingPattern) { if ((node as any).dotDotDotToken) { Object.assign(result, { type: AST_NODE_TYPES.RestElement, @@ -1262,11 +1298,11 @@ export function convert(config: any): ESTreeNode | null { left: convertChild((node as any).name), right: convertChild((node as any).initializer), range: [ - (node as any).name.getStart(), + (node as any).name.getStart(ast), (node as any).initializer.end ], loc: nodeUtils.getLocFor( - (node as any).name.getStart(), + (node as any).name.getStart(ast), (node as any).initializer.end, ast ) @@ -1323,7 +1359,7 @@ export function convert(config: any): ESTreeNode | null { { type: AST_NODE_TYPES.TemplateElement, value: { - raw: ast.text.slice(node.getStart() + 1, node.end - 1), + raw: ast.text.slice(node.getStart(ast) + 1, node.end - 1), cooked: (node as any).text }, tail: true, @@ -1366,7 +1402,10 @@ export function convert(config: any): ESTreeNode | null { Object.assign(result, { type: AST_NODE_TYPES.TemplateElement, value: { - raw: ast.text.slice(node.getStart() + 1, node.end - (tail ? 1 : 2)), + raw: ast.text.slice( + node.getStart(ast) + 1, + node.end - (tail ? 1 : 2) + ), cooked: (node as any).text }, tail @@ -1459,7 +1498,7 @@ export function convert(config: any): ESTreeNode | null { if (node.modifiers) { return { type: AST_NODE_TYPES.TSParameterProperty, - range: [node.getStart(), node.end], + range: [node.getStart(ast), node.end], loc: nodeUtils.getLoc(node, ast), accessibility: nodeUtils.getTSNodeAccessibility(node) || undefined, readonly: @@ -1493,7 +1532,7 @@ export function convert(config: any): ESTreeNode | null { ]; if (!lastClassToken || lastTypeParameter.pos > lastClassToken.pos) { - lastClassToken = nodeUtils.findNextToken(lastTypeParameter, ast); + lastClassToken = nodeUtils.findNextToken(lastTypeParameter, ast, ast); } result.typeParameters = convertTSTypeParametersToTypeParametersDeclaration( (node as any).typeParameters @@ -1517,14 +1556,14 @@ export function convert(config: any): ESTreeNode | null { const lastModifier = node.modifiers[node.modifiers.length - 1]; if (!lastClassToken || lastModifier.pos > lastClassToken.pos) { - lastClassToken = nodeUtils.findNextToken(lastModifier, ast); + lastClassToken = nodeUtils.findNextToken(lastModifier, ast, ast); } } else if (!lastClassToken) { // no name lastClassToken = node.getFirstToken(); } - const openBrace = nodeUtils.findNextToken(lastClassToken, ast); + const openBrace = nodeUtils.findNextToken(lastClassToken, ast, ast)!; const superClass = heritageClauses.find( (clause: any) => clause.token === SyntaxKind.ExtendsKeyword ); @@ -1557,8 +1596,8 @@ export function convert(config: any): ESTreeNode | null { body: [], // TODO: Fix location info - range: [openBrace.getStart(), (result as any).range[1]], - loc: nodeUtils.getLocFor(openBrace.getStart(), node.end, ast) + range: [openBrace.getStart(ast), (result as any).range[1]], + loc: nodeUtils.getLocFor(openBrace.getStart(ast), node.end, ast) }, superClass: superClass && superClass.types[0] @@ -1849,7 +1888,7 @@ export function convert(config: any): ESTreeNode | null { break; case SyntaxKind.PropertyAccessExpression: - if (nodeUtils.isJSXToken(parent)) { + if (nodeUtils.isJSXToken(parent!)) { const jsxMemberExpression = { type: AST_NODE_TYPES.MemberExpression, object: convertChild((node as any).expression), @@ -1948,7 +1987,7 @@ export function convert(config: any): ESTreeNode | null { type: AST_NODE_TYPES.Literal, raw: ast.text.slice((result as any).range[0], (result as any).range[1]) }); - if (parent.name && parent.name === node) { + if ((parent as any).name && (parent as any).name === node) { (result as any).value = (node as any).text; } else { (result as any).value = nodeUtils.unescapeStringLiteralText( @@ -2216,7 +2255,7 @@ export function convert(config: any): ESTreeNode | null { type: AST_NODE_TYPES.VariableDeclarator, id: convertChild((node as any).name), init: convertChild((node as any).type), - range: [(node as any).name.getStart(), (node as any).end] + range: [(node as any).name.getStart(ast), (node as any).end] }; (typeAliasDeclarator as any).loc = nodeUtils.getLocFor( @@ -2359,6 +2398,7 @@ export function convert(config: any): ESTreeNode | null { ) { interfaceLastClassToken = nodeUtils.findNextToken( interfaceLastTypeParameter, + ast, ast ); } @@ -2374,14 +2414,19 @@ export function convert(config: any): ESTreeNode | null { ); const interfaceOpenBrace = nodeUtils.findNextToken( interfaceLastClassToken, + ast, ast - ); + )!; const interfaceBody = { type: AST_NODE_TYPES.TSInterfaceBody, body: (node as any).members.map((member: any) => convertChild(member)), - range: [interfaceOpenBrace.getStart(), (result as any).range[1]], - loc: nodeUtils.getLocFor(interfaceOpenBrace.getStart(), node.end, ast) + range: [interfaceOpenBrace.getStart(ast), (result as any).range[1]], + loc: nodeUtils.getLocFor( + interfaceOpenBrace.getStart(ast), + node.end, + ast + ) }; Object.assign(result, { @@ -2492,5 +2537,10 @@ export function convert(config: any): ESTreeNode | null { deeplyCopy(); } + if (additionalOptions.shouldProvideParserServices) { + tsNodeToESTreeNodeMap.set(node, result); + esTreeNodeToTSNodeMap.set(result, node); + } + return result as any; } diff --git a/src/node-utils.ts b/src/node-utils.ts index 89618cd..873db9c 100644 --- a/src/node-utils.ts +++ b/src/node-utils.ts @@ -160,7 +160,8 @@ export default { isTypeKeyword, isComment, isJSDocComment, - createError + createError, + firstDefined }; /** @@ -296,7 +297,7 @@ function getLoc( nodeOrToken: ts.Node | ts.Token, ast: ts.SourceFile ): ESTreeNodeLoc { - return getLocFor(nodeOrToken.getStart(), nodeOrToken.end, ast); + return getLocFor(nodeOrToken.getStart(ast), nodeOrToken.end, ast); } /** @@ -406,18 +407,35 @@ function hasStaticModifierFlag(node: ts.Node): boolean { /** * Finds the next token based on the previous one and its parent - * @param {ts.Token} previousToken The previous ts.Token - * @param {ts.Node} parent The parent ts.Node + * Had to copy this from TS instead of using TS's version because theirs doesn't pass the ast to getChildren + * @param {ts.Token} previousToken The previous TSToken + * @param {ts.Node} parent The parent TSNode + * @param {ts.SourceFile} ast The TS AST * @returns {ts.Token} the next TSToken */ function findNextToken( previousToken: ts.Token, - parent: ts.Node -): ts.Token { - /** - * TODO: Remove dependency on private TypeScript method - */ - return (ts as any).findNextToken(previousToken, parent); + parent: ts.Node, + ast: ts.SourceFile +): ts.Token | undefined { + return find(parent); + + function find(n: ts.Node): ts.Token | undefined { + if (ts.isToken(n) && n.pos === previousToken.end) { + // this is token that starts at the end of previous token - return it + return n; + } + return firstDefined(n.getChildren(ast), (child: ts.Node) => { + const shouldDiveInChildNode = + // previous token is enclosed somewhere in the child + (child.pos <= previousToken.pos && child.end > previousToken.end) || + // previous token ends exactly at the beginning of child + child.pos === previousToken.end; + return shouldDiveInChildNode && nodeHasTokens(child, ast) + ? find(child) + : undefined; + }); + } } /** @@ -425,18 +443,20 @@ function findNextToken( * @param {ts.Token} previousToken The previous ts.Token * @param {ts.Node} parent The parent ts.Node * @param {Function} predicate The predicate function to apply to each checked token + * @param {ts.SourceFile} ast The TS AST * @returns {ts.Token|undefined} a matching ts.Token */ function findFirstMatchingToken( - previousToken: ts.Token, + previousToken: ts.Token | undefined, parent: ts.Node, - predicate: (node: ts.Node) => boolean + predicate: (node: ts.Node) => boolean, + ast: ts.SourceFile ): ts.Token | undefined { while (previousToken) { if (predicate(previousToken)) { return previousToken; } - previousToken = findNextToken(previousToken, parent); + previousToken = findNextToken(previousToken, parent, ast); } return undefined; } @@ -555,9 +575,9 @@ function fixExports( lastModifier = node.modifiers[node.modifiers.length - 1], declarationIsDefault = nextModifier && nextModifier.kind === SyntaxKind.DefaultKeyword, - varToken = findNextToken(lastModifier, ast); + varToken = findNextToken(lastModifier, ast, ast); - result.range[0] = varToken.getStart(); + result.range[0] = varToken!.getStart(ast); result.loc = getLocFor(result.range[0], result.range[1], ast); const declarationType = declarationIsDefault @@ -567,8 +587,8 @@ function fixExports( const newResult: any = { type: declarationType, declaration: result, - range: [exportKeyword.getStart(), result.range[1]], - loc: getLocFor(exportKeyword.getStart(), result.range[1], ast) + range: [exportKeyword.getStart(ast), result.range[1]], + loc: getLocFor(exportKeyword.getStart(ast), result.range[1], ast) }; if (!declarationIsDefault) { @@ -699,7 +719,7 @@ function convertToken(token: ts.Token, ast: ts.SourceFile): ESTreeToken { const start = token.kind === SyntaxKind.JsxText ? token.getFullStart() - : token.getStart(), + : token.getStart(ast), end = token.getEnd(), value = ast.text.slice(start, end), newToken: any = { @@ -744,7 +764,7 @@ function convertTokens(ast: ts.SourceFile): ESTreeToken[] { result.push(converted); } } else { - node.getChildren().forEach(walk); + node.getChildren(ast).forEach(walk); } } walk(ast); @@ -802,3 +822,40 @@ function createError(ast: ts.SourceFile, start: number, message: string) { message }; } + +/** + * @param {ts.Node} n the TSNode + * @param {ts.SourceFile} ast the TS AST + */ +function nodeHasTokens(n: ts.Node, ast: ts.SourceFile) { + // If we have a token or node that has a non-zero width, it must have tokens. + // Note: getWidth() does not take trivia into account. + return n.kind === SyntaxKind.EndOfFileToken + ? !!(n as any).jsDoc + : n.getWidth(ast) !== 0; +} + +/** + * Like `forEach`, but suitable for use with numbers and strings (which may be falsy). + * @template T + * @template U + * @param {ReadonlyArray|undefined} array + * @param {(element: T, index: number) => (U|undefined)} callback + * @returns {U|undefined} + */ +function firstDefined( + array: ReadonlyArray | undefined, + callback: (element: T, index: number) => U | undefined +): U | undefined { + if (array === undefined) { + return undefined; + } + + for (let i = 0; i < array.length; i++) { + const result = callback(array[i], i); + if (result !== undefined) { + return result; + } + } + return undefined; +} diff --git a/src/parser.ts b/src/parser.ts index 3d7c5d0..1a48475 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -5,9 +5,11 @@ * @copyright jQuery Foundation and other contributors, https://jquery.org/ * MIT License */ +import calculateProjectParserOptions from './tsconfig-parser'; import semver from 'semver'; import ts from 'typescript'; import convert from './ast-converter'; +import util from './node-utils'; import { Extra, ParserOptions } from './temp-types-based-on-js-source'; const packageJSON = require('../package.json'); @@ -37,11 +39,104 @@ function resetExtra(): void { jsx: false, useJSXTextNode: false, log: console.log, + projects: [], errorOnUnknownASTType: false, - code: '' + code: '', + tsconfigRootDir: process.cwd() }; } +/** + * @param {string} code The code of the file being linted + * @param {Object} options The config object + * @returns {{ast: ts.SourceFile, program: ts.Program} | undefined} If found, returns the source file corresponding to the code and the containing program + */ +function getASTFromProject(code: string, options: ParserOptions) { + return util.firstDefined( + calculateProjectParserOptions(code, options.filePath, extra), + (currentProgram: ts.Program) => { + const ast = currentProgram.getSourceFile(options.filePath); + return ast && { ast, program: currentProgram }; + } + ); +} + +/** + * @param {string} code The code of the file being linted + * @returns {{ast: ts.SourceFile, program: ts.Program}} Returns a new source file and program corresponding to the linted code + */ +function createNewProgram(code: string) { + // Even if jsx option is set in typescript compiler, filename still has to + // contain .tsx file extension + const FILENAME = extra.jsx ? 'estree.tsx' : 'estree.ts'; + + const compilerHost = { + fileExists() { + return true; + }, + getCanonicalFileName() { + return FILENAME; + }, + getCurrentDirectory() { + return ''; + }, + getDirectories() { + return []; + }, + getDefaultLibFileName() { + return 'lib.d.ts'; + }, + + // TODO: Support Windows CRLF + getNewLine() { + return '\n'; + }, + getSourceFile(filename: string) { + return ts.createSourceFile(filename, code, ts.ScriptTarget.Latest, true); + }, + readFile() { + return undefined; + }, + useCaseSensitiveFileNames() { + return true; + }, + writeFile() { + return null; + } + }; + + const program = ts.createProgram( + [FILENAME], + { + noResolve: true, + target: ts.ScriptTarget.Latest, + jsx: extra.jsx ? ts.JsxEmit.Preserve : undefined + }, + compilerHost + ); + + const ast = program.getSourceFile(FILENAME)!; + + return { ast, program }; +} + +/** + * @param {string} code The code of the file being linted + * @param {Object} options The config object + * @param {boolean} shouldProvideParserServices True iff the program should be attempted to be calculated from provided tsconfig files + * @returns {{ast: ts.SourceFile, program: ts.Program}} Returns a source file and program corresponding to the linted code + */ +function getProgramAndAST( + code: string, + options: ParserOptions, + shouldProvideParserServices: boolean +) { + return ( + (shouldProvideParserServices && getASTFromProject(code, options)) || + createNewProgram(code) + ); +} + //------------------------------------------------------------------------------ // Parser //------------------------------------------------------------------------------ @@ -49,10 +144,15 @@ function resetExtra(): void { /** * Parses the given source code to produce a valid AST * @param {string} code TypeScript code + * @param {boolean} shouldGenerateServices Flag determining whether to generate ast maps and program or not * @param {ParserOptions} options configuration object for the parser * @returns {Object} the AST */ -function generateAST(code: string, options: ParserOptions): any { +function generateAST( + code: string, + options: ParserOptions, + shouldGenerateServices = false +): any { const toString = String; if (typeof code !== 'string' && !((code as any) instanceof String)) { @@ -101,6 +201,19 @@ function generateAST(code: string, options: ParserOptions): any { } else if (options.loggerFn === false) { extra.log = Function.prototype; } + + if (typeof options.project === 'string') { + extra.projects = [options.project]; + } else if ( + Array.isArray(options.project) && + options.project.every(projectPath => typeof projectPath === 'string') + ) { + extra.projects = options.project; + } + + if (typeof options.tsconfigRootDir === 'string') { + extra.tsconfigRootDir = options.tsconfigRootDir; + } } if (!isRunningSupportedTypeScriptVersion && !warnedAboutTSVersion) { @@ -118,59 +231,23 @@ function generateAST(code: string, options: ParserOptions): any { warnedAboutTSVersion = true; } - // Even if jsx option is set in typescript compiler, filename still has to - // contain .tsx file extension - const FILENAME = extra.jsx ? 'estree.tsx' : 'estree.ts'; - - const compilerHost = { - fileExists() { - return true; - }, - getCanonicalFileName() { - return FILENAME; - }, - getCurrentDirectory() { - return ''; - }, - getDefaultLibFileName() { - return 'lib.d.ts'; - }, - - // TODO: Support Windows CRLF - getNewLine() { - return '\n'; - }, - getSourceFile(filename: string) { - return ts.createSourceFile(filename, code, ts.ScriptTarget.Latest, true); - }, - readFile() { - return undefined; - }, - useCaseSensitiveFileNames() { - return true; - }, - writeFile() { - return null; - }, - getDirectories() { - return []; - } - }; - - const program = ts.createProgram( - [FILENAME], - { - noResolve: true, - target: ts.ScriptTarget.Latest, - jsx: extra.jsx ? ts.JsxEmit.Preserve : undefined - }, - compilerHost + const shouldProvideParserServices = + shouldGenerateServices && extra.projects && extra.projects.length > 0; + const { ast, program } = getProgramAndAST( + code, + options, + shouldProvideParserServices ); - const ast = program.getSourceFile(FILENAME); - extra.code = code; - return convert(ast, extra); + const { estree, astMaps } = convert(ast, extra, shouldProvideParserServices); + return { + estree, + program: shouldProvideParserServices ? program : undefined, + astMaps: shouldProvideParserServices + ? astMaps + : { esTreeNodeToTSNodeMap: undefined, tsNodeToESTreeNodeMap: undefined } + }; } //------------------------------------------------------------------------------ @@ -183,5 +260,17 @@ export { version }; const version = packageJSON.version; export function parse(code: string, options: ParserOptions) { - return generateAST(code, options); + return generateAST(code, options).estree; +} + +export function parseAndGenerateServices(code: string, options: ParserOptions) { + const result = generateAST(code, options, /*shouldGenerateServices*/ true); + return { + ast: result.estree, + services: { + program: result.program, + esTreeNodeToTSNodeMap: result.astMaps.esTreeNodeToTSNodeMap, + tsNodeToESTreeNodeMap: result.astMaps.tsNodeToESTreeNodeMap + } + }; } diff --git a/src/temp-types-based-on-js-source.ts b/src/temp-types-based-on-js-source.ts index 9fe32a5..f1afe55 100644 --- a/src/temp-types-based-on-js-source.ts +++ b/src/temp-types-based-on-js-source.ts @@ -66,6 +66,8 @@ export interface Extra { strict: boolean; jsx: boolean; log: Function; + projects: string[]; + tsconfigRootDir: string; } export interface ParserOptions { @@ -77,4 +79,7 @@ export interface ParserOptions { errorOnUnknownASTType: boolean; useJSXTextNode: boolean; loggerFn: Function | false; + project: string | string[]; + filePath: string; + tsconfigRootDir: string; } diff --git a/src/tsconfig-parser.ts b/src/tsconfig-parser.ts new file mode 100644 index 0000000..63d472f --- /dev/null +++ b/src/tsconfig-parser.ts @@ -0,0 +1,149 @@ +'use strict'; + +import path from 'path'; +import ts from 'typescript'; +import { Extra } from './temp-types-based-on-js-source'; + +//------------------------------------------------------------------------------ +// Environment calculation +//------------------------------------------------------------------------------ + +/** + * Maps tsconfig paths to their corresponding file contents and resulting watches + * @type {Map>} + */ +const knownWatchProgramMap = new Map< + string, + ts.WatchOfConfigFile +>(); + +/** + * Maps file paths to their set of corresponding watch callbacks + * There may be more than one per file if a file is shared between projects + * @type {Map} + */ +const watchCallbackTrackingMap = new Map(); + +/** + * Holds information about the file currently being linted + * @type {{code: string, filePath: string}} + */ +const currentLintOperationState = { + code: '', + filePath: '' +}; + +/** + * Appropriately report issues found when reading a config file + * @param {ts.Diagnostic} diagnostic The diagnostic raised when creating a program + * @returns {void} + */ +function diagnosticReporter(diagnostic: ts.Diagnostic): void { + throw new Error( + ts.flattenDiagnosticMessageText(diagnostic.messageText, ts.sys.newLine) + ); +} + +const noopFileWatcher = { close: () => {} }; + +/** + * Calculate project environments using options provided by consumer and paths from config + * @param {string} code The code being linted + * @param {string} filePath The path of the file being parsed + * @param {string} extra.tsconfigRootDir The root directory for relative tsconfig paths + * @param {string[]} extra.project Provided tsconfig paths + * @returns {ts.Program[]} The programs corresponding to the supplied tsconfig paths + */ +export default function calculateProjectParserOptions( + code: string, + filePath: string, + extra: Extra +): ts.Program[] { + const results = []; + const tsconfigRootDir = extra.tsconfigRootDir; + + // preserve reference to code and file being linted + currentLintOperationState.code = code; + currentLintOperationState.filePath = filePath; + + // Update file version if necessary + // TODO: only update when necessary, currently marks as changed on every lint + const watchCallback = watchCallbackTrackingMap.get(filePath); + if (typeof watchCallback !== 'undefined') { + watchCallback(filePath, ts.FileWatcherEventKind.Changed); + } + + for (let tsconfigPath of extra.projects) { + // if absolute paths aren't provided, make relative to tsconfigRootDir + if (!path.isAbsolute(tsconfigPath)) { + tsconfigPath = path.join(tsconfigRootDir, tsconfigPath); + } + + const existingWatch = knownWatchProgramMap.get(tsconfigPath); + + if (typeof existingWatch !== 'undefined') { + // get new program (updated if necessary) + results.push(existingWatch.getProgram().getProgram()); + continue; + } + + // create compiler host + const watchCompilerHost = ts.createWatchCompilerHost( + tsconfigPath, + /*optionsToExtend*/ undefined, + ts.sys, + ts.createSemanticDiagnosticsBuilderProgram, + diagnosticReporter, + /*reportWatchStatus*/ () => {} + ); + + // ensure readFile reads the code being linted instead of the copy on disk + const oldReadFile = watchCompilerHost.readFile; + watchCompilerHost.readFile = (filePath, encoding) => + path.normalize(filePath) === + path.normalize(currentLintOperationState.filePath) + ? currentLintOperationState.code + : oldReadFile(filePath, encoding); + + // ensure process reports error on failure instead of exiting process immediately + watchCompilerHost.onUnRecoverableConfigFileDiagnostic = diagnosticReporter; + + // ensure process doesn't emit programs + watchCompilerHost.afterProgramCreate = program => { + // report error if there are any errors in the config file + const configFileDiagnostics = program + .getConfigFileParsingDiagnostics() + .filter( + diag => + diag.category === ts.DiagnosticCategory.Error && diag.code !== 18003 + ); + if (configFileDiagnostics.length > 0) { + diagnosticReporter(configFileDiagnostics[0]); + } + }; + + // register callbacks to trigger program updates without using fileWatchers + watchCompilerHost.watchFile = (fileName, callback) => { + const normalizedFileName = path.normalize(fileName); + watchCallbackTrackingMap.set(normalizedFileName, callback); + return { + close: () => { + watchCallbackTrackingMap.delete(normalizedFileName); + } + }; + }; + + // ensure fileWatchers aren't created for directories + watchCompilerHost.watchDirectory = () => noopFileWatcher; + + // create program + const programWatch = ts.createWatchProgram(watchCompilerHost); + const program = programWatch.getProgram().getProgram(); + + // cache watch program and return current program + knownWatchProgramMap.set(tsconfigPath, programWatch); + results.push(program); + } + + return results; +} diff --git a/tests/fixtures/semanticInfo/badTSConfig/app.ts b/tests/fixtures/semanticInfo/badTSConfig/app.ts new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/semanticInfo/badTSConfig/tsconfig.json b/tests/fixtures/semanticInfo/badTSConfig/tsconfig.json new file mode 100644 index 0000000..134439a --- /dev/null +++ b/tests/fixtures/semanticInfo/badTSConfig/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compileOnSave": "hello", + "compilerOptions": { + "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ + "strict": true, /* Enable all strict type-checking options. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + } + } \ No newline at end of file diff --git a/tests/fixtures/semanticInfo/export-file.src.ts b/tests/fixtures/semanticInfo/export-file.src.ts new file mode 100644 index 0000000..8bb4cb8 --- /dev/null +++ b/tests/fixtures/semanticInfo/export-file.src.ts @@ -0,0 +1 @@ +export default [3, 4, 5]; \ No newline at end of file diff --git a/tests/fixtures/semanticInfo/import-file.src.ts b/tests/fixtures/semanticInfo/import-file.src.ts new file mode 100644 index 0000000..da5d202 --- /dev/null +++ b/tests/fixtures/semanticInfo/import-file.src.ts @@ -0,0 +1,2 @@ +import arr from "./export-file.src"; +arr.push(6, 7); \ No newline at end of file diff --git a/tests/fixtures/semanticInfo/isolated-file.src.ts b/tests/fixtures/semanticInfo/isolated-file.src.ts new file mode 100644 index 0000000..ca04667 --- /dev/null +++ b/tests/fixtures/semanticInfo/isolated-file.src.ts @@ -0,0 +1 @@ +const x = [3, 4, 5]; \ No newline at end of file diff --git a/tests/fixtures/semanticInfo/tsconfig.json b/tests/fixtures/semanticInfo/tsconfig.json new file mode 100644 index 0000000..3caa872 --- /dev/null +++ b/tests/fixtures/semanticInfo/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "strict": true, + "esModuleInterop": true + } +} \ No newline at end of file diff --git a/tests/lib/__snapshots__/semanticInfo.ts.snap b/tests/lib/__snapshots__/semanticInfo.ts.snap new file mode 100644 index 0000000..5af1b41 --- /dev/null +++ b/tests/lib/__snapshots__/semanticInfo.ts.snap @@ -0,0 +1,1136 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`semanticInfo fixtures/export-file.src 1`] = ` +Object { + "body": Array [ + Object { + "declaration": Object { + "elements": Array [ + Object { + "loc": Object { + "end": Object { + "column": 17, + "line": 1, + }, + "start": Object { + "column": 16, + "line": 1, + }, + }, + "range": Array [ + 16, + 17, + ], + "raw": "3", + "type": "Literal", + "value": 3, + }, + Object { + "loc": Object { + "end": Object { + "column": 20, + "line": 1, + }, + "start": Object { + "column": 19, + "line": 1, + }, + }, + "range": Array [ + 19, + 20, + ], + "raw": "4", + "type": "Literal", + "value": 4, + }, + Object { + "loc": Object { + "end": Object { + "column": 23, + "line": 1, + }, + "start": Object { + "column": 22, + "line": 1, + }, + }, + "range": Array [ + 22, + 23, + ], + "raw": "5", + "type": "Literal", + "value": 5, + }, + ], + "loc": Object { + "end": Object { + "column": 24, + "line": 1, + }, + "start": Object { + "column": 15, + "line": 1, + }, + }, + "range": Array [ + 15, + 24, + ], + "type": "ArrayExpression", + }, + "loc": Object { + "end": Object { + "column": 25, + "line": 1, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 25, + ], + "type": "ExportDefaultDeclaration", + }, + ], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 25, + "line": 1, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 25, + ], + "sourceType": "module", + "tokens": Array [ + Object { + "loc": Object { + "end": Object { + "column": 6, + "line": 1, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 6, + ], + "type": "Keyword", + "value": "export", + }, + Object { + "loc": Object { + "end": Object { + "column": 14, + "line": 1, + }, + "start": Object { + "column": 7, + "line": 1, + }, + }, + "range": Array [ + 7, + 14, + ], + "type": "Keyword", + "value": "default", + }, + Object { + "loc": Object { + "end": Object { + "column": 16, + "line": 1, + }, + "start": Object { + "column": 15, + "line": 1, + }, + }, + "range": Array [ + 15, + 16, + ], + "type": "Punctuator", + "value": "[", + }, + Object { + "loc": Object { + "end": Object { + "column": 17, + "line": 1, + }, + "start": Object { + "column": 16, + "line": 1, + }, + }, + "range": Array [ + 16, + 17, + ], + "type": "Numeric", + "value": "3", + }, + Object { + "loc": Object { + "end": Object { + "column": 18, + "line": 1, + }, + "start": Object { + "column": 17, + "line": 1, + }, + }, + "range": Array [ + 17, + 18, + ], + "type": "Punctuator", + "value": ",", + }, + Object { + "loc": Object { + "end": Object { + "column": 20, + "line": 1, + }, + "start": Object { + "column": 19, + "line": 1, + }, + }, + "range": Array [ + 19, + 20, + ], + "type": "Numeric", + "value": "4", + }, + Object { + "loc": Object { + "end": Object { + "column": 21, + "line": 1, + }, + "start": Object { + "column": 20, + "line": 1, + }, + }, + "range": Array [ + 20, + 21, + ], + "type": "Punctuator", + "value": ",", + }, + Object { + "loc": Object { + "end": Object { + "column": 23, + "line": 1, + }, + "start": Object { + "column": 22, + "line": 1, + }, + }, + "range": Array [ + 22, + 23, + ], + "type": "Numeric", + "value": "5", + }, + Object { + "loc": Object { + "end": Object { + "column": 24, + "line": 1, + }, + "start": Object { + "column": 23, + "line": 1, + }, + }, + "range": Array [ + 23, + 24, + ], + "type": "Punctuator", + "value": "]", + }, + Object { + "loc": Object { + "end": Object { + "column": 25, + "line": 1, + }, + "start": Object { + "column": 24, + "line": 1, + }, + }, + "range": Array [ + 24, + 25, + ], + "type": "Punctuator", + "value": ";", + }, + ], + "type": "Program", +} +`; + +exports[`semanticInfo fixtures/import-file.src 1`] = ` +Object { + "body": Array [ + Object { + "loc": Object { + "end": Object { + "column": 36, + "line": 1, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 36, + ], + "source": Object { + "loc": Object { + "end": Object { + "column": 35, + "line": 1, + }, + "start": Object { + "column": 16, + "line": 1, + }, + }, + "range": Array [ + 16, + 35, + ], + "raw": "\\"./export-file.src\\"", + "type": "Literal", + "value": "./export-file.src", + }, + "specifiers": Array [ + Object { + "loc": Object { + "end": Object { + "column": 10, + "line": 1, + }, + "start": Object { + "column": 7, + "line": 1, + }, + }, + "local": Object { + "loc": Object { + "end": Object { + "column": 10, + "line": 1, + }, + "start": Object { + "column": 7, + "line": 1, + }, + }, + "name": "arr", + "range": Array [ + 7, + 10, + ], + "type": "Identifier", + }, + "range": Array [ + 7, + 10, + ], + "type": "ImportDefaultSpecifier", + }, + ], + "type": "ImportDeclaration", + }, + Object { + "expression": Object { + "arguments": Array [ + Object { + "loc": Object { + "end": Object { + "column": 10, + "line": 2, + }, + "start": Object { + "column": 9, + "line": 2, + }, + }, + "range": Array [ + 46, + 47, + ], + "raw": "6", + "type": "Literal", + "value": 6, + }, + Object { + "loc": Object { + "end": Object { + "column": 13, + "line": 2, + }, + "start": Object { + "column": 12, + "line": 2, + }, + }, + "range": Array [ + 49, + 50, + ], + "raw": "7", + "type": "Literal", + "value": 7, + }, + ], + "callee": Object { + "computed": false, + "loc": Object { + "end": Object { + "column": 8, + "line": 2, + }, + "start": Object { + "column": 0, + "line": 2, + }, + }, + "object": Object { + "loc": Object { + "end": Object { + "column": 3, + "line": 2, + }, + "start": Object { + "column": 0, + "line": 2, + }, + }, + "name": "arr", + "range": Array [ + 37, + 40, + ], + "type": "Identifier", + }, + "property": Object { + "loc": Object { + "end": Object { + "column": 8, + "line": 2, + }, + "start": Object { + "column": 4, + "line": 2, + }, + }, + "name": "push", + "range": Array [ + 41, + 45, + ], + "type": "Identifier", + }, + "range": Array [ + 37, + 45, + ], + "type": "MemberExpression", + }, + "loc": Object { + "end": Object { + "column": 14, + "line": 2, + }, + "start": Object { + "column": 0, + "line": 2, + }, + }, + "range": Array [ + 37, + 51, + ], + "type": "CallExpression", + }, + "loc": Object { + "end": Object { + "column": 15, + "line": 2, + }, + "start": Object { + "column": 0, + "line": 2, + }, + }, + "range": Array [ + 37, + 52, + ], + "type": "ExpressionStatement", + }, + ], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 15, + "line": 2, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 52, + ], + "sourceType": "module", + "tokens": Array [ + Object { + "loc": Object { + "end": Object { + "column": 6, + "line": 1, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 6, + ], + "type": "Keyword", + "value": "import", + }, + Object { + "loc": Object { + "end": Object { + "column": 10, + "line": 1, + }, + "start": Object { + "column": 7, + "line": 1, + }, + }, + "range": Array [ + 7, + 10, + ], + "type": "Identifier", + "value": "arr", + }, + Object { + "loc": Object { + "end": Object { + "column": 15, + "line": 1, + }, + "start": Object { + "column": 11, + "line": 1, + }, + }, + "range": Array [ + 11, + 15, + ], + "type": "Identifier", + "value": "from", + }, + Object { + "loc": Object { + "end": Object { + "column": 35, + "line": 1, + }, + "start": Object { + "column": 16, + "line": 1, + }, + }, + "range": Array [ + 16, + 35, + ], + "type": "String", + "value": "\\"./export-file.src\\"", + }, + Object { + "loc": Object { + "end": Object { + "column": 36, + "line": 1, + }, + "start": Object { + "column": 35, + "line": 1, + }, + }, + "range": Array [ + 35, + 36, + ], + "type": "Punctuator", + "value": ";", + }, + Object { + "loc": Object { + "end": Object { + "column": 3, + "line": 2, + }, + "start": Object { + "column": 0, + "line": 2, + }, + }, + "range": Array [ + 37, + 40, + ], + "type": "Identifier", + "value": "arr", + }, + Object { + "loc": Object { + "end": Object { + "column": 4, + "line": 2, + }, + "start": Object { + "column": 3, + "line": 2, + }, + }, + "range": Array [ + 40, + 41, + ], + "type": "Punctuator", + "value": ".", + }, + Object { + "loc": Object { + "end": Object { + "column": 8, + "line": 2, + }, + "start": Object { + "column": 4, + "line": 2, + }, + }, + "range": Array [ + 41, + 45, + ], + "type": "Identifier", + "value": "push", + }, + Object { + "loc": Object { + "end": Object { + "column": 9, + "line": 2, + }, + "start": Object { + "column": 8, + "line": 2, + }, + }, + "range": Array [ + 45, + 46, + ], + "type": "Punctuator", + "value": "(", + }, + Object { + "loc": Object { + "end": Object { + "column": 10, + "line": 2, + }, + "start": Object { + "column": 9, + "line": 2, + }, + }, + "range": Array [ + 46, + 47, + ], + "type": "Numeric", + "value": "6", + }, + Object { + "loc": Object { + "end": Object { + "column": 11, + "line": 2, + }, + "start": Object { + "column": 10, + "line": 2, + }, + }, + "range": Array [ + 47, + 48, + ], + "type": "Punctuator", + "value": ",", + }, + Object { + "loc": Object { + "end": Object { + "column": 13, + "line": 2, + }, + "start": Object { + "column": 12, + "line": 2, + }, + }, + "range": Array [ + 49, + 50, + ], + "type": "Numeric", + "value": "7", + }, + Object { + "loc": Object { + "end": Object { + "column": 14, + "line": 2, + }, + "start": Object { + "column": 13, + "line": 2, + }, + }, + "range": Array [ + 50, + 51, + ], + "type": "Punctuator", + "value": ")", + }, + Object { + "loc": Object { + "end": Object { + "column": 15, + "line": 2, + }, + "start": Object { + "column": 14, + "line": 2, + }, + }, + "range": Array [ + 51, + 52, + ], + "type": "Punctuator", + "value": ";", + }, + ], + "type": "Program", +} +`; + +exports[`semanticInfo fixtures/isolated-file.src 1`] = ` +Object { + "body": Array [ + Object { + "declarations": Array [ + Object { + "id": Object { + "loc": Object { + "end": Object { + "column": 7, + "line": 1, + }, + "start": Object { + "column": 6, + "line": 1, + }, + }, + "name": "x", + "range": Array [ + 6, + 7, + ], + "type": "Identifier", + }, + "init": Object { + "elements": Array [ + Object { + "loc": Object { + "end": Object { + "column": 12, + "line": 1, + }, + "start": Object { + "column": 11, + "line": 1, + }, + }, + "range": Array [ + 11, + 12, + ], + "raw": "3", + "type": "Literal", + "value": 3, + }, + Object { + "loc": Object { + "end": Object { + "column": 15, + "line": 1, + }, + "start": Object { + "column": 14, + "line": 1, + }, + }, + "range": Array [ + 14, + 15, + ], + "raw": "4", + "type": "Literal", + "value": 4, + }, + Object { + "loc": Object { + "end": Object { + "column": 18, + "line": 1, + }, + "start": Object { + "column": 17, + "line": 1, + }, + }, + "range": Array [ + 17, + 18, + ], + "raw": "5", + "type": "Literal", + "value": 5, + }, + ], + "loc": Object { + "end": Object { + "column": 19, + "line": 1, + }, + "start": Object { + "column": 10, + "line": 1, + }, + }, + "range": Array [ + 10, + 19, + ], + "type": "ArrayExpression", + }, + "loc": Object { + "end": Object { + "column": 19, + "line": 1, + }, + "start": Object { + "column": 6, + "line": 1, + }, + }, + "range": Array [ + 6, + 19, + ], + "type": "VariableDeclarator", + }, + ], + "kind": "const", + "loc": Object { + "end": Object { + "column": 20, + "line": 1, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 20, + ], + "type": "VariableDeclaration", + }, + ], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 20, + "line": 1, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 20, + ], + "sourceType": "script", + "tokens": Array [ + Object { + "loc": Object { + "end": Object { + "column": 5, + "line": 1, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 5, + ], + "type": "Keyword", + "value": "const", + }, + Object { + "loc": Object { + "end": Object { + "column": 7, + "line": 1, + }, + "start": Object { + "column": 6, + "line": 1, + }, + }, + "range": Array [ + 6, + 7, + ], + "type": "Identifier", + "value": "x", + }, + Object { + "loc": Object { + "end": Object { + "column": 9, + "line": 1, + }, + "start": Object { + "column": 8, + "line": 1, + }, + }, + "range": Array [ + 8, + 9, + ], + "type": "Punctuator", + "value": "=", + }, + Object { + "loc": Object { + "end": Object { + "column": 11, + "line": 1, + }, + "start": Object { + "column": 10, + "line": 1, + }, + }, + "range": Array [ + 10, + 11, + ], + "type": "Punctuator", + "value": "[", + }, + Object { + "loc": Object { + "end": Object { + "column": 12, + "line": 1, + }, + "start": Object { + "column": 11, + "line": 1, + }, + }, + "range": Array [ + 11, + 12, + ], + "type": "Numeric", + "value": "3", + }, + Object { + "loc": Object { + "end": Object { + "column": 13, + "line": 1, + }, + "start": Object { + "column": 12, + "line": 1, + }, + }, + "range": Array [ + 12, + 13, + ], + "type": "Punctuator", + "value": ",", + }, + Object { + "loc": Object { + "end": Object { + "column": 15, + "line": 1, + }, + "start": Object { + "column": 14, + "line": 1, + }, + }, + "range": Array [ + 14, + 15, + ], + "type": "Numeric", + "value": "4", + }, + Object { + "loc": Object { + "end": Object { + "column": 16, + "line": 1, + }, + "start": Object { + "column": 15, + "line": 1, + }, + }, + "range": Array [ + 15, + 16, + ], + "type": "Punctuator", + "value": ",", + }, + Object { + "loc": Object { + "end": Object { + "column": 18, + "line": 1, + }, + "start": Object { + "column": 17, + "line": 1, + }, + }, + "range": Array [ + 17, + 18, + ], + "type": "Numeric", + "value": "5", + }, + Object { + "loc": Object { + "end": Object { + "column": 19, + "line": 1, + }, + "start": Object { + "column": 18, + "line": 1, + }, + }, + "range": Array [ + 18, + 19, + ], + "type": "Punctuator", + "value": "]", + }, + Object { + "loc": Object { + "end": Object { + "column": 20, + "line": 1, + }, + "start": Object { + "column": 19, + "line": 1, + }, + }, + "range": Array [ + 19, + 20, + ], + "type": "Punctuator", + "value": ";", + }, + ], + "type": "Program", +} +`; + +exports[`semanticInfo malformed project file 1`] = `"Compiler option 'compileOnSave' requires a value of type boolean."`; diff --git a/tests/lib/semanticInfo.ts b/tests/lib/semanticInfo.ts new file mode 100644 index 0000000..5dafc92 --- /dev/null +++ b/tests/lib/semanticInfo.ts @@ -0,0 +1,195 @@ +/** + * @fileoverview Tests for semantic information + * @author Benjamin Lichtman + */ + +'use strict'; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +import path from 'path'; +import shelljs from 'shelljs'; +import { + parseCodeAndGenerateServices, + createSnapshotTestBlock +} from '../../tools/test-utils'; +import ts from 'typescript'; +import { ParserOptions } from '../../src/temp-types-based-on-js-source'; + +//------------------------------------------------------------------------------ +// Setup +//------------------------------------------------------------------------------ + +const FIXTURES_DIR = './tests/fixtures/semanticInfo'; + +const testFiles = shelljs + .find(FIXTURES_DIR) + .filter(filename => filename.indexOf('.src.ts') > -1) + // strip off ".src.ts" + .map(filename => + filename.substring(FIXTURES_DIR.length - 1, filename.length - 7) + ); + +function createOptions(fileName: string): ParserOptions & { cwd?: string } { + return { + loc: true, + range: true, + tokens: true, + comment: true, + jsx: false, + useJSXTextNode: false, + errorOnUnknownASTType: true, + filePath: fileName, + tsconfigRootDir: path.join(process.cwd(), FIXTURES_DIR), + project: './tsconfig.json', + loggerFn: false + }; +} + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +describe('semanticInfo', () => { + // test all AST snapshots + testFiles.forEach(filename => { + // Uncomment and fill in filename to focus on a single file + // var filename = "jsx/invalid-matching-placeholder-in-closing-tag"; + const fullFileName = `${path.resolve(FIXTURES_DIR, filename)}.src.ts`; + const code = shelljs.cat(fullFileName); + test( + `fixtures/${filename}.src`, + createSnapshotTestBlock( + code, + createOptions(fullFileName), + /*generateServices*/ true + ) + ); + }); + + // case-specific tests + test('isolated-file tests', () => { + const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); + const parseResult = parseCodeAndGenerateServices( + shelljs.cat(fileName), + createOptions(fileName) + ); + + // get type checker + expect(parseResult).toHaveProperty('services.program.getTypeChecker'); + const checker = parseResult.services.program.getTypeChecker(); + + // get number node (ast shape validated by snapshot) + const arrayMember = + parseResult.ast.body[0].declarations[0].init.elements[0]; + expect(parseResult).toHaveProperty('services.esTreeNodeToTSNodeMap'); + + // get corresponding TS node + const tsArrayMember = parseResult.services.esTreeNodeToTSNodeMap.get( + arrayMember + ); + expect(tsArrayMember).toBeDefined(); + expect(tsArrayMember.kind).toBe(ts.SyntaxKind.NumericLiteral); + expect(tsArrayMember.text).toBe('3'); + + // get type of TS node + const arrayMemberType = checker.getTypeAtLocation(tsArrayMember); + expect(arrayMemberType.flags).toBe(ts.TypeFlags.NumberLiteral); + expect(arrayMemberType.value).toBe(3); + + // make sure it maps back to original ESTree node + expect(parseResult).toHaveProperty('services.tsNodeToESTreeNodeMap'); + expect(parseResult.services.tsNodeToESTreeNodeMap.get(tsArrayMember)).toBe( + arrayMember + ); + + // get bound name + const boundName = parseResult.ast.body[0].declarations[0].id; + expect(boundName.name).toBe('x'); + + const tsBoundName = parseResult.services.esTreeNodeToTSNodeMap.get( + boundName + ); + expect(tsBoundName).toBeDefined(); + + checkNumberArrayType(checker, tsBoundName); + + expect(parseResult.services.tsNodeToESTreeNodeMap.get(tsBoundName)).toBe( + boundName + ); + }); + + test('imported-file tests', () => { + const fileName = path.resolve(FIXTURES_DIR, 'import-file.src.ts'); + const parseResult = parseCodeAndGenerateServices( + shelljs.cat(fileName), + createOptions(fileName) + ); + + // get type checker + expect(parseResult).toHaveProperty('services.program.getTypeChecker'); + const checker = parseResult.services.program.getTypeChecker(); + + // get array node (ast shape validated by snapshot) + // node is defined in other file than the parsed one + const arrayBoundName = parseResult.ast.body[1].expression.callee.object; + expect(arrayBoundName.name).toBe('arr'); + + expect(parseResult).toHaveProperty('services.esTreeNodeToTSNodeMap'); + const tsArrayBoundName = parseResult.services.esTreeNodeToTSNodeMap.get( + arrayBoundName + ); + expect(tsArrayBoundName).toBeDefined(); + checkNumberArrayType(checker, tsArrayBoundName); + + expect( + parseResult.services.tsNodeToESTreeNodeMap.get(tsArrayBoundName) + ).toBe(arrayBoundName); + }); + + test('non-existent project file', () => { + const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); + const badConfig = createOptions(fileName); + badConfig.project = './tsconfigs.json'; + expect(() => + parseCodeAndGenerateServices(shelljs.cat(fileName), badConfig) + ).toThrowError(/File .+tsconfigs\.json' not found/); + }); + + test('fail to read project file', () => { + const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); + const badConfig = createOptions(fileName); + badConfig.project = '.'; + expect(() => + parseCodeAndGenerateServices(shelljs.cat(fileName), badConfig) + ).toThrowError(/File .+semanticInfo' not found/); + }); + + test('malformed project file', () => { + const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); + const badConfig = createOptions(fileName); + badConfig.project = './badTSConfig/tsconfig.json'; + expect(() => + parseCodeAndGenerateServices(shelljs.cat(fileName), badConfig) + ).toThrowErrorMatchingSnapshot(); + }); +}); + +/** + * Verifies that the type of a TS node is number[] as expected + * @param {ts.TypeChecker} checker + * @param {ts.Node} tsNode + */ +function checkNumberArrayType(checker: ts.TypeChecker, tsNode: ts.Node) { + const nodeType = checker.getTypeAtLocation(tsNode); + expect(nodeType.flags).toBe(ts.TypeFlags.Object); + expect((nodeType as ts.ObjectType).objectFlags).toBe( + ts.ObjectFlags.Reference + ); + expect((nodeType as ts.TypeReference).typeArguments).toHaveLength(1); + expect((nodeType as ts.TypeReference).typeArguments![0].flags).toBe( + ts.TypeFlags.Number + ); +} diff --git a/tools/test-utils.ts b/tools/test-utils.ts index 453a8db..d167d49 100644 --- a/tools/test-utils.ts +++ b/tools/test-utils.ts @@ -24,19 +24,32 @@ export function getRaw(ast: any) { ); } +export function parseCodeAndGenerateServices( + code: string, + config: ParserOptions +) { + return parser.parseAndGenerateServices(code, config); +} + /** * Returns a function which can be used as the callback of a Jest test() block, * and which performs an assertion on the snapshot for the given code and config. * @param {string} code The source code to parse * @param {ParserOptions} config the parser configuration - * @returns {Function} callback for Jest it() block + * @returns {jest.ProvidesCallback} callback for Jest it() block */ -export function createSnapshotTestBlock(code: string, config: ParserOptions) { +export function createSnapshotTestBlock( + code: string, + config: ParserOptions, + generateServices?: true +) { /** * @returns {Object} the AST object */ function parse() { - const ast = parser.parse(code, config); + const ast = generateServices + ? parser.parseAndGenerateServices(code, config).ast + : parser.parse(code, config); return getRaw(ast); }