Skip to content
This repository was archived by the owner on Oct 16, 2020. It is now read-only.

Commit 9cd02d0

Browse files
tomv564felixfbecker
authored andcommitted
feat: support language service plugins (#327)
1 parent 9c7cd41 commit 9cd02d0

File tree

7 files changed

+327
-14
lines changed

7 files changed

+327
-14
lines changed

src/diagnostics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function convertTsDiagnostic(diagnostic: ts.Diagnostic): Diagnostic {
1818
message: text,
1919
severity: convertDiagnosticCategory(diagnostic.category),
2020
code: diagnostic.code,
21-
source: 'ts'
21+
source: diagnostic.source || 'ts'
2222
};
2323
}
2424

src/match-files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export function matchFiles(path: string, extensions: string[], excludes: string[
5555

5656
const directorySeparator = '/';
5757

58-
function combinePaths(path1: string, path2: string) {
58+
export function combinePaths(path1: string, path2: string) {
5959
if (!(path1 && path1.length)) return path2;
6060
if (!(path2 && path2.length)) return path1;
6161
if (getRootLength(path2) !== 0) return path2;

src/plugins.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import * as fs from 'mz/fs';
2+
import * as path from 'path';
3+
import * as ts from 'typescript';
4+
import { Logger, NoopLogger } from './logging';
5+
import { combinePaths } from './match-files';
6+
import { PluginSettings } from './request-type';
7+
import { toUnixPath } from './util';
8+
9+
// Based on types and logic from TypeScript server/project.ts @
10+
// https://github.com/Microsoft/TypeScript/blob/711e890e59e10aa05a43cb938474a3d9c2270429/src/server/project.ts
11+
12+
/**
13+
* A plugin exports an initialization function, injected with
14+
* the current typescript instance
15+
*/
16+
export type PluginModuleFactory = (mod: { typescript: typeof ts }) => PluginModule;
17+
18+
export type EnableProxyFunc = (pluginModuleFactory: PluginModuleFactory, pluginConfigEntry: ts.PluginImport) => void;
19+
20+
/**
21+
* A plugin presents this API when initialized
22+
*/
23+
export interface PluginModule {
24+
create(createInfo: PluginCreateInfo): ts.LanguageService;
25+
getExternalFiles?(proj: Project): string[];
26+
}
27+
28+
/**
29+
* All of tsserver's environment exposed to plugins
30+
*/
31+
export interface PluginCreateInfo {
32+
project: Project;
33+
languageService: ts.LanguageService;
34+
languageServiceHost: ts.LanguageServiceHost;
35+
serverHost: ServerHost;
36+
config: any;
37+
}
38+
39+
/**
40+
* The portion of tsserver's Project API exposed to plugins
41+
*/
42+
export interface Project {
43+
projectService: {
44+
logger: Logger;
45+
};
46+
}
47+
48+
/**
49+
* The portion of tsserver's ServerHost API exposed to plugins
50+
*/
51+
export type ServerHost = object;
52+
53+
/**
54+
* The result of a node require: a module or an error.
55+
*/
56+
type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} };
57+
58+
export class PluginLoader {
59+
60+
private allowLocalPluginLoads: boolean = false;
61+
private globalPlugins: string[] = [];
62+
private pluginProbeLocations: string[] = [];
63+
64+
constructor(
65+
private rootFilePath: string,
66+
private fs: ts.ModuleResolutionHost,
67+
pluginSettings?: PluginSettings,
68+
private logger = new NoopLogger(),
69+
private resolutionHost = new LocalModuleResolutionHost(),
70+
private requireModule: (moduleName: string) => any = require) {
71+
if (pluginSettings) {
72+
this.allowLocalPluginLoads = pluginSettings.allowLocalPluginLoads || false;
73+
this.globalPlugins = pluginSettings.globalPlugins || [];
74+
this.pluginProbeLocations = pluginSettings.pluginProbeLocations || [];
75+
}
76+
}
77+
78+
public loadPlugins(options: ts.CompilerOptions, applyProxy: EnableProxyFunc) {
79+
// Search our peer node_modules, then any globally-specified probe paths
80+
// ../../.. to walk from X/node_modules/javascript-typescript-langserver/lib/project-manager.js to X/node_modules/
81+
const searchPaths = [combinePaths(__filename, '../../..'), ...this.pluginProbeLocations];
82+
83+
// Corresponds to --allowLocalPluginLoads, opt-in to avoid remote code execution.
84+
if (this.allowLocalPluginLoads) {
85+
const local = this.rootFilePath;
86+
this.logger.info(`Local plugin loading enabled; adding ${local} to search paths`);
87+
searchPaths.unshift(local);
88+
}
89+
90+
let pluginImports: ts.PluginImport[] = [];
91+
if (options.plugins) {
92+
pluginImports = options.plugins as ts.PluginImport[];
93+
}
94+
95+
// Enable tsconfig-specified plugins
96+
if (options.plugins) {
97+
for (const pluginConfigEntry of pluginImports) {
98+
this.enablePlugin(pluginConfigEntry, searchPaths, applyProxy);
99+
}
100+
}
101+
102+
if (this.globalPlugins) {
103+
// Enable global plugins with synthetic configuration entries
104+
for (const globalPluginName of this.globalPlugins) {
105+
// Skip already-locally-loaded plugins
106+
if (!pluginImports || pluginImports.some(p => p.name === globalPluginName)) {
107+
continue;
108+
}
109+
110+
// Provide global: true so plugins can detect why they can't find their config
111+
this.enablePlugin({ name: globalPluginName, global: true } as ts.PluginImport, searchPaths, applyProxy);
112+
}
113+
}
114+
}
115+
116+
/**
117+
* Tries to load and enable a single plugin
118+
* @param pluginConfigEntry
119+
* @param searchPaths
120+
*/
121+
private enablePlugin(pluginConfigEntry: ts.PluginImport, searchPaths: string[], enableProxy: EnableProxyFunc) {
122+
for (const searchPath of searchPaths) {
123+
const resolvedModule = this.resolveModule(pluginConfigEntry.name, searchPath) as PluginModuleFactory;
124+
if (resolvedModule) {
125+
enableProxy(resolvedModule, pluginConfigEntry);
126+
return;
127+
}
128+
}
129+
this.logger.error(`Couldn't find ${pluginConfigEntry.name} anywhere in paths: ${searchPaths.join(',')}`);
130+
}
131+
132+
/**
133+
* Load a plugin using a node require
134+
* @param moduleName
135+
* @param initialDir
136+
*/
137+
private resolveModule(moduleName: string, initialDir: string): {} | undefined {
138+
const resolvedPath = toUnixPath(path.resolve(combinePaths(initialDir, 'node_modules')));
139+
this.logger.info(`Loading ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`);
140+
const result = this.requirePlugin(resolvedPath, moduleName);
141+
if (result.error) {
142+
this.logger.error(`Failed to load module: ${JSON.stringify(result.error)}`);
143+
return undefined;
144+
}
145+
return result.module;
146+
}
147+
148+
/**
149+
* Resolves a loads a plugin function relative to initialDir
150+
* @param initialDir
151+
* @param moduleName
152+
*/
153+
private requirePlugin(initialDir: string, moduleName: string): RequireResult {
154+
try {
155+
const modulePath = this.resolveJavaScriptModule(moduleName, initialDir, this.fs);
156+
return { module: this.requireModule(modulePath), error: undefined };
157+
} catch (error) {
158+
return { module: undefined, error };
159+
}
160+
}
161+
162+
/**
163+
* Expose resolution logic to allow us to use Node module resolution logic from arbitrary locations.
164+
* No way to do this with `require()`: https://github.com/nodejs/node/issues/5963
165+
* Throws an error if the module can't be resolved.
166+
* stolen from moduleNameResolver.ts because marked as internal
167+
*/
168+
private resolveJavaScriptModule(moduleName: string, initialDir: string, host: ts.ModuleResolutionHost): string {
169+
// TODO: this should set jsOnly=true to the internal resolver, but this parameter is not exposed on a public api.
170+
const result =
171+
ts.nodeModuleNameResolver(
172+
moduleName,
173+
initialDir.replace('\\', '/') + '/package.json', /* containingFile */
174+
{ moduleResolution: ts.ModuleResolutionKind.NodeJs, allowJs: true },
175+
this.resolutionHost,
176+
undefined
177+
);
178+
if (!result.resolvedModule) {
179+
// this.logger.error(result.failedLookupLocations);
180+
throw new Error(`Could not resolve JS module ${moduleName} starting at ${initialDir}.`);
181+
}
182+
return result.resolvedModule.resolvedFileName;
183+
}
184+
}
185+
186+
/**
187+
* A local filesystem-based ModuleResolutionHost for plugin loading.
188+
*/
189+
export class LocalModuleResolutionHost implements ts.ModuleResolutionHost {
190+
fileExists(fileName: string): boolean {
191+
return fs.existsSync(fileName);
192+
}
193+
readFile(fileName: string): string {
194+
return fs.readFileSync(fileName, 'utf8');
195+
}
196+
}

src/project-manager.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { Disposable } from './disposable';
99
import { FileSystemUpdater } from './fs';
1010
import { Logger, NoopLogger } from './logging';
1111
import { InMemoryFileSystem } from './memfs';
12+
import { PluginCreateInfo, PluginLoader, PluginModuleFactory } from './plugins';
13+
import { PluginSettings } from './request-type';
1214
import { traceObservable, traceSync } from './tracing';
1315
import {
1416
isConfigFile,
@@ -100,6 +102,11 @@ export class ProjectManager implements Disposable {
100102
*/
101103
private subscriptions = new Subscription();
102104

105+
/**
106+
* Options passed to the language server at startup
107+
*/
108+
private pluginSettings?: PluginSettings;
109+
103110
/**
104111
* @param rootPath root path as passed to `initialize`
105112
* @param inMemoryFileSystem File system that keeps structure and contents in memory
@@ -111,12 +118,14 @@ export class ProjectManager implements Disposable {
111118
inMemoryFileSystem: InMemoryFileSystem,
112119
updater: FileSystemUpdater,
113120
traceModuleResolution?: boolean,
121+
pluginSettings?: PluginSettings,
114122
protected logger: Logger = new NoopLogger()
115123
) {
116124
this.rootPath = rootPath;
117125
this.updater = updater;
118126
this.inMemoryFs = inMemoryFileSystem;
119127
this.versions = new Map<string, number>();
128+
this.pluginSettings = pluginSettings;
120129
this.traceModuleResolution = traceModuleResolution || false;
121130

122131
// Share DocumentRegistry between all ProjectConfigurations
@@ -144,6 +153,7 @@ export class ProjectManager implements Disposable {
144153
'',
145154
tsConfig,
146155
this.traceModuleResolution,
156+
this.pluginSettings,
147157
this.logger
148158
);
149159
configs.set(trimmedRootPath, config);
@@ -173,6 +183,7 @@ export class ProjectManager implements Disposable {
173183
filePath,
174184
undefined,
175185
this.traceModuleResolution,
186+
this.pluginSettings,
176187
this.logger
177188
));
178189
// Remove catch-all config (if exists)
@@ -802,6 +813,7 @@ export class ProjectConfiguration {
802813
configFilePath: string,
803814
configContent?: any,
804815
traceModuleResolution?: boolean,
816+
private pluginSettings?: PluginSettings,
805817
private logger: Logger = new NoopLogger()
806818
) {
807819
this.fs = fs;
@@ -910,9 +922,38 @@ export class ProjectConfiguration {
910922
this.logger
911923
);
912924
this.service = ts.createLanguageService(this.host, this.documentRegistry);
925+
const pluginLoader = new PluginLoader(this.rootFilePath, this.fs, this.pluginSettings, this.logger);
926+
pluginLoader.loadPlugins(options, (factory, config) => this.wrapService(factory, config));
913927
this.initialized = true;
914928
}
915929

930+
/**
931+
* Replaces the LanguageService with an instance wrapped by the plugin
932+
* @param pluginModuleFactory function to create the module
933+
* @param configEntry extra settings from tsconfig to pass to the plugin module
934+
*/
935+
private wrapService(pluginModuleFactory: PluginModuleFactory, configEntry: ts.PluginImport) {
936+
try {
937+
if (typeof pluginModuleFactory !== 'function') {
938+
this.logger.info(`Skipped loading plugin ${configEntry.name} because it didn't expose a proper factory function`);
939+
return;
940+
}
941+
942+
const info: PluginCreateInfo = {
943+
config: configEntry,
944+
project: { projectService: { logger: this.logger }}, // TODO: may need more support
945+
languageService: this.getService(),
946+
languageServiceHost: this.getHost(),
947+
serverHost: {} // TODO: may need an adapter
948+
};
949+
950+
const pluginModule = pluginModuleFactory({ typescript: ts });
951+
this.service = pluginModule.create(info);
952+
} catch (e) {
953+
this.logger.error(`Plugin activation failed: ${e}`);
954+
}
955+
}
956+
916957
/**
917958
* Ensures we are ready to process files from a given sub-project
918959
*/

src/request-type.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ export interface InitializeParams extends vscode.InitializeParams {
55
capabilities: ClientCapabilities;
66
}
77

8+
/**
9+
* Settings to enable plugin loading
10+
*/
11+
export interface PluginSettings {
12+
allowLocalPluginLoads: boolean;
13+
globalPlugins: string[];
14+
pluginProbeLocations: string[];
15+
}
16+
817
export interface ClientCapabilities extends vscode.ClientCapabilities {
918

1019
/**

src/test/plugins.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import * as path from 'path';
2+
import * as sinon from 'sinon';
3+
import * as ts from 'typescript';
4+
import {InMemoryFileSystem} from '../memfs';
5+
import {PluginLoader, PluginModule, PluginModuleFactory} from '../plugins';
6+
import {PluginSettings} from '../request-type';
7+
import { path2uri } from '../util';
8+
9+
describe('plugins', () => {
10+
describe('loadPlugins()', () => {
11+
it('should do nothing if no plugins are configured', () => {
12+
const memfs = new InMemoryFileSystem('/');
13+
14+
const loader = new PluginLoader('/', memfs);
15+
const compilerOptions: ts.CompilerOptions = {};
16+
const applyProxy: (pluginModuleFactory: PluginModuleFactory) => PluginModule = sinon.spy();
17+
loader.loadPlugins(compilerOptions, applyProxy);
18+
19+
});
20+
21+
it('should load a global plugin if specified', () => {
22+
const memfs = new InMemoryFileSystem('/');
23+
const peerPackagesPath = path.resolve(__filename, '../../../../');
24+
const peerPackagesUri = path2uri(peerPackagesPath);
25+
memfs.add(peerPackagesUri + '/node_modules/some-plugin/package.json', '{ "name": "some-plugin", "version": "0.1.1", "main": "plugin.js"}');
26+
memfs.add(peerPackagesUri + '/node_modules/some-plugin/plugin.js', '');
27+
const pluginSettings: PluginSettings = {
28+
globalPlugins: ['some-plugin'],
29+
allowLocalPluginLoads: false,
30+
pluginProbeLocations: []
31+
};
32+
const pluginFactoryFunc = (modules: any) => 5;
33+
const fakeRequire = (path: string) => pluginFactoryFunc;
34+
const loader = new PluginLoader('/', memfs, pluginSettings, undefined, memfs, fakeRequire);
35+
const compilerOptions: ts.CompilerOptions = {};
36+
const applyProxy = sinon.spy();
37+
loader.loadPlugins(compilerOptions, applyProxy);
38+
sinon.assert.calledOnce(applyProxy);
39+
sinon.assert.calledWithExactly(applyProxy, pluginFactoryFunc, sinon.match({ name: 'some-plugin', global: true}));
40+
});
41+
42+
it('should load a local plugin if specified', () => {
43+
const rootDir = (process.platform === 'win32' ? 'c:\\' : '/') + 'some-project';
44+
const rootUri = path2uri(rootDir) + '/';
45+
const memfs = new InMemoryFileSystem('/some-project');
46+
memfs.add(rootUri + 'node_modules/some-plugin/package.json', '{ "name": "some-plugin", "version": "0.1.1", "main": "plugin.js"}');
47+
memfs.add(rootUri + 'node_modules/some-plugin/plugin.js', '');
48+
const pluginSettings: PluginSettings = {
49+
globalPlugins: [],
50+
allowLocalPluginLoads: true,
51+
pluginProbeLocations: []
52+
};
53+
const pluginFactoryFunc = (modules: any) => 5;
54+
const fakeRequire = (path: string) => pluginFactoryFunc;
55+
const loader = new PluginLoader(rootDir, memfs, pluginSettings, undefined, memfs, fakeRequire);
56+
const pluginOption: ts.PluginImport = {
57+
name: 'some-plugin'
58+
};
59+
const compilerOptions: ts.CompilerOptions = {
60+
plugins: [pluginOption]
61+
};
62+
const applyProxy = sinon.spy();
63+
loader.loadPlugins(compilerOptions, applyProxy);
64+
sinon.assert.calledOnce(applyProxy);
65+
sinon.assert.calledWithExactly(applyProxy, pluginFactoryFunc, sinon.match(pluginOption));
66+
});
67+
68+
});
69+
});

0 commit comments

Comments
 (0)