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

Support TS plugins #327

Merged
merged 16 commits into from
Sep 29, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/match-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
196 changes: 196 additions & 0 deletions src/plugins.ts
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];
Copy link
Contributor

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 and path.resolve()?

Copy link
Contributor Author

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)

Copy link
Contributor

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"?


// 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is global not in the typings?

Copy link
Contributor Author

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 - 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this cast needed / why is resolveModule not typed with that return type?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveModule was not specific to plugin loading, it also was copied and I tried to keep it close to the original.
If it is exposed through the TS api we could remove it from this plugin.ts.

Copy link
Contributor

Choose a reason for hiding this comment

The 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');
}
}
41 changes: 41 additions & 0 deletions src/project-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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<string, number>();
this.pluginSettings = pluginSettings;
this.traceModuleResolution = traceModuleResolution || false;

// Share DocumentRegistry between all ProjectConfigurations
Expand Down Expand Up @@ -144,6 +153,7 @@ export class ProjectManager implements Disposable {
'',
tsConfig,
this.traceModuleResolution,
this.pluginSettings,
this.logger
);
configs.set(trimmedRootPath, config);
Expand Down Expand Up @@ -173,6 +183,7 @@ export class ProjectManager implements Disposable {
filePath,
undefined,
this.traceModuleResolution,
this.pluginSettings,
this.logger
));
// Remove catch-all config (if exists)
Expand Down Expand Up @@ -802,6 +813,7 @@ export class ProjectConfiguration {
configFilePath: string,
configContent?: any,
traceModuleResolution?: boolean,
private pluginSettings?: PluginSettings,
private logger: Logger = new NoopLogger()
) {
this.fs = fs;
Expand Down Expand Up @@ -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
*/
Expand Down
9 changes: 9 additions & 0 deletions src/request-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

/**
Expand Down
69 changes: 69 additions & 0 deletions src/test/plugins.test.ts
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));
});

});
});
Loading