-
Notifications
You must be signed in to change notification settings - Fork 73
Support TS plugins #327
Support TS plugins #327
Changes from all commits
9b9c821
112102d
77429ce
3df438c
64d7378
0d79881
786b0c0
0f0f039
2292e96
7382e8f
791074d
d807ec3
d3e4419
bbfd5b4
e333011
42ce6e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Code is copied from TS project - fix should be made there |
||
} | ||
} | ||
} | ||
|
||
/** | ||
* 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this cast needed / why is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there an open issue for exposing this? If not, could you create one and link it at the top? |
||
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'); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)); | ||
}); | ||
|
||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what is the difference between
combinePaths
andpath.resolve()
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code is copied from TS project, wanted to keep it as close to original as possible (and we had the function already)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, that wasn't clear to me. Maybe change the wording "based on" at the top of the file to "copied from"?