diff --git a/src/diagnostics.ts b/src/diagnostics.ts index ca239d370..bf0397262 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -18,7 +18,7 @@ export function convertTsDiagnostic(diagnostic: ts.Diagnostic): Diagnostic { message: text, severity: convertDiagnosticCategory(diagnostic.category), code: diagnostic.code, - source: 'ts' + source: diagnostic.source || 'ts' }; } diff --git a/src/match-files.ts b/src/match-files.ts index f3b45202e..6d38ba38d 100644 --- a/src/match-files.ts +++ b/src/match-files.ts @@ -55,7 +55,7 @@ export function matchFiles(path: string, extensions: string[], excludes: string[ const directorySeparator = '/'; -function combinePaths(path1: string, path2: string) { +export function combinePaths(path1: string, path2: string) { if (!(path1 && path1.length)) return path2; if (!(path2 && path2.length)) return path1; if (getRootLength(path2) !== 0) return path2; diff --git a/src/plugins.ts b/src/plugins.ts new file mode 100644 index 000000000..4221a18f0 --- /dev/null +++ b/src/plugins.ts @@ -0,0 +1,196 @@ +import * as fs from 'mz/fs'; +import * as path from 'path'; +import * as ts from 'typescript'; +import { Logger, NoopLogger } from './logging'; +import { combinePaths } from './match-files'; +import { PluginSettings } from './request-type'; +import { toUnixPath } from './util'; + +// Based on types and logic from TypeScript server/project.ts @ +// https://github.com/Microsoft/TypeScript/blob/711e890e59e10aa05a43cb938474a3d9c2270429/src/server/project.ts + +/** + * A plugin exports an initialization function, injected with + * the current typescript instance + */ +export type PluginModuleFactory = (mod: { typescript: typeof ts }) => PluginModule; + +export type EnableProxyFunc = (pluginModuleFactory: PluginModuleFactory, pluginConfigEntry: ts.PluginImport) => void; + +/** + * A plugin presents this API when initialized + */ +export interface PluginModule { + create(createInfo: PluginCreateInfo): ts.LanguageService; + getExternalFiles?(proj: Project): string[]; +} + +/** + * All of tsserver's environment exposed to plugins + */ +export interface PluginCreateInfo { + project: Project; + languageService: ts.LanguageService; + languageServiceHost: ts.LanguageServiceHost; + serverHost: ServerHost; + config: any; +} + +/** + * The portion of tsserver's Project API exposed to plugins + */ +export interface Project { + projectService: { + logger: Logger; + }; +} + +/** + * The portion of tsserver's ServerHost API exposed to plugins + */ +export type ServerHost = object; + +/** + * The result of a node require: a module or an error. + */ +type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} }; + +export class PluginLoader { + + private allowLocalPluginLoads: boolean = false; + private globalPlugins: string[] = []; + private pluginProbeLocations: string[] = []; + + constructor( + private rootFilePath: string, + private fs: ts.ModuleResolutionHost, + pluginSettings?: PluginSettings, + private logger = new NoopLogger(), + private resolutionHost = new LocalModuleResolutionHost(), + private requireModule: (moduleName: string) => any = require) { + if (pluginSettings) { + this.allowLocalPluginLoads = pluginSettings.allowLocalPluginLoads || false; + this.globalPlugins = pluginSettings.globalPlugins || []; + this.pluginProbeLocations = pluginSettings.pluginProbeLocations || []; + } + } + + public loadPlugins(options: ts.CompilerOptions, applyProxy: EnableProxyFunc) { + // Search our peer node_modules, then any globally-specified probe paths + // ../../.. to walk from X/node_modules/javascript-typescript-langserver/lib/project-manager.js to X/node_modules/ + const searchPaths = [combinePaths(__filename, '../../..'), ...this.pluginProbeLocations]; + + // Corresponds to --allowLocalPluginLoads, opt-in to avoid remote code execution. + if (this.allowLocalPluginLoads) { + const local = this.rootFilePath; + this.logger.info(`Local plugin loading enabled; adding ${local} to search paths`); + searchPaths.unshift(local); + } + + let pluginImports: ts.PluginImport[] = []; + if (options.plugins) { + pluginImports = options.plugins as ts.PluginImport[]; + } + + // Enable tsconfig-specified plugins + if (options.plugins) { + for (const pluginConfigEntry of pluginImports) { + this.enablePlugin(pluginConfigEntry, searchPaths, applyProxy); + } + } + + if (this.globalPlugins) { + // Enable global plugins with synthetic configuration entries + for (const globalPluginName of this.globalPlugins) { + // Skip already-locally-loaded plugins + if (!pluginImports || pluginImports.some(p => p.name === globalPluginName)) { + continue; + } + + // Provide global: true so plugins can detect why they can't find their config + this.enablePlugin({ name: globalPluginName, global: true } as ts.PluginImport, searchPaths, applyProxy); + } + } + } + + /** + * Tries to load and enable a single plugin + * @param pluginConfigEntry + * @param searchPaths + */ + private enablePlugin(pluginConfigEntry: ts.PluginImport, searchPaths: string[], enableProxy: EnableProxyFunc) { + for (const searchPath of searchPaths) { + const resolvedModule = this.resolveModule(pluginConfigEntry.name, searchPath) as PluginModuleFactory; + if (resolvedModule) { + enableProxy(resolvedModule, pluginConfigEntry); + return; + } + } + this.logger.error(`Couldn't find ${pluginConfigEntry.name} anywhere in paths: ${searchPaths.join(',')}`); + } + + /** + * Load a plugin using a node require + * @param moduleName + * @param initialDir + */ + private resolveModule(moduleName: string, initialDir: string): {} | undefined { + const resolvedPath = toUnixPath(path.resolve(combinePaths(initialDir, 'node_modules'))); + this.logger.info(`Loading ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`); + const result = this.requirePlugin(resolvedPath, moduleName); + if (result.error) { + this.logger.error(`Failed to load module: ${JSON.stringify(result.error)}`); + return undefined; + } + return result.module; + } + + /** + * Resolves a loads a plugin function relative to initialDir + * @param initialDir + * @param moduleName + */ + private requirePlugin(initialDir: string, moduleName: string): RequireResult { + try { + const modulePath = this.resolveJavaScriptModule(moduleName, initialDir, this.fs); + return { module: this.requireModule(modulePath), error: undefined }; + } catch (error) { + return { module: undefined, error }; + } + } + + /** + * Expose resolution logic to allow us to use Node module resolution logic from arbitrary locations. + * No way to do this with `require()`: https://github.com/nodejs/node/issues/5963 + * Throws an error if the module can't be resolved. + * stolen from moduleNameResolver.ts because marked as internal + */ + private resolveJavaScriptModule(moduleName: string, initialDir: string, host: ts.ModuleResolutionHost): string { + // TODO: this should set jsOnly=true to the internal resolver, but this parameter is not exposed on a public api. + const result = + ts.nodeModuleNameResolver( + moduleName, + initialDir.replace('\\', '/') + '/package.json', /* containingFile */ + { moduleResolution: ts.ModuleResolutionKind.NodeJs, allowJs: true }, + this.resolutionHost, + undefined + ); + if (!result.resolvedModule) { + // this.logger.error(result.failedLookupLocations); + throw new Error(`Could not resolve JS module ${moduleName} starting at ${initialDir}.`); + } + return result.resolvedModule.resolvedFileName; + } +} + +/** + * A local filesystem-based ModuleResolutionHost for plugin loading. + */ +export class LocalModuleResolutionHost implements ts.ModuleResolutionHost { + fileExists(fileName: string): boolean { + return fs.existsSync(fileName); + } + readFile(fileName: string): string { + return fs.readFileSync(fileName, 'utf8'); + } +} diff --git a/src/project-manager.ts b/src/project-manager.ts index 96831cc41..dd5dbf680 100644 --- a/src/project-manager.ts +++ b/src/project-manager.ts @@ -9,6 +9,8 @@ import { Disposable } from './disposable'; import { FileSystemUpdater } from './fs'; import { Logger, NoopLogger } from './logging'; import { InMemoryFileSystem } from './memfs'; +import { PluginCreateInfo, PluginLoader, PluginModuleFactory } from './plugins'; +import { PluginSettings } from './request-type'; import { traceObservable, traceSync } from './tracing'; import { isConfigFile, @@ -100,6 +102,11 @@ export class ProjectManager implements Disposable { */ private subscriptions = new Subscription(); + /** + * Options passed to the language server at startup + */ + private pluginSettings?: PluginSettings; + /** * @param rootPath root path as passed to `initialize` * @param inMemoryFileSystem File system that keeps structure and contents in memory @@ -111,12 +118,14 @@ export class ProjectManager implements Disposable { inMemoryFileSystem: InMemoryFileSystem, updater: FileSystemUpdater, traceModuleResolution?: boolean, + pluginSettings?: PluginSettings, protected logger: Logger = new NoopLogger() ) { this.rootPath = rootPath; this.updater = updater; this.inMemoryFs = inMemoryFileSystem; this.versions = new Map(); + this.pluginSettings = pluginSettings; this.traceModuleResolution = traceModuleResolution || false; // Share DocumentRegistry between all ProjectConfigurations @@ -144,6 +153,7 @@ export class ProjectManager implements Disposable { '', tsConfig, this.traceModuleResolution, + this.pluginSettings, this.logger ); configs.set(trimmedRootPath, config); @@ -173,6 +183,7 @@ export class ProjectManager implements Disposable { filePath, undefined, this.traceModuleResolution, + this.pluginSettings, this.logger )); // Remove catch-all config (if exists) @@ -802,6 +813,7 @@ export class ProjectConfiguration { configFilePath: string, configContent?: any, traceModuleResolution?: boolean, + private pluginSettings?: PluginSettings, private logger: Logger = new NoopLogger() ) { this.fs = fs; @@ -910,9 +922,38 @@ export class ProjectConfiguration { this.logger ); this.service = ts.createLanguageService(this.host, this.documentRegistry); + const pluginLoader = new PluginLoader(this.rootFilePath, this.fs, this.pluginSettings, this.logger); + pluginLoader.loadPlugins(options, (factory, config) => this.wrapService(factory, config)); this.initialized = true; } + /** + * Replaces the LanguageService with an instance wrapped by the plugin + * @param pluginModuleFactory function to create the module + * @param configEntry extra settings from tsconfig to pass to the plugin module + */ + private wrapService(pluginModuleFactory: PluginModuleFactory, configEntry: ts.PluginImport) { + try { + if (typeof pluginModuleFactory !== 'function') { + this.logger.info(`Skipped loading plugin ${configEntry.name} because it didn't expose a proper factory function`); + return; + } + + const info: PluginCreateInfo = { + config: configEntry, + project: { projectService: { logger: this.logger }}, // TODO: may need more support + languageService: this.getService(), + languageServiceHost: this.getHost(), + serverHost: {} // TODO: may need an adapter + }; + + const pluginModule = pluginModuleFactory({ typescript: ts }); + this.service = pluginModule.create(info); + } catch (e) { + this.logger.error(`Plugin activation failed: ${e}`); + } + } + /** * Ensures we are ready to process files from a given sub-project */ diff --git a/src/request-type.ts b/src/request-type.ts index 7cbd3c3e0..e2f8dd435 100644 --- a/src/request-type.ts +++ b/src/request-type.ts @@ -5,6 +5,15 @@ export interface InitializeParams extends vscode.InitializeParams { capabilities: ClientCapabilities; } +/** + * Settings to enable plugin loading + */ +export interface PluginSettings { + allowLocalPluginLoads: boolean; + globalPlugins: string[]; + pluginProbeLocations: string[]; +} + export interface ClientCapabilities extends vscode.ClientCapabilities { /** diff --git a/src/test/plugins.test.ts b/src/test/plugins.test.ts new file mode 100644 index 000000000..ea4037f59 --- /dev/null +++ b/src/test/plugins.test.ts @@ -0,0 +1,69 @@ +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as ts from 'typescript'; +import {InMemoryFileSystem} from '../memfs'; +import {PluginLoader, PluginModule, PluginModuleFactory} from '../plugins'; +import {PluginSettings} from '../request-type'; +import { path2uri } from '../util'; + +describe('plugins', () => { + describe('loadPlugins()', () => { + it('should do nothing if no plugins are configured', () => { + const memfs = new InMemoryFileSystem('/'); + + const loader = new PluginLoader('/', memfs); + const compilerOptions: ts.CompilerOptions = {}; + const applyProxy: (pluginModuleFactory: PluginModuleFactory) => PluginModule = sinon.spy(); + loader.loadPlugins(compilerOptions, applyProxy); + + }); + + it('should load a global plugin if specified', () => { + const memfs = new InMemoryFileSystem('/'); + const peerPackagesPath = path.resolve(__filename, '../../../../'); + const peerPackagesUri = path2uri(peerPackagesPath); + memfs.add(peerPackagesUri + '/node_modules/some-plugin/package.json', '{ "name": "some-plugin", "version": "0.1.1", "main": "plugin.js"}'); + memfs.add(peerPackagesUri + '/node_modules/some-plugin/plugin.js', ''); + const pluginSettings: PluginSettings = { + globalPlugins: ['some-plugin'], + allowLocalPluginLoads: false, + pluginProbeLocations: [] + }; + const pluginFactoryFunc = (modules: any) => 5; + const fakeRequire = (path: string) => pluginFactoryFunc; + const loader = new PluginLoader('/', memfs, pluginSettings, undefined, memfs, fakeRequire); + const compilerOptions: ts.CompilerOptions = {}; + const applyProxy = sinon.spy(); + loader.loadPlugins(compilerOptions, applyProxy); + sinon.assert.calledOnce(applyProxy); + sinon.assert.calledWithExactly(applyProxy, pluginFactoryFunc, sinon.match({ name: 'some-plugin', global: true})); + }); + + it('should load a local plugin if specified', () => { + const rootDir = (process.platform === 'win32' ? 'c:\\' : '/') + 'some-project'; + const rootUri = path2uri(rootDir) + '/'; + const memfs = new InMemoryFileSystem('/some-project'); + memfs.add(rootUri + 'node_modules/some-plugin/package.json', '{ "name": "some-plugin", "version": "0.1.1", "main": "plugin.js"}'); + memfs.add(rootUri + 'node_modules/some-plugin/plugin.js', ''); + const pluginSettings: PluginSettings = { + globalPlugins: [], + allowLocalPluginLoads: true, + pluginProbeLocations: [] + }; + const pluginFactoryFunc = (modules: any) => 5; + const fakeRequire = (path: string) => pluginFactoryFunc; + const loader = new PluginLoader(rootDir, memfs, pluginSettings, undefined, memfs, fakeRequire); + const pluginOption: ts.PluginImport = { + name: 'some-plugin' + }; + const compilerOptions: ts.CompilerOptions = { + plugins: [pluginOption] + }; + const applyProxy = sinon.spy(); + loader.loadPlugins(compilerOptions, applyProxy); + sinon.assert.calledOnce(applyProxy); + sinon.assert.calledWithExactly(applyProxy, pluginFactoryFunc, sinon.match(pluginOption)); + }); + + }); +}); diff --git a/src/typescript-service.ts b/src/typescript-service.ts index fb1030452..4c6e6f116 100644 --- a/src/typescript-service.ts +++ b/src/typescript-service.ts @@ -49,6 +49,7 @@ import { InitializeResult, PackageDescriptor, PackageInformation, + PluginSettings, ReferenceInformation, SymbolDescriptor, SymbolLocationInformation, @@ -86,7 +87,7 @@ export type TypeScriptServiceFactory = (client: LanguageClient, options?: TypeSc /** * Settings synced through `didChangeConfiguration` */ -export interface Settings { +export interface Settings extends PluginSettings { format: ts.FormatCodeSettings; } @@ -171,7 +172,10 @@ export class TypeScriptService { insertSpaceBeforeFunctionParenthesis: false, placeOpenBraceOnNewLineForFunctions: false, placeOpenBraceOnNewLineForControlBlocks: false - } + }, + allowLocalPluginLoads: false, + globalPlugins: [], + pluginProbeLocations: [] }; /** @@ -223,6 +227,7 @@ export class TypeScriptService { this.inMemoryFileSystem, this.updater, this.traceModuleResolution, + this.settings, this.logger ); this.packageManager = new PackageManager(this.updater, this.inMemoryFileSystem, this.logger); @@ -311,7 +316,7 @@ export class TypeScriptService { * A notification sent from the client to the server to signal the change of configuration * settings. */ - didChangeConfiguration(params: DidChangeConfigurationParams): void { + workspaceDidChangeConfiguration(params: DidChangeConfigurationParams): void { merge(this.settings, params.settings); } @@ -1337,15 +1342,8 @@ export class TypeScriptService { if (!config) { return; } - const program = config.getProgram(span); - if (!program) { - return; - } - const sourceFile = program.getSourceFile(uri2path(uri)); - if (!sourceFile) { - return; - } - const tsDiagnostics = ts.getPreEmitDiagnostics(program, sourceFile); + const fileName = uri2path(uri); + const tsDiagnostics = config.getService().getSyntacticDiagnostics(fileName).concat(config.getService().getSemanticDiagnostics(fileName)); const diagnostics = iterate(tsDiagnostics) // TS can report diagnostics without a file and range in some cases // These cannot be represented as LSP Diagnostics since the range and URI is required