From 8b3aeb8c43b24ee0210a602a9a716277a83ffb0c Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Mon, 6 Jun 2016 12:38:54 -0700 Subject: [PATCH] Functional extension loading & lint passes --- Jakefile.js | 5 +- src/compiler/checker.ts | 4 + src/compiler/commandLineParser.ts | 6 + src/compiler/core.ts | 20 ++ src/compiler/diagnosticMessages.json | 15 +- src/compiler/program.ts | 219 ++++++++++++- src/compiler/sys.ts | 4 + src/compiler/tsc.ts | 18 +- src/compiler/types.ts | 69 +++- src/compiler/utilities.ts | 13 + src/services/shims.ts | 4 +- tests/cases/unittests/extensionAPI.ts | 455 ++++++++++++++++++++++++++ 12 files changed, 806 insertions(+), 26 deletions(-) create mode 100644 tests/cases/unittests/extensionAPI.ts diff --git a/Jakefile.js b/Jakefile.js index f11d1bf423d2b..9679199807b76 100644 --- a/Jakefile.js +++ b/Jakefile.js @@ -157,7 +157,8 @@ var harnessSources = harnessCoreSources.concat([ "convertCompilerOptionsFromJson.ts", "convertTypingOptionsFromJson.ts", "tsserverProjectSystem.ts", - "matchFiles.ts" + "matchFiles.ts", + "extensionAPI.ts", ].map(function (f) { return path.join(unittestsDirectory, f); })).concat([ @@ -527,7 +528,7 @@ compileFile(servicesFile, servicesSources,[builtLocalDirectory, copyright].conca // Node package definition file to be distributed without the package. Created by replacing // 'ts' namespace with '"typescript"' as a module. - var nodeStandaloneDefinitionsFileContents = definitionFileContents.replace(/declare (namespace|module) ts/g, 'declare module "typescript"'); + var nodeStandaloneDefinitionsFileContents = definitionFileContents.replace(/declare (namespace|module) ts {/g, 'declare module "typescript" {\n import * as ts from "typescript";'); fs.writeFileSync(nodeStandaloneDefinitionsFile, nodeStandaloneDefinitionsFileContents); }); diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts index ac8a47a991379..49d8a98e49b8d 100644 --- a/src/compiler/checker.ts +++ b/src/compiler/checker.ts @@ -17258,6 +17258,10 @@ namespace ts { return getTypeForVariableLikeDeclaration(node.parent, /*includeOptionality*/ true); } + if (node.kind === SyntaxKind.SourceFile) { + return unknownType; + } + if (isInRightSideOfImportOrExportAssignment(node)) { const symbol = getSymbolAtLocation(node); const declaredType = symbol && getDeclaredTypeOfSymbol(symbol); diff --git a/src/compiler/commandLineParser.ts b/src/compiler/commandLineParser.ts index 2e0bfa8290a22..3d32316491fd1 100644 --- a/src/compiler/commandLineParser.ts +++ b/src/compiler/commandLineParser.ts @@ -268,6 +268,12 @@ namespace ts { experimental: true, description: Diagnostics.Enables_experimental_support_for_emitting_type_metadata_for_decorators }, + { + name: "extensions", + type: "object", + isTSConfigOnly: true, + description: Diagnostics.List_of_compiler_extensions_to_require + }, { name: "moduleResolution", type: { diff --git a/src/compiler/core.ts b/src/compiler/core.ts index ed171b720d0a2..165baca488ff8 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -176,6 +176,26 @@ namespace ts { return array1.concat(array2); } + export function flatten(array1: T[][]): T[] { + if (!array1 || !array1.length) return array1; + return [].concat(...array1); + } + + export function groupBy(array: T[], classifier: (item: T) => string): {[index: string]: T[]}; + export function groupBy(array: T[], classifier: (item: T) => number): {[index: number]: T[]}; + export function groupBy(array: T[], classifier: (item: T) => (string | number)): {[index: string]: T[], [index: number]: T[]} { + if (!array || !array.length) return undefined; + const ret: {[index: string]: T[], [index: number]: T[]} = {}; + for (let i = 0, len = array.length; i < len; i++) { + const result = classifier(array[i]); + if (!ret[result]) { + ret[result] = []; + } + ret[result].push(array[i]); + } + return ret; + } + export function deduplicate(array: T[], areEqual?: (a: T, b: T) => boolean): T[] { let result: T[]; if (array) { diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index d3ecb1715a910..1b0d456351ffb 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -2656,7 +2656,7 @@ "category": "Message", "code": 6099 }, - "'package.json' does not have 'types' field.": { + "'package.json' does not have '{0}' field.": { "category": "Message", "code": 6100 }, @@ -2789,6 +2789,19 @@ "code": 6132 }, + "List of compiler extensions to require.": { + "category": "Message", + "code": 6150 + }, + "Extension loading failed with error '{0}'.": { + "category": "Error", + "code": 6151 + }, + "Extension '{0}' exported member '{1}' has extension kind '{2}', but was type '{3}' when type '{4}' was expected.": { + "category": "Error", + "code": 6152 + }, + "Variable '{0}' implicitly has an '{1}' type.": { "category": "Error", "code": 7005 diff --git a/src/compiler/program.ts b/src/compiler/program.ts index a01f3a4c5b240..2a4bfa7fa2214 100644 --- a/src/compiler/program.ts +++ b/src/compiler/program.ts @@ -176,6 +176,39 @@ namespace ts { return undefined; } + function tryReadMainSection(packageJsonPath: string, baseDirectory: string, state: ModuleResolutionState): string { + let jsonContent: { main?: string }; + try { + const jsonText = state.host.readFile(packageJsonPath); + jsonContent = jsonText ? <{ main?: string }>JSON.parse(jsonText) : {}; + } + catch (e) { + // gracefully handle if readFile fails or returns not JSON + jsonContent = {}; + } + + let mainFile: string; + + if (jsonContent.main) { + if (typeof jsonContent.main === "string") { + mainFile = jsonContent.main; + } + else { + if (state.traceEnabled) { + trace(state.host, Diagnostics.Expected_type_of_0_field_in_package_json_to_be_string_got_1, "main", typeof jsonContent.main); + } + } + } + if (mainFile) { + const mainFilePath = normalizePath(combinePaths(baseDirectory, mainFile)); + if (state.traceEnabled) { + trace(state.host, Diagnostics.package_json_has_0_field_1_that_references_2, "main", mainFile, mainFilePath); + } + return mainFilePath; + } + return undefined; + } + const typeReferenceExtensions = [".d.ts"]; function getEffectiveTypeRoots(options: CompilerOptions, host: ModuleResolutionHost) { @@ -282,7 +315,7 @@ namespace ts { }; } - export function resolveModuleName(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost): ResolvedModuleWithFailedLookupLocations { + export function resolveModuleName(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, loadJs?: boolean): ResolvedModuleWithFailedLookupLocations { const traceEnabled = isTraceEnabled(compilerOptions, host); if (traceEnabled) { trace(host, Diagnostics.Resolving_module_0_from_1, moduleName, containingFile); @@ -304,7 +337,7 @@ namespace ts { let result: ResolvedModuleWithFailedLookupLocations; switch (moduleResolution) { case ModuleResolutionKind.NodeJs: - result = nodeModuleNameResolver(moduleName, containingFile, compilerOptions, host); + result = nodeModuleNameResolver(moduleName, containingFile, compilerOptions, host, loadJs); break; case ModuleResolutionKind.Classic: result = classicNameResolver(moduleName, containingFile, compilerOptions, host); @@ -599,7 +632,7 @@ namespace ts { }; } - export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost): ResolvedModuleWithFailedLookupLocations { + export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, loadJs?: boolean): ResolvedModuleWithFailedLookupLocations { const containingDirectory = getDirectoryPath(containingFile); const supportedExtensions = getSupportedExtensions(compilerOptions); const traceEnabled = isTraceEnabled(compilerOptions, host); @@ -615,7 +648,7 @@ namespace ts { if (traceEnabled) { trace(host, Diagnostics.Loading_module_0_from_node_modules_folder, moduleName); } - resolvedFileName = loadModuleFromNodeModules(moduleName, containingDirectory, failedLookupLocations, state); + resolvedFileName = loadModuleFromNodeModules(moduleName, containingDirectory, failedLookupLocations, state, loadJs); isExternalLibraryImport = resolvedFileName !== undefined; } else { @@ -705,14 +738,14 @@ namespace ts { } } - function loadNodeModuleFromDirectory(extensions: string[], candidate: string, failedLookupLocation: string[], onlyRecordFailures: boolean, state: ModuleResolutionState): string { + function loadNodeModuleFromDirectory(extensions: string[], candidate: string, failedLookupLocation: string[], onlyRecordFailures: boolean, state: ModuleResolutionState, loadJS?: boolean): string { const packageJsonPath = combinePaths(candidate, "package.json"); const directoryExists = !onlyRecordFailures && directoryProbablyExists(candidate, state.host); if (directoryExists && state.host.fileExists(packageJsonPath)) { if (state.traceEnabled) { trace(state.host, Diagnostics.Found_package_json_at_0, packageJsonPath); } - const typesFile = tryReadTypesSection(packageJsonPath, candidate, state); + const typesFile = loadJS ? tryReadMainSection(packageJsonPath, candidate, state) : tryReadTypesSection(packageJsonPath, candidate, state); if (typesFile) { const result = loadModuleFromFile(typesFile, extensions, failedLookupLocation, !directoryProbablyExists(getDirectoryPath(typesFile), state.host), state); if (result) { @@ -721,7 +754,7 @@ namespace ts { } else { if (state.traceEnabled) { - trace(state.host, Diagnostics.package_json_does_not_have_types_field); + trace(state.host, Diagnostics.package_json_does_not_have_0_field, loadJS ? "main" : "types"); } } } @@ -736,29 +769,30 @@ namespace ts { return loadModuleFromFile(combinePaths(candidate, "index"), extensions, failedLookupLocation, !directoryExists, state); } - function loadModuleFromNodeModulesFolder(moduleName: string, directory: string, failedLookupLocations: string[], state: ModuleResolutionState): string { + function loadModuleFromNodeModulesFolder(moduleName: string, directory: string, failedLookupLocations: string[], state: ModuleResolutionState, loadJS?: boolean): string { const nodeModulesFolder = combinePaths(directory, "node_modules"); const nodeModulesFolderExists = directoryProbablyExists(nodeModulesFolder, state.host); const candidate = normalizePath(combinePaths(nodeModulesFolder, moduleName)); // Load only typescript files irrespective of allowJs option if loading from node modules - let result = loadModuleFromFile(candidate, supportedTypeScriptExtensions, failedLookupLocations, !nodeModulesFolderExists, state); + const extensionsSearched = loadJS ? supportedJavascriptExtensions : supportedTypeScriptExtensions; + let result = loadModuleFromFile(candidate, extensionsSearched, failedLookupLocations, !nodeModulesFolderExists, state); if (result) { return result; } - result = loadNodeModuleFromDirectory(supportedTypeScriptExtensions, candidate, failedLookupLocations, !nodeModulesFolderExists, state); + result = loadNodeModuleFromDirectory(extensionsSearched, candidate, failedLookupLocations, !nodeModulesFolderExists, state, loadJS); if (result) { return result; } } - function loadModuleFromNodeModules(moduleName: string, directory: string, failedLookupLocations: string[], state: ModuleResolutionState): string { + function loadModuleFromNodeModules(moduleName: string, directory: string, failedLookupLocations: string[], state: ModuleResolutionState, loadJS?: boolean): string { directory = normalizeSlashes(directory); while (true) { const baseName = getBaseFileName(directory); if (baseName !== "node_modules") { const result = // first: try to load module as-is - loadModuleFromNodeModulesFolder(moduleName, directory, failedLookupLocations, state) || + loadModuleFromNodeModulesFolder(moduleName, directory, failedLookupLocations, state, loadJS) || // second: try to load module from the scope '@types' loadModuleFromNodeModulesFolder(combinePaths("@types", moduleName), directory, failedLookupLocations, state); if (result) { @@ -935,6 +969,12 @@ namespace ts { return getDirectoryPath(normalizePath(sys.getExecutingFilePath())); } + function loadExtension(name: string): any { + if (sys.loadExtension) { + return sys.loadExtension(name); + } + } + const newLine = getNewLineCharacter(options); const realpath = sys.realpath && ((path: string) => sys.realpath(path)); @@ -952,7 +992,8 @@ namespace ts { trace: (s: string) => sys.write(s + newLine), directoryExists: directoryName => sys.directoryExists(directoryName), getDirectories: (path: string) => sys.getDirectories(path), - realpath + realpath, + loadExtension }; } @@ -1129,6 +1170,8 @@ namespace ts { // unconditionally set oldProgram to undefined to prevent it from being captured in closure oldProgram = undefined; + const compilerExtensions = collectCompilerExtensions(); + program = { getRootFileNames: () => rootNames, getSourceFile, @@ -1151,7 +1194,10 @@ namespace ts { getSymbolCount: () => getDiagnosticsProducingTypeChecker().getSymbolCount(), getTypeCount: () => getDiagnosticsProducingTypeChecker().getTypeCount(), getFileProcessingDiagnostics: () => fileProcessingDiagnostics, - getResolvedTypeReferenceDirectives: () => resolvedTypeReferenceDirectives + getResolvedTypeReferenceDirectives: () => resolvedTypeReferenceDirectives, + getCompilerExtensions() { + return compilerExtensions; + } }; verifyCompilerOptions(); @@ -1160,6 +1206,76 @@ namespace ts { return program; + function collectCompilerExtensions(): ExtensionCollectionMap { + const extOptions = options.extensions; + const extensionNames = (extOptions instanceof Array) ? extOptions : getKeys(extOptions); + return groupBy(flatten(map(filter(map(extensionNames, name => { + let result: any; + let error: any; + if (host.loadExtension) { + // TODO (weswig): @ts is taken on npm. Aquire it or use @tsc? + const resolved = resolveModuleName(combinePaths("@ts", name), combinePaths(currentDirectory, "tsconfig.json"), options, host, /*loadJs*/true).resolvedModule; + if (resolved) { + try { + result = host.loadExtension(resolved.resolvedFileName); + } + catch (e) { + error = e; + } + } + else { + error = new Error(`Host could not locate extension '${name}'.`); + } + } + else { + error = new Error("Extension loading not implemented in compiler host."); + } + if (error) { + programDiagnostics.add(createCompilerDiagnostic(Diagnostics.Extension_loading_failed_with_error_0, error.stack ? `${error} + Stack trace: + ${error.stack}` : error)); + } + return {name, result, error}; + }), res => !res.error), res => { + if (res.result) { + return reduceProperties(res.result, (aggregate: Extension[], potentialExtension: any, key: string) => { + if (!potentialExtension) { + return; // Avoid errors on explicitly exported null/undefined (why would someone do that, though?) + } + const annotatedKind = potentialExtension.__tsCompilerExtensionKind; + if (typeof annotatedKind === "string") { + const ext: ExtensionBase = { + name: key !== "default" ? `${res.name}[${key}]` : res.name, + args: extensionNames === extOptions ? undefined : (extOptions as Map)[res.name], + kind: annotatedKind as ExtensionKind + }; + switch (ext.kind) { + case ExtensionKind.SemanticLint: + case ExtensionKind.SyntacticLint: + if (typeof potentialExtension !== "function") { + programDiagnostics.add(createCompilerDiagnostic( + Diagnostics.Extension_0_exported_member_1_has_extension_kind_2_but_was_type_3_when_type_4_was_expected, + res.name, + key, + (ts as any).ExtensionKind[annotatedKind], + typeof potentialExtension, + "function" + )); + return; + } + (ext as (SemanticLintExtension | SyntacticLintExtension)).ctor = potentialExtension; + } + aggregate.push(ext as Extension); + } + return aggregate; + }, []); + } + else { + return []; + } + })), elem => elem.kind) || {}; + } + function getCommonSourceDirectory() { if (typeof commonSourceDirectory === "undefined") { if (options.rootDir && checkSourceFilesBelongToPath(files, options.rootDir)) { @@ -1454,7 +1570,79 @@ namespace ts { } } + /** + * ExtensionKind.SyntacticLint or ExtensionKind.SemanticLint only + */ + function performLintPassOnFile(sourceFile: SourceFile, kind: ExtensionKind): Diagnostic[] | undefined { + const lints = compilerExtensions[kind]; + if (!lints || !lints.length) { + return; + } + type UniqueLint = {name: string, walker: LintWalker, accepted: boolean}; + const initializedLints = new Array(lints.length); + const diagnostics: Diagnostic[] = []; + let activeLint: UniqueLint; + let parent: Node | undefined = undefined; + for (let i = 0; i < lints.length; i++) { + if (kind === ExtensionKind.SemanticLint) { + initializedLints[i] = {name: lints[i].name, walker: new (lints[i].ctor as SemanticLintProviderStatic)(ts, getTypeChecker(), lints[i].args), accepted: true}; + } + else if (kind === ExtensionKind.SyntacticLint) { + initializedLints[i] = {name: lints[i].name, walker: new (lints[i].ctor as SyntacticLintProviderStatic)(ts, lints[i].args), accepted: true}; + } + } + + visitNode(sourceFile); + + return diagnostics; + + function visitNode(node: Node) { + let oneAccepted = false; + const oldParent = parent; + const needsReset: boolean[] = new Array(initializedLints.length); + for (let i = 0; i < initializedLints.length; i++) { + if (initializedLints[i].accepted) { + activeLint = initializedLints[i]; + activeLint.accepted = false; + node.parent = parent; + activeLint.walker.visit(node, accept, error); + if (activeLint.accepted) { + oneAccepted = true; + } + else { + needsReset[i] = true; + } + } + } + parent = node; + if (oneAccepted) { + forEachChild(node, visitNode); + } + parent = oldParent; + for (let i = 0; i < initializedLints.length; i++) { + if (needsReset[i]) { + initializedLints[i].accepted = true; + needsReset[i] = false; + } + } + } + + function accept() { + activeLint.accepted = true; + } + + function error(err: string, node: Node) { + diagnostics.push(createExtensionDiagnosticForNode(node, activeLint.name, err)); + } + } + function getSyntacticDiagnosticsForFile(sourceFile: SourceFile, cancellationToken: CancellationToken): Diagnostic[] { + if (!(sourceFile.isDeclarationFile || sourceFile.externalModuleIndicator)) { + const lintDiagnostics = performLintPassOnFile(sourceFile, ExtensionKind.SyntacticLint); + if (lintDiagnostics && lintDiagnostics.length) { + return sourceFile.parseDiagnostics.concat(lintDiagnostics); + } + } return sourceFile.parseDiagnostics; } @@ -1495,8 +1683,9 @@ namespace ts { typeChecker.getDiagnostics(sourceFile, cancellationToken); const fileProcessingDiagnosticsInFile = fileProcessingDiagnostics.getDiagnostics(sourceFile.fileName); const programDiagnosticsInFile = programDiagnostics.getDiagnostics(sourceFile.fileName); + const lintDiagnostics = (!(sourceFile.isDeclarationFile || sourceFile.externalModuleIndicator)) ? (performLintPassOnFile(sourceFile, ExtensionKind.SemanticLint) || []) : []; - return bindDiagnostics.concat(checkDiagnostics).concat(fileProcessingDiagnosticsInFile).concat(programDiagnosticsInFile); + return bindDiagnostics.concat(checkDiagnostics).concat(fileProcessingDiagnosticsInFile).concat(programDiagnosticsInFile).concat(lintDiagnostics); }); } diff --git a/src/compiler/sys.ts b/src/compiler/sys.ts index 338b6de1e635f..8ffd48bcf99dc 100644 --- a/src/compiler/sys.ts +++ b/src/compiler/sys.ts @@ -32,6 +32,7 @@ namespace ts { getMemoryUsage?(): number; exit(exitCode?: number): void; realpath?(path: string): string; + loadExtension?(name: string): any; } export interface FileWatcher { @@ -547,6 +548,9 @@ namespace ts { }, realpath(path: string): string { return _fs.realpathSync(path); + }, + loadExtension(name) { + return require(name); } }; } diff --git a/src/compiler/tsc.ts b/src/compiler/tsc.ts index e25ae37e21fa9..e1f2543db98bc 100644 --- a/src/compiler/tsc.ts +++ b/src/compiler/tsc.ts @@ -115,7 +115,12 @@ namespace ts { } const category = DiagnosticCategory[diagnostic.category].toLowerCase(); - output += `${ category } TS${ diagnostic.code }: ${ flattenDiagnosticMessageText(diagnostic.messageText, sys.newLine) }${ sys.newLine }`; + if (diagnostic.category === DiagnosticCategory.Extension) { + output += `${ category } ${ diagnostic.code }: ${ flattenDiagnosticMessageText(diagnostic.messageText, sys.newLine) }${ sys.newLine }`; + } + else { + output += `${ category } TS${ diagnostic.code }: ${ flattenDiagnosticMessageText(diagnostic.messageText, sys.newLine) }${ sys.newLine }`; + } sys.write(output); } @@ -604,13 +609,16 @@ namespace ts { // First get and report any syntactic errors. diagnostics = program.getSyntacticDiagnostics(); + // Count extension diagnostics and ignore them for determining continued error reporting + const extensionDiagnostics = filter(diagnostics, d => d.category === DiagnosticCategory.Extension).length; + // If we didn't have any syntactic errors, then also try getting the global and // semantic errors. - if (diagnostics.length === 0) { - diagnostics = program.getOptionsDiagnostics().concat(program.getGlobalDiagnostics()); + if (diagnostics.length === extensionDiagnostics) { + diagnostics = diagnostics.concat(program.getOptionsDiagnostics().concat(program.getGlobalDiagnostics())); - if (diagnostics.length === 0) { - diagnostics = program.getSemanticDiagnostics(); + if (diagnostics.length === extensionDiagnostics) { + diagnostics = diagnostics.concat(program.getSemanticDiagnostics()); } } diff --git a/src/compiler/types.ts b/src/compiler/types.ts index f3549aa381530..0c2740ead64f7 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -1736,6 +1736,11 @@ namespace ts { */ getTypeChecker(): TypeChecker; + /** + * Gets a map of loaded compiler extensions + */ + getCompilerExtensions(): ExtensionCollectionMap; + /* @internal */ getCommonSourceDirectory(): string; // For testing purposes only. Should not be used by any other consumers (including the @@ -1812,6 +1817,7 @@ namespace ts { getSourceFiles(): SourceFile[]; getSourceFile(fileName: string): SourceFile; getResolvedTypeReferenceDirectives(): Map; + getCompilerExtensions(): ExtensionCollectionMap; } export interface TypeChecker { @@ -2496,13 +2502,14 @@ namespace ts { length: number; messageText: string | DiagnosticMessageChain; category: DiagnosticCategory; - code: number; + code: number | string; } export enum DiagnosticCategory { Warning, Error, Message, + Extension, } export enum ModuleResolutionKind { @@ -2586,6 +2593,7 @@ namespace ts { typesSearchPaths?: string[]; /*@internal*/ version?: boolean; /*@internal*/ watch?: boolean; + extensions?: string[] | Map; [option: string]: CompilerOptionsValue | undefined; } @@ -2893,6 +2901,57 @@ namespace ts { failedLookupLocations: string[]; } + export type LintErrorMethod = (err: string, span: Node) => void; + export type LintAcceptMethod = () => void; + + /* + * Walkers call accept to decend into the node's children + * Walkers call error to add errors to the output. + */ + export interface LintWalker { + visit(node: Node, accept: LintAcceptMethod, error: LintErrorMethod): void; + } + + export interface SyntacticLintProviderStatic { + new (typescript: typeof ts, args: any): LintWalker; + } + + export interface SemanticLintProviderStatic { + new (typescript: typeof ts, checker: TypeChecker, args: any): LintWalker; + } + + export namespace ExtensionKind { + export const SemanticLint: "semantic-lint" = "semantic-lint"; + export type SemanticLint = "semantic-lint"; + export const SyntacticLint: "syntactic-lint" = "syntactic-lint"; + export type SyntacticLint = "syntactic-lint"; + } + export type ExtensionKind = ExtensionKind.SemanticLint | ExtensionKind.SyntacticLint; + + export interface ExtensionCollectionMap { + "syntactic-lint"?: SyntacticLintExtension[]; + "semantic-lint"?: SemanticLintExtension[]; + [index: string]: Extension[] | undefined; + } + + export interface ExtensionBase { + name: string; + args: any; + kind: ExtensionKind; + } + + // @kind(ExtensionKind.SyntacticLint) + export interface SyntacticLintExtension extends ExtensionBase { + ctor: SyntacticLintProviderStatic; + } + + // @kind(ExtensionKind.SemanticLint) + export interface SemanticLintExtension extends ExtensionBase { + ctor: SemanticLintProviderStatic; + } + + export type Extension = SyntacticLintExtension | SemanticLintExtension; + export interface CompilerHost extends ModuleResolutionHost { getSourceFile(fileName: string, languageVersion: ScriptTarget, onError?: (message: string) => void): SourceFile; getSourceFileByPath?(fileName: string, path: Path, languageVersion: ScriptTarget, onError?: (message: string) => void): SourceFile; @@ -2919,6 +2978,14 @@ namespace ts { * This method is a companion for 'resolveModuleNames' and is used to resolve 'types' references to actual type declaration files */ resolveTypeReferenceDirectives?(typeReferenceDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[]; + + /** + * Delegates the loading of compiler extensions to the compiler host. + * The function should return the result of executing the code of an extension + * - its exported members. These members will be searched for objects who have been decorated with + * specific flags. + */ + loadExtension?(extension: string): any; } export interface TextSpan { diff --git a/src/compiler/utilities.ts b/src/compiler/utilities.ts index b4ef62c6c3e22..303d2d61f16c7 100644 --- a/src/compiler/utilities.ts +++ b/src/compiler/utilities.ts @@ -453,6 +453,19 @@ namespace ts { return createFileDiagnostic(sourceFile, span.start, span.length, message, arg0, arg1, arg2); } + export function createExtensionDiagnosticForNode(node: Node, extension: string, message: string): Diagnostic { + const sourceFile = getSourceFileOfNode(node); + const span = getErrorSpanForNode(sourceFile, node); + return { + file: sourceFile, + messageText: message, + code: extension, + start: span.start, + length: span.length, + category: DiagnosticCategory.Extension + }; + } + export function createDiagnosticForNodeFromMessageChain(node: Node, messageChain: DiagnosticMessageChain): Diagnostic { const sourceFile = getSourceFileOfNode(node); const span = getErrorSpanForNode(sourceFile, node); diff --git a/src/services/shims.ts b/src/services/shims.ts index ac74ee0975019..73c516ca19d65 100644 --- a/src/services/shims.ts +++ b/src/services/shims.ts @@ -541,11 +541,11 @@ namespace ts { } } - export function realizeDiagnostics(diagnostics: Diagnostic[], newLine: string): { message: string; start: number; length: number; category: string; code: number; }[] { + export function realizeDiagnostics(diagnostics: Diagnostic[], newLine: string): { message: string; start: number; length: number; category: string; code: number | string; }[] { return diagnostics.map(d => realizeDiagnostic(d, newLine)); } - function realizeDiagnostic(diagnostic: Diagnostic, newLine: string): { message: string; start: number; length: number; category: string; code: number; } { + function realizeDiagnostic(diagnostic: Diagnostic, newLine: string): { message: string; start: number; length: number; category: string; code: number | string; } { return { message: flattenDiagnosticMessageText(diagnostic.messageText, newLine), start: diagnostic.start, diff --git a/tests/cases/unittests/extensionAPI.ts b/tests/cases/unittests/extensionAPI.ts new file mode 100644 index 0000000000000..535b24a15adff --- /dev/null +++ b/tests/cases/unittests/extensionAPI.ts @@ -0,0 +1,455 @@ +/// + +namespace ts { + describe("Extension API", () => { + + function prettyPrintDiagnostic(diagnostic: Diagnostic): string { + const message = flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + if (diagnostic.file) { + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`; + } + else { + return `!!!${message}`; + } + } + + function checkDiagnostics(diagnostics: Diagnostic[], expectedDiagnosticCodes?: (number | string)[]) { + if (!expectedDiagnosticCodes) { + return; + } + + for (let i = 0; i < expectedDiagnosticCodes.length; i++) { + assert.equal(expectedDiagnosticCodes[i], diagnostics[i] && diagnostics[i].code, `Could not find expeced diagnostic.`); + } + if (expectedDiagnosticCodes.length === 0 && diagnostics.length) { + throw new Error(`Unexpected diagnostic (${diagnostics.length - 1} more): ${prettyPrintDiagnostic(diagnostics[0])}`); + } + assert.equal(diagnostics.length, expectedDiagnosticCodes.length, "Resuting diagnostics count does not match expected"); + } + + interface ExtensionTestOptions { + compilerOptions: CompilerOptions; + availableExtensions: string[]; + expectedDiagnostics: (number | string)[]; + } + + const {content: libContent} = Harness.getDefaultLibraryFile(Harness.IO); + const tsLibContents = Harness.IO.readFile("built/local/typescript_standalone.d.ts"); + const virtualLib: Map = { + "/lib/lib.d.ts": libContent, + "/lib/typescript.d.ts": tsLibContents + }; + + let virtualFs: Map = {}; + + const getCanonicalFileName = createGetCanonicalFileName(true); + + function loadSetIntoFsAt(set: Map, prefix: string) { + forEachKey(set, key => void (virtualFs[getCanonicalFileName(combinePaths(prefix, key))] = set[key])); + } + + function loadSetIntoFs(set: Map) { + forEachKey(set, key => void (virtualFs[getCanonicalFileName(key)] = set[key])); + } + + const mockHost: CompilerHost = { + useCaseSensitiveFileNames() { return true; }, + getNewLine() { return "\n"; }, + readFile(path) { return virtualFs[this.getCanonicalFileName(path)]; }, + writeFile(path, content, foo, bar, baz) { + virtualFs[this.getCanonicalFileName(path)] = content; + }, + fileExists(path) { + return !!virtualFs[this.getCanonicalFileName(path)]; + }, + directoryExists(path) { + const fullPath = this.getCanonicalFileName(path); + return forEach(getKeys(virtualFs), key => startsWith(key, fullPath)); + }, + getCurrentDirectory(): string { return "/"; }, + getSourceFile(path, languageVersion, onError): SourceFile { + const fullPath = this.getCanonicalFileName(path); + return createSourceFile(fullPath, virtualFs[fullPath], languageVersion); + }, + getDefaultLibLocation() { + return "/lib/"; + }, + getDefaultLibFileName(options) { + return combinePaths(this.getDefaultLibLocation(), getDefaultLibFileName(options)); + }, + getCanonicalFileName, + getDirectories(path) { + path = this.getCanonicalFileName(path); + return filter(map(filter(getKeys(virtualFs), + fullpath => startsWith(fullpath, path) && fullpath.substr(path.length, 1) === "/"), + fullpath => fullpath.substr(path.length + 1).indexOf("/") >= 0 ? fullpath.substr(0, 1 + path.length + fullpath.substr(path.length + 1).indexOf("/")) : fullpath), + fullpath => fullpath.lastIndexOf(".") === -1); + }, + loadExtension(path) { + const fullPath = this.getCanonicalFileName(path); + const m = {exports: {}}; + ((module, exports, require) => { eval(virtualFs[fullPath]); })( + m, + m.exports, + (name: string) => { + return this.loadExtension( + this.getCanonicalFileName( + ts.resolveModuleName(name, fullPath, {module: ts.ModuleKind.CommonJS}, this, true).resolvedModule.resolvedFileName + ) + ); + } + ); + return m.exports; + }, + trace(s) { + console.log(s); + } + }; + + const extensionAPI: Map = { + "package.json": `{ + "name": "typescript-plugin-api", + "version": "1.0.0", + "description": "", + "main": "index.js", + "author": "", + "types": "index.d.ts" +}`, + "index.ts": ` +import * as tsi from "typescript"; + +export abstract class SyntacticLintWalker implements tsi.LintWalker { + private static __tsCompilerExtensionKind: tsi.ExtensionKind.SyntacticLint = "syntactic-lint"; + constructor(protected ts: typeof tsi, protected args: any) {} + abstract visit(node: tsi.Node, accept: tsi.LintAcceptMethod, error: tsi.LintErrorMethod): void; +} + +export abstract class SemanticLintWalker implements tsi.LintWalker { + private static __tsCompilerExtensionKind: tsi.ExtensionKind.SemanticLint = "semantic-lint"; + constructor(protected ts: typeof tsi, protected checker: tsi.TypeChecker, protected args: any) {} + abstract visit(node: tsi.Node, accept: tsi.LintAcceptMethod, error: tsi.LintErrorMethod): void; +} +` + }; + // Compile extension API once (generating .d.ts and .js) + + function compile(fileset: Map, options: ts.CompilerOptions): Diagnostic[] { + loadSetIntoFs(virtualLib); + loadSetIntoFs(fileset); + + const program = createProgram(filter(getKeys(fileset), name => name != "package.json"), options, mockHost); + program.emit(); + + return ts.getPreEmitDiagnostics(program); + } + + function buildMap(map: Map, out: Map, compilerOptions?: CompilerOptions, shouldError?: boolean): Diagnostic[] { + const diagnostics = compile(map, compilerOptions ? compilerOptions : {module: ModuleKind.CommonJS, declaration: true}); + if (shouldError && diagnostics && diagnostics.length) { + for (let i = 0; i < diagnostics.length; i++) { + console.log(prettyPrintDiagnostic(diagnostics[i])); + } + throw new Error("Compiling test harness extension API code resulted in errors."); + } + copyMap(virtualFs, out); + virtualFs = {}; + return diagnostics; + } + buildMap(extensionAPI, extensionAPI, {module: ModuleKind.CommonJS, declaration: true, baseUrl: ".", paths: {"typescript": ["/lib/typescript.d.ts"]}}, /*shouldError*/true); + + const extensions: Map> = { + "test-syntactic-lint": { + "package.json": `{ + "name": "@ts/test-syntactic-lint", + "version": "1.0.0", + "description": "", + "main": "index.js", + "author": "" +}`, + "index.ts": ` +import {SyntacticLintWalker} from "typescript-plugin-api"; + +export default class IsNamedFoo extends SyntacticLintWalker { + constructor(ts, args) { super(ts, args); } + visit(node, accept, error) { + if (node.kind === this.ts.SyntaxKind.Identifier) { + if (node.text.toLowerCase() === "foo") { + error("Identifier 'foo' is forbidden.", node); + } + } + accept(); + } +} +`, + }, + "test-semantic-lint": { + "package.json": `{ + "name": "@ts/test-semantic-lint", + "version": "1.0.0", + "description": "", + "main": "main.js", + "author": "" +}`, + "main.ts": ` +import {SemanticLintWalker} from "typescript-plugin-api"; + +export default class IsValueFoo extends SemanticLintWalker { + constructor(ts, checker, args) { super(ts, checker, args); } + visit(node, accept, error) { + const type = this.checker.getTypeAtLocation(node); + if (type.flags & this.ts.TypeFlags.StringLiteral) { + if (node.text === "foo") { + error("String literal type 'foo' is forbidden.", node); + } + } + accept(); + } +} +`, + }, + "test-extension-arguments": { + "package.json": `{ + "name": "@ts/test-extension-arguments", + "version": "1.0.0", + "description": "", + "main": "index.js", + "author": "" +}`, + "index.ts": ` +import {SyntacticLintWalker} from "typescript-plugin-api"; + +export default class IsNamedX extends SyntacticLintWalker { + constructor(ts, args) { super(ts, args); } + visit(node, accept, error) { + if (node.kind === this.ts.SyntaxKind.Identifier) { + for (let i = 0; i { + loadSetIntoFsAt(extensionAPI, "/node_modules/typescript-plugin-api"); + buildMap(extensions[extName], extensions[extName], {module: ModuleKind.CommonJS, declaration: true, experimentalDecorators: true, baseUrl: "/", paths: {"typescript": ["lib/typescript.d.ts"]}}, /*shouldError*/true); + }); + + /** + * Setup a new test, where all extensions specified in the options hash are available in a node_modules folder, alongside the extension API + */ + function test(sources: Map, options: ExtensionTestOptions) { + forEach(options.availableExtensions, ext => loadSetIntoFsAt(extensions[ext], `/node_modules/@ts/${ext}`)); + const diagnostics = buildMap(sources, sources, options.compilerOptions); + checkDiagnostics(diagnostics, options.expectedDiagnostics); + } + + it("can load syntactic lint extensions", () => { + test({ + "main.ts": `console.log("Hello, world!");`, + }, { + availableExtensions: ["test-syntactic-lint"], + expectedDiagnostics: [], + compilerOptions: { + extensions: ["test-syntactic-lint"], + module: ModuleKind.CommonJS, + } + }); + + test({ + "main.ts": `interface Foo {a; b;}`, + }, { + availableExtensions: ["test-syntactic-lint"], + expectedDiagnostics: ["test-syntactic-lint"], + compilerOptions: { + extensions: ["test-syntactic-lint"], + module: ModuleKind.CommonJS, + } + }); + }); + + it("can load semantic lint extensions", () => { + test({ + "main.ts": `console.log("Hello, world!");`, + }, { + availableExtensions: ["test-semantic-lint"], + expectedDiagnostics: [], + compilerOptions: { + extensions: ["test-semantic-lint"], + module: ModuleKind.CommonJS, + } + }); + + test({ + "main.ts": `const s: "foo" = "foo";`, + }, { + availableExtensions: ["test-semantic-lint"], + expectedDiagnostics: ["test-semantic-lint", "test-semantic-lint"], + compilerOptions: { + extensions: ["test-semantic-lint"], + module: ModuleKind.CommonJS, + } + }); + }); + + it("can load semantic & syntactic lint extensions simultaneously", () => { + test({ + "main.ts": `console.log("Hello, world!");`, + }, { + availableExtensions: ["test-syntactic-lint", "test-semantic-lint"], + expectedDiagnostics: [], + compilerOptions: { + extensions: ["test-syntactic-lint", "test-semantic-lint"], + module: ModuleKind.CommonJS, + } + }); + + test({ + "main.ts": `const s: "foo" = "foo";`, + }, { + availableExtensions: ["test-syntactic-lint", "test-semantic-lint"], + expectedDiagnostics: ["test-semantic-lint", "test-semantic-lint"], + compilerOptions: { + extensions: ["test-syntactic-lint", "test-semantic-lint"], + module: ModuleKind.CommonJS, + } + }); + + test({ + "main.ts": `interface Foo {a; b;}`, + }, { + availableExtensions: ["test-syntactic-lint", "test-semantic-lint"], + expectedDiagnostics: ["test-syntactic-lint"], + compilerOptions: { + extensions: ["test-syntactic-lint", "test-semantic-lint"], + module: ModuleKind.CommonJS, + } + }); + + test({ + "main.ts": `interface Foo {a; b;} + const s: "foo" = "foo";`, + }, { + availableExtensions: ["test-syntactic-lint", "test-semantic-lint"], + expectedDiagnostics: ["test-syntactic-lint", "test-semantic-lint", "test-semantic-lint"], + compilerOptions: { + extensions: ["test-syntactic-lint", "test-semantic-lint"], + module: ModuleKind.CommonJS, + } + }); + }); + + it("can pass arguments to lint rules", () => { + test({ + "main.ts": `interface Foo {a; b;}`, + }, { + availableExtensions: ["test-extension-arguments"], + expectedDiagnostics: ["test-extension-arguments", "test-extension-arguments"], + compilerOptions: { + extensions: { + "test-extension-arguments": ["a", "b"] + }, + module: ModuleKind.CommonJS, + } + }); + }); + + it("can load multiple rules from a single extension", () => { + test({ + "main.ts": `interface Foo {b;} + interface Bar {a;} + const f: "foo" = "foo"; + let b: "bar" = "bar";`, + }, { + availableExtensions: ["test-multi-extension"], + expectedDiagnostics: ["test-multi-extension[IsNamedFoo]", "test-multi-extension[IsNamedBar]", "test-multi-extension[IsValueFoo]", "test-multi-extension[IsValueFoo]", "test-multi-extension[IsValueBar]", "test-multi-extension[IsValueBar]"], + compilerOptions: { + extensions: ["test-multi-extension"], + module: ModuleKind.CommonJS, + } + }); + }); + + it("can error when it fails to load a lint rule", () => { + test({ + "main.ts": `console.log("Hello, world!");`, + }, { + availableExtensions: [], + expectedDiagnostics: [6151, 6151], + compilerOptions: { + extensions: ["test-syntactic-lint", "test-semantic-lint"], + module: ModuleKind.CommonJS, + } + }); + }); + }); +}