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

Commit 21ac43e

Browse files
committed
fix: extract plugin functionality + add tests
1 parent 08d51b3 commit 21ac43e

File tree

3 files changed

+246
-158
lines changed

3 files changed

+246
-158
lines changed

src/plugins.ts

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

src/project-manager.ts

Lines changed: 4 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import * as ts from 'typescript';
77
import { Disposable } from './disposable';
88
import { FileSystemUpdater } from './fs';
99
import { Logger, NoopLogger } from './logging';
10-
import { combinePaths } from './match-files';
1110
import { InMemoryFileSystem } from './memfs';
11+
import { PluginCreateInfo, PluginLoader, PluginModuleFactory } from './plugins';
1212
import { InitializationOptions } from './request-type';
1313
import { traceObservable, traceSync } from './tracing';
1414
import {
@@ -25,52 +25,6 @@ import {
2525

2626
export type ConfigType = 'js' | 'ts';
2727

28-
// definitions from from TypeScript server/project.ts
29-
30-
/**
31-
* A plugin exports an initialization function, injected with
32-
* the current typescript instance
33-
*/
34-
type PluginModuleFactory = (mod: { typescript: typeof ts }) => PluginModule;
35-
36-
/**
37-
* A plugin presents this API when initialized
38-
*/
39-
interface PluginModule {
40-
create(createInfo: PluginCreateInfo): ts.LanguageService;
41-
getExternalFiles?(proj: Project): string[];
42-
}
43-
44-
/**
45-
* All of tsserver's environment exposed to plugins
46-
*/
47-
interface PluginCreateInfo {
48-
project: Project;
49-
languageService: ts.LanguageService;
50-
languageServiceHost: ts.LanguageServiceHost;
51-
serverHost: ServerHost;
52-
config: any;
53-
}
54-
55-
/**
56-
* The portion of tsserver's Project API exposed to plugins
57-
*/
58-
interface Project {
59-
projectService: {
60-
logger: Logger;
61-
};
62-
}
63-
64-
/**
65-
* The portion of tsserver's ServerHost API exposed to plugins
66-
*/
67-
type ServerHost = object;
68-
69-
/**
70-
* The result of a node require: a module or an error.
71-
*/
72-
type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} };
73-
7428
/**
7529
* ProjectManager translates VFS files to one or many projects denoted by [tj]config.json.
7630
* It uses either local or remote file system to fetch directory tree and files from and then
@@ -780,10 +734,6 @@ export class ProjectConfiguration {
780734
*/
781735
private traceModuleResolution: boolean;
782736

783-
private allowLocalPluginLoads: boolean = false;
784-
private globalPlugins: string[] = [];
785-
private pluginProbeLocations: string[] = [];
786-
787737
/**
788738
* Root file path, relative to workspace hierarchy root
789739
*/
@@ -810,18 +760,13 @@ export class ProjectConfiguration {
810760
configFilePath: string,
811761
configContent?: any,
812762
traceModuleResolution?: boolean,
813-
initializationOptions?: InitializationOptions,
763+
private initializationOptions?: InitializationOptions,
814764
private logger: Logger = new NoopLogger()
815765
) {
816766
this.fs = fs;
817767
this.configFilePath = configFilePath;
818768
this.configContent = configContent;
819769
this.versions = versions;
820-
if (initializationOptions) {
821-
this.allowLocalPluginLoads = initializationOptions.allowLocalPluginLoads || false;
822-
this.globalPlugins = initializationOptions.globalPlugins || [];
823-
this.pluginProbeLocations = initializationOptions.pluginProbeLocations || [];
824-
}
825770
this.traceModuleResolution = traceModuleResolution || false;
826771
this.rootFilePath = rootFilePath;
827772
}
@@ -919,110 +864,11 @@ export class ProjectConfiguration {
919864
this.logger
920865
);
921866
this.service = ts.createLanguageService(this.host, this.documentRegistry);
922-
this.enablePlugins(options);
867+
const pluginLoader = new PluginLoader(this.rootFilePath, this.fs, this.initializationOptions, this.logger);
868+
pluginLoader.loadPlugins(options, this.enableProxy.bind(this));
923869
this.initialized = true;
924870
}
925871

926-
private enablePlugins(options: ts.CompilerOptions) {
927-
// Search our peer node_modules, then any globally-specified probe paths
928-
// ../../.. to walk from X/node_modules/javascript-typescript-langserver/lib/project-manager.js to X/node_modules/
929-
const searchPaths = [combinePaths(__filename, '../../..'), ...this.pluginProbeLocations];
930-
931-
// Corresponds to --allowLocalPluginLoads, opt-in to avoid remote code execution.
932-
if (this.allowLocalPluginLoads) {
933-
const local = this.rootFilePath;
934-
this.logger.info(`Local plugin loading enabled; adding ${local} to search paths`);
935-
searchPaths.unshift(local);
936-
}
937-
938-
let pluginImports: ts.PluginImport[] = [];
939-
if (options.plugins) {
940-
pluginImports = options.plugins as ts.PluginImport[];
941-
}
942-
943-
// Enable tsconfig-specified plugins
944-
if (options.plugins) {
945-
for (const pluginConfigEntry of pluginImports) {
946-
this.enablePlugin(pluginConfigEntry, searchPaths);
947-
}
948-
}
949-
950-
if (this.globalPlugins) {
951-
// Enable global plugins with synthetic configuration entries
952-
for (const globalPluginName of this.globalPlugins) {
953-
// Skip already-locally-loaded plugins
954-
if (!pluginImports || pluginImports.some(p => p.name === globalPluginName)) {
955-
continue;
956-
}
957-
958-
// Provide global: true so plugins can detect why they can't find their config
959-
this.enablePlugin({ name: globalPluginName, global: true } as ts.PluginImport, searchPaths);
960-
}
961-
}
962-
}
963-
964-
/**
965-
* Tries to load and enable a single plugin
966-
* @param pluginConfigEntry
967-
* @param searchPaths
968-
*/
969-
private enablePlugin(pluginConfigEntry: ts.PluginImport, searchPaths: string[]) {
970-
for (const searchPath of searchPaths) {
971-
const resolvedModule = this.resolveModule(pluginConfigEntry.name, searchPath) as PluginModuleFactory;
972-
if (resolvedModule) {
973-
this.enableProxy(resolvedModule, pluginConfigEntry);
974-
return;
975-
}
976-
}
977-
this.logger.info(`Couldn't find ${pluginConfigEntry.name} anywhere in paths: ${searchPaths.join(',')}`);
978-
}
979-
980-
/**
981-
* Load a plugin use a node require
982-
* @param moduleName
983-
* @param initialDir
984-
*/
985-
private resolveModule(moduleName: string, initialDir: string): {} | undefined {
986-
this.logger.info(`Loading ${moduleName} from ${initialDir}`);
987-
const result = this.requirePlugin(initialDir, moduleName);
988-
if (result.error) {
989-
this.logger.info(`Failed to load module: ${JSON.stringify(result.error)}`);
990-
return undefined;
991-
}
992-
return result.module;
993-
}
994-
995-
/**
996-
* Resolves a loads a plugin function relative to initialDir
997-
* @param initialDir
998-
* @param moduleName
999-
*/
1000-
private requirePlugin(initialDir: string, moduleName: string): RequireResult {
1001-
const modulePath = this.resolveJavaScriptModule(moduleName, initialDir, this.fs);
1002-
try {
1003-
return { module: require(modulePath), error: undefined };
1004-
} catch (error) {
1005-
return { module: undefined, error };
1006-
}
1007-
}
1008-
1009-
/**
1010-
* Expose resolution logic to allow us to use Node module resolution logic from arbitrary locations.
1011-
* No way to do this with `require()`: https://github.com/nodejs/node/issues/5963
1012-
* Throws an error if the module can't be resolved.
1013-
* stolen from moduleNameResolver.ts because marked as internal
1014-
*/
1015-
private resolveJavaScriptModule(moduleName: string, initialDir: string, host: ts.ModuleResolutionHost): string {
1016-
// TODO: this should set jsOnly=true to the internal resolver, but this parameter is not exposed on a public api.
1017-
const result =
1018-
ts.nodeModuleNameResolver(moduleName, /* containingFile */ initialDir.replace('\\', '/') + '/package.json', { moduleResolution: ts.ModuleResolutionKind.NodeJs, allowJs: true }, this.fs, undefined);
1019-
if (!result.resolvedModule) {
1020-
// this.logger.error(result.failedLookupLocations);
1021-
throw new Error(`Could not resolve JS module ${moduleName} starting at ${initialDir}.`);
1022-
}
1023-
return result.resolvedModule.resolvedFileName;
1024-
}
1025-
1026872
/**
1027873
* Replaces the LanguageService with an instance wrapped by the plugin
1028874
* @param pluginModuleFactory function to create the module

0 commit comments

Comments
 (0)