diff --git a/scripts/buildProtocol.ts b/scripts/buildProtocol.ts index 66f29f577d933..37ebd0105ae6f 100644 --- a/scripts/buildProtocol.ts +++ b/scripts/buildProtocol.ts @@ -167,7 +167,7 @@ function generateProtocolFile(protocolTs: string, typeScriptServicesDts: string) const sanityCheckProgram = getProgramWithProtocolText(protocolDts, /*includeTypeScriptServices*/ false); const diagnostics = [...sanityCheckProgram.getSyntacticDiagnostics(), ...sanityCheckProgram.getSemanticDiagnostics(), ...sanityCheckProgram.getGlobalDiagnostics()]; if (diagnostics.length) { - const flattenedDiagnostics = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n")).join("\n"); + const flattenedDiagnostics = diagnostics.map(d => `${ts.flattenDiagnosticMessageText(d.messageText, "\n")} at ${d.file.fileName} line ${d.start}`).join("\n"); throw new Error(`Unexpected errors during sanity check: ${flattenedDiagnostics}`); } return protocolDts; diff --git a/src/compiler/commandLineParser.ts b/src/compiler/commandLineParser.ts index 52804dcd336de..ebb6a9ec39f41 100644 --- a/src/compiler/commandLineParser.ts +++ b/src/compiler/commandLineParser.ts @@ -459,6 +459,16 @@ namespace ts { name: "alwaysStrict", type: "boolean", description: Diagnostics.Parse_in_strict_mode_and_emit_use_strict_for_each_source_file + }, + { + // A list of plugins to load in the language service + name: "plugins", + type: "list", + isTSConfigOnly: true, + element: { + name: "plugin", + type: "object" + } } ]; diff --git a/src/compiler/moduleNameResolver.ts b/src/compiler/moduleNameResolver.ts index 68b043f1439ff..c35bf3da28c6b 100644 --- a/src/compiler/moduleNameResolver.ts +++ b/src/compiler/moduleNameResolver.ts @@ -675,13 +675,18 @@ namespace ts { } export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache): ResolvedModuleWithFailedLookupLocations { + return nodeModuleNameResolverWorker(moduleName, containingFile, compilerOptions, host, cache, /* jsOnly*/ false); + } + + /* @internal */ + export function nodeModuleNameResolverWorker(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, jsOnly = false): ResolvedModuleWithFailedLookupLocations { const containingDirectory = getDirectoryPath(containingFile); const traceEnabled = isTraceEnabled(compilerOptions, host); const failedLookupLocations: string[] = []; const state: ModuleResolutionState = { compilerOptions, host, traceEnabled }; - const result = tryResolve(Extensions.TypeScript) || tryResolve(Extensions.JavaScript); + const result = jsOnly ? tryResolve(Extensions.JavaScript) : (tryResolve(Extensions.TypeScript) || tryResolve(Extensions.JavaScript)); if (result && result.value) { const { resolved, isExternalLibraryImport } = result.value; return createResolvedModuleWithFailedLookupLocations(resolved, isExternalLibraryImport, failedLookupLocations); diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 4d8dcaa8ad7ab..bdc4eea98da09 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -3215,7 +3215,11 @@ NodeJs = 2 } - export type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike; + export interface PluginImport { + name: string + } + + export type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike | PluginImport[]; export interface CompilerOptions { allowJs?: boolean; @@ -3270,6 +3274,7 @@ outDir?: string; outFile?: string; paths?: MapLike; + /*@internal*/ plugins?: PluginImport[]; preserveConstEnums?: boolean; project?: string; /* @internal */ pretty?: DiagnosticStyle; @@ -3353,7 +3358,8 @@ JS = 1, JSX = 2, TS = 3, - TSX = 4 + TSX = 4, + External = 5 } export const enum ScriptTarget { @@ -3428,7 +3434,7 @@ /* @internal */ export interface CommandLineOptionOfListType extends CommandLineOptionBase { type: "list"; - element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType; + element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType | TsConfigOnlyOption; } /* @internal */ diff --git a/src/harness/fourslash.ts b/src/harness/fourslash.ts index d0a5f35d8ed2f..34aa9580d6d78 100644 --- a/src/harness/fourslash.ts +++ b/src/harness/fourslash.ts @@ -263,6 +263,20 @@ namespace FourSlash { // Create map between fileName and its content for easily looking up when resolveReference flag is specified this.inputFiles.set(file.fileName, file.content); if (ts.getBaseFileName(file.fileName).toLowerCase() === "tsconfig.json") { + const configJson = ts.parseConfigFileTextToJson(file.fileName, file.content); + if (configJson.config === undefined) { + throw new Error(`Failed to parse test tsconfig.json: ${configJson.error.messageText}`); + } + + // Extend our existing compiler options so that we can also support tsconfig only options + if (configJson.config.compilerOptions) { + const baseDirectory = ts.normalizePath(ts.getDirectoryPath(file.fileName)); + const tsConfig = ts.convertCompilerOptionsFromJson(configJson.config.compilerOptions, baseDirectory, file.fileName); + + if (!tsConfig.errors || !tsConfig.errors.length) { + compilationOptions = ts.extend(compilationOptions, tsConfig.options); + } + } configFileName = file.fileName; } diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index ca2db231ef7cf..202b429fcbc6a 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -126,7 +126,7 @@ namespace Harness.LanguageService { protected virtualFileSystem: Utils.VirtualFileSystem = new Utils.VirtualFileSystem(virtualFileSystemRoot, /*useCaseSensitiveFilenames*/false); constructor(protected cancellationToken = DefaultHostCancellationToken.Instance, - protected settings = ts.getDefaultCompilerOptions()) { + protected settings = ts.getDefaultCompilerOptions()) { } public getNewLine(): string { @@ -135,7 +135,7 @@ namespace Harness.LanguageService { public getFilenames(): string[] { const fileNames: string[] = []; - for (const virtualEntry of this.virtualFileSystem.getAllFileEntries()){ + for (const virtualEntry of this.virtualFileSystem.getAllFileEntries()) { const scriptInfo = virtualEntry.content; if (scriptInfo.isRootFile) { // only include root files here @@ -211,8 +211,8 @@ namespace Harness.LanguageService { readDirectory(path: string, extensions?: string[], exclude?: string[], include?: string[]): string[] { return ts.matchFiles(path, extensions, exclude, include, /*useCaseSensitiveFileNames*/false, - this.getCurrentDirectory(), - (p) => this.virtualFileSystem.getAccessibleFileSystemEntries(p)); + this.getCurrentDirectory(), + (p) => this.virtualFileSystem.getAccessibleFileSystemEntries(p)); } readFile(path: string): string { const snapshot = this.getScriptSnapshot(path); @@ -724,6 +724,87 @@ namespace Harness.LanguageService { createHash(s: string) { return s; } + + require(_initialDir: string, _moduleName: string): ts.server.RequireResult { + switch (_moduleName) { + // Adds to the Quick Info a fixed string and a string from the config file + // and replaces the first display part + case "quickinfo-augmeneter": + return { + module: () => ({ + create(info: ts.server.PluginCreateInfo) { + const proxy = makeDefaultProxy(info); + const langSvc: any = info.languageService; + proxy.getQuickInfoAtPosition = function () { + const parts = langSvc.getQuickInfoAtPosition.apply(langSvc, arguments); + if (parts.displayParts.length > 0) { + parts.displayParts[0].text = "Proxied"; + } + parts.displayParts.push({ text: info.config.message, kind: "punctuation" }); + return parts; + }; + + return proxy; + } + }), + error: undefined + }; + + // Throws during initialization + case "create-thrower": + return { + module: () => ({ + create() { + throw new Error("I am not a well-behaved plugin"); + } + }), + error: undefined + }; + + // Adds another diagnostic + case "diagnostic-adder": + return { + module: () => ({ + create(info: ts.server.PluginCreateInfo) { + const proxy = makeDefaultProxy(info); + proxy.getSemanticDiagnostics = function (filename: string) { + const prev = info.languageService.getSemanticDiagnostics(filename); + const sourceFile: ts.SourceFile = info.languageService.getSourceFile(filename); + prev.push({ + category: ts.DiagnosticCategory.Warning, + file: sourceFile, + code: 9999, + length: 3, + messageText: `Plugin diagnostic`, + start: 0 + }); + return prev; + } + return proxy; + } + }), + error: undefined + }; + + default: + return { + module: undefined, + error: "Could not resolve module" + }; + } + + function makeDefaultProxy(info: ts.server.PluginCreateInfo) { + // tslint:disable-next-line:no-null-keyword + const proxy = Object.create(null); + const langSvc: any = info.languageService; + for (const k of Object.keys(langSvc)) { + proxy[k] = function () { + return langSvc[k].apply(langSvc, arguments); + }; + } + return proxy; + } + } } export class ServerLanguageServiceAdapter implements LanguageServiceAdapter { diff --git a/src/server/project.ts b/src/server/project.ts index 6042fd9a62be6..5a48b6c0d1b9e 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -91,19 +91,38 @@ namespace ts.server { } } + export interface PluginCreateInfo { + project: Project; + languageService: LanguageService; + languageServiceHost: LanguageServiceHost; + serverHost: ServerHost; + config: any; + } + + export interface PluginModule { + create(createInfo: PluginCreateInfo): LanguageService; + getExternalFiles?(proj: Project): string[]; + } + + export interface PluginModuleFactory { + (mod: { typescript: typeof ts }): PluginModule; + } + export abstract class Project { private rootFiles: ScriptInfo[] = []; private rootFilesMap: FileMap = createFileMap(); - private lsHost: LSHost; private program: ts.Program; private cachedUnresolvedImportsPerFile = new UnresolvedImportsMap(); private lastCachedUnresolvedImportsList: SortedReadonlyArray; - private readonly languageService: LanguageService; + // wrapper over the real language service that will suppress all semantic operations + protected languageService: LanguageService; public languageServiceEnabled = true; + protected readonly lsHost: LSHost; + builder: Builder; /** * Set of files names that were updated since the last call to getChangesSinceVersion. @@ -150,6 +169,17 @@ namespace ts.server { return this.cachedUnresolvedImportsPerFile; } + public static resolveModule(moduleName: string, initialDir: string, host: ServerHost, log: (message: string) => void): {} { + const resolvedPath = normalizeSlashes(host.resolvePath(combinePaths(initialDir, "node_modules"))); + log(`Loading ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`); + const result = host.require(resolvedPath, moduleName); + if (result.error) { + log(`Failed to load module: ${JSON.stringify(result.error)}`); + return undefined; + } + return result.module; + } + constructor( private readonly projectName: string, readonly projectKind: ProjectKind, @@ -237,6 +267,10 @@ namespace ts.server { abstract getProjectRootPath(): string | undefined; abstract getTypeAcquisition(): TypeAcquisition; + getExternalFiles(): string[] { + return []; + } + getSourceFile(path: Path) { if (!this.program) { return undefined; @@ -804,10 +838,12 @@ namespace ts.server { private typeRootsWatchers: FileWatcher[]; readonly canonicalConfigFilePath: NormalizedPath; + private plugins: PluginModule[] = []; + /** Used for configured projects which may have multiple open roots */ openRefCount = 0; - constructor(configFileName: NormalizedPath, + constructor(private configFileName: NormalizedPath, projectService: ProjectService, documentRegistry: ts.DocumentRegistry, hasExplicitListOfFiles: boolean, @@ -817,12 +853,64 @@ namespace ts.server { public compileOnSaveEnabled: boolean) { super(configFileName, ProjectKind.Configured, projectService, documentRegistry, hasExplicitListOfFiles, languageServiceEnabled, compilerOptions, compileOnSaveEnabled); this.canonicalConfigFilePath = asNormalizedPath(projectService.toCanonicalFileName(configFileName)); + this.enablePlugins(); } getConfigFilePath() { return this.getProjectName(); } + enablePlugins() { + const host = this.projectService.host; + const options = this.getCompilerOptions(); + const log = (message: string) => { + this.projectService.logger.info(message); + }; + + if (!(options.plugins && options.plugins.length)) { + this.projectService.logger.info("No plugins exist"); + // No plugins + return; + } + + if (!host.require) { + this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded"); + return; + } + + for (const pluginConfigEntry of options.plugins) { + const searchPath = getDirectoryPath(this.configFileName); + const resolvedModule = Project.resolveModule(pluginConfigEntry.name, searchPath, host, log); + if (resolvedModule) { + this.enableProxy(resolvedModule, pluginConfigEntry); + } + } + } + + private enableProxy(pluginModuleFactory: PluginModuleFactory, configEntry: PluginImport) { + try { + if (typeof pluginModuleFactory !== "function") { + this.projectService.logger.info(`Skipped loading plugin ${configEntry.name} because it did expose a proper factory function`); + return; + } + + const info: PluginCreateInfo = { + config: configEntry, + project: this, + languageService: this.languageService, + languageServiceHost: this.lsHost, + serverHost: this.projectService.host + }; + + const pluginModule = pluginModuleFactory({ typescript: ts }); + this.languageService = pluginModule.create(info); + this.plugins.push(pluginModule); + } + catch (e) { + this.projectService.logger.info(`Plugin activation failed: ${e}`); + } + } + getProjectRootPath() { return getDirectoryPath(this.getConfigFilePath()); } @@ -839,6 +927,21 @@ namespace ts.server { return this.typeAcquisition; } + getExternalFiles(): string[] { + const items: string[] = []; + for (const plugin of this.plugins) { + if (typeof plugin.getExternalFiles === "function") { + try { + items.push(...plugin.getExternalFiles(this)); + } + catch (e) { + this.projectService.logger.info(`A plugin threw an exception in getExternalFiles: ${e}`); + } + } + } + return items; + } + watchConfigFile(callback: (project: ConfiguredProject) => void) { this.projectFileWatcher = this.projectService.host.watchFile(this.getConfigFilePath(), _ => callback(this)); } diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 4d583cb66d9d5..d8faad28b108f 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -2267,6 +2267,7 @@ namespace ts.server.protocol { outDir?: string; outFile?: string; paths?: MapLike; + plugins?: PluginImport[]; preserveConstEnums?: boolean; project?: string; reactNamespace?: string; diff --git a/src/server/server.ts b/src/server/server.ts index 9488d8037bbf0..d828a717a4150 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -99,6 +99,8 @@ namespace ts.server { birthtime: Date; } + type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} }; + const readline: { createInterface(options: ReadLineOptions): NodeJS.EventEmitter; } = require("readline"); @@ -593,6 +595,16 @@ namespace ts.server { sys.gc = () => global.gc(); } + sys.require = (initialDir: string, moduleName: string): RequireResult => { + const result = nodeModuleNameResolverWorker(moduleName, initialDir + "/program.ts", { moduleResolution: ts.ModuleResolutionKind.NodeJs, allowJs: true }, sys, undefined, /*jsOnly*/ true); + try { + return { module: require(result.resolvedModule.resolvedFileName), error: undefined }; + } + catch (e) { + return { module: undefined, error: e }; + } + }; + let cancellationToken: ServerCancellationToken; try { const factory = require("./cancellationToken"); diff --git a/src/server/types.ts b/src/server/types.ts index 2c18f275202be..6edf424cbe6b6 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -9,6 +9,7 @@ declare namespace ts.server { data: any; } + type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} }; export interface ServerHost extends System { setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): any; clearTimeout(timeoutId: any): void; @@ -16,6 +17,7 @@ declare namespace ts.server { clearImmediate(timeoutId: any): void; gc?(): void; trace?(s: string): void; + require?(initialPath: string, moduleName: string): RequireResult; } export interface SortedReadonlyArray extends ReadonlyArray { diff --git a/tests/cases/fourslash/server/ngProxy1.ts b/tests/cases/fourslash/server/ngProxy1.ts new file mode 100644 index 0000000000000..9c75a21e83861 --- /dev/null +++ b/tests/cases/fourslash/server/ngProxy1.ts @@ -0,0 +1,19 @@ +/// + +// @Filename: tsconfig.json +//// { +//// "compilerOptions": { +//// "plugins": [ +//// { "name": "quickinfo-augmeneter", "message": "hello world" } +//// ] +//// }, +//// "files": ["a.ts"] +//// } + +// @Filename: a.ts +//// let x = [1, 2]; +//// x/**/ +//// + +goTo.marker(); +verify.quickInfoIs('Proxied x: number[]hello world'); diff --git a/tests/cases/fourslash/server/ngProxy2.ts b/tests/cases/fourslash/server/ngProxy2.ts new file mode 100644 index 0000000000000..33f28eb31d53c --- /dev/null +++ b/tests/cases/fourslash/server/ngProxy2.ts @@ -0,0 +1,20 @@ +/// + +// @Filename: tsconfig.json +//// { +//// "compilerOptions": { +//// "plugins": [ +//// { "name": "invalidmodulename" } +//// ] +//// }, +//// "files": ["a.ts"] +//// } + +// @Filename: a.ts +//// let x = [1, 2]; +//// x/**/ +//// + +// LS shouldn't crash/fail if a plugin fails to load +goTo.marker(); +verify.quickInfoIs('let x: number[]'); diff --git a/tests/cases/fourslash/server/ngProxy3.ts b/tests/cases/fourslash/server/ngProxy3.ts new file mode 100644 index 0000000000000..5b9ea26d1dd88 --- /dev/null +++ b/tests/cases/fourslash/server/ngProxy3.ts @@ -0,0 +1,20 @@ +/// + +// @Filename: tsconfig.json +//// { +//// "compilerOptions": { +//// "plugins": [ +//// { "name": "create-thrower" } +//// ] +//// }, +//// "files": ["a.ts"] +//// } + +// @Filename: a.ts +//// let x = [1, 2]; +//// x/**/ +//// + +// LS shouldn't crash/fail if a plugin fails to init correctly +goTo.marker(); +verify.quickInfoIs('let x: number[]'); diff --git a/tests/cases/fourslash/server/ngProxy4.ts b/tests/cases/fourslash/server/ngProxy4.ts new file mode 100644 index 0000000000000..69455903dfbc9 --- /dev/null +++ b/tests/cases/fourslash/server/ngProxy4.ts @@ -0,0 +1,20 @@ +/// + +// @Filename: tsconfig.json +//// { +//// "compilerOptions": { +//// "plugins": [ +//// { "name": "diagnostic-adder" } +//// ] +//// }, +//// "files": ["a.ts"] +//// } + +// @Filename: a.ts +//// let x = [1, 2]; +//// x/**/ +//// + +// Test adding an error message +goTo.marker(); +verify.numberOfErrorsInCurrentFile(1);