Language service extensibility

This commit is contained in:
Ryan Cavanaugh 2017-02-14 13:35:16 -08:00
parent 81f4e38643
commit aec310996c
14 changed files with 325 additions and 12 deletions

View File

@ -167,7 +167,7 @@ function generateProtocolFile(protocolTs: string, typeScriptServicesDts: string)
const sanityCheckProgram = getProgramWithProtocolText(protocolDts, /*includeTypeScriptServices*/ false);
const diagnostics = [...sanityCheckProgram.getSyntacticDiagnostics(), ...sanityCheckProgram.getSemanticDiagnostics(), ...sanityCheckProgram.getGlobalDiagnostics()];
if (diagnostics.length) {
const flattenedDiagnostics = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n")).join("\n");
const flattenedDiagnostics = diagnostics.map(d => `${ts.flattenDiagnosticMessageText(d.messageText, "\n")} at ${d.file.fileName} line ${d.start}`).join("\n");
throw new Error(`Unexpected errors during sanity check: ${flattenedDiagnostics}`);
}
return protocolDts;

View File

@ -459,6 +459,16 @@ namespace ts {
name: "alwaysStrict",
type: "boolean",
description: Diagnostics.Parse_in_strict_mode_and_emit_use_strict_for_each_source_file
},
{
// A list of plugins to load in the language service
name: "plugins",
type: "list",
isTSConfigOnly: true,
element: {
name: "plugin",
type: "object"
}
}
];

View File

@ -675,13 +675,18 @@ namespace ts {
}
export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache): ResolvedModuleWithFailedLookupLocations {
return nodeModuleNameResolverWorker(moduleName, containingFile, compilerOptions, host, cache, /* jsOnly*/ false);
}
/* @internal */
export function nodeModuleNameResolverWorker(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, jsOnly = false): ResolvedModuleWithFailedLookupLocations {
const containingDirectory = getDirectoryPath(containingFile);
const traceEnabled = isTraceEnabled(compilerOptions, host);
const failedLookupLocations: string[] = [];
const state: ModuleResolutionState = { compilerOptions, host, traceEnabled };
const result = tryResolve(Extensions.TypeScript) || tryResolve(Extensions.JavaScript);
const result = jsOnly ? tryResolve(Extensions.JavaScript) : (tryResolve(Extensions.TypeScript) || tryResolve(Extensions.JavaScript));
if (result && result.value) {
const { resolved, isExternalLibraryImport } = result.value;
return createResolvedModuleWithFailedLookupLocations(resolved, isExternalLibraryImport, failedLookupLocations);

View File

@ -3215,7 +3215,11 @@
NodeJs = 2
}
export type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike<string[]>;
export interface PluginImport {
name: string
}
export type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike<string[]> | PluginImport[];
export interface CompilerOptions {
allowJs?: boolean;
@ -3270,6 +3274,7 @@
outDir?: string;
outFile?: string;
paths?: MapLike<string[]>;
/*@internal*/ plugins?: PluginImport[];
preserveConstEnums?: boolean;
project?: string;
/* @internal */ pretty?: DiagnosticStyle;
@ -3353,7 +3358,8 @@
JS = 1,
JSX = 2,
TS = 3,
TSX = 4
TSX = 4,
External = 5
}
export const enum ScriptTarget {
@ -3428,7 +3434,7 @@
/* @internal */
export interface CommandLineOptionOfListType extends CommandLineOptionBase {
type: "list";
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType;
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType | TsConfigOnlyOption;
}
/* @internal */

View File

@ -263,6 +263,20 @@ namespace FourSlash {
// Create map between fileName and its content for easily looking up when resolveReference flag is specified
this.inputFiles.set(file.fileName, file.content);
if (ts.getBaseFileName(file.fileName).toLowerCase() === "tsconfig.json") {
const configJson = ts.parseConfigFileTextToJson(file.fileName, file.content);
if (configJson.config === undefined) {
throw new Error(`Failed to parse test tsconfig.json: ${configJson.error.messageText}`);
}
// Extend our existing compiler options so that we can also support tsconfig only options
if (configJson.config.compilerOptions) {
const baseDirectory = ts.normalizePath(ts.getDirectoryPath(file.fileName));
const tsConfig = ts.convertCompilerOptionsFromJson(configJson.config.compilerOptions, baseDirectory, file.fileName);
if (!tsConfig.errors || !tsConfig.errors.length) {
compilationOptions = ts.extend(compilationOptions, tsConfig.options);
}
}
configFileName = file.fileName;
}

View File

@ -126,7 +126,7 @@ namespace Harness.LanguageService {
protected virtualFileSystem: Utils.VirtualFileSystem = new Utils.VirtualFileSystem(virtualFileSystemRoot, /*useCaseSensitiveFilenames*/false);
constructor(protected cancellationToken = DefaultHostCancellationToken.Instance,
protected settings = ts.getDefaultCompilerOptions()) {
protected settings = ts.getDefaultCompilerOptions()) {
}
public getNewLine(): string {
@ -135,7 +135,7 @@ namespace Harness.LanguageService {
public getFilenames(): string[] {
const fileNames: string[] = [];
for (const virtualEntry of this.virtualFileSystem.getAllFileEntries()){
for (const virtualEntry of this.virtualFileSystem.getAllFileEntries()) {
const scriptInfo = virtualEntry.content;
if (scriptInfo.isRootFile) {
// only include root files here
@ -211,8 +211,8 @@ namespace Harness.LanguageService {
readDirectory(path: string, extensions?: string[], exclude?: string[], include?: string[]): string[] {
return ts.matchFiles(path, extensions, exclude, include,
/*useCaseSensitiveFileNames*/false,
this.getCurrentDirectory(),
(p) => this.virtualFileSystem.getAccessibleFileSystemEntries(p));
this.getCurrentDirectory(),
(p) => this.virtualFileSystem.getAccessibleFileSystemEntries(p));
}
readFile(path: string): string {
const snapshot = this.getScriptSnapshot(path);
@ -724,6 +724,87 @@ namespace Harness.LanguageService {
createHash(s: string) {
return s;
}
require(_initialDir: string, _moduleName: string): ts.server.RequireResult {
switch (_moduleName) {
// Adds to the Quick Info a fixed string and a string from the config file
// and replaces the first display part
case "quickinfo-augmeneter":
return {
module: () => ({
create(info: ts.server.PluginCreateInfo) {
const proxy = makeDefaultProxy(info);
const langSvc: any = info.languageService;
proxy.getQuickInfoAtPosition = function () {
const parts = langSvc.getQuickInfoAtPosition.apply(langSvc, arguments);
if (parts.displayParts.length > 0) {
parts.displayParts[0].text = "Proxied";
}
parts.displayParts.push({ text: info.config.message, kind: "punctuation" });
return parts;
};
return proxy;
}
}),
error: undefined
};
// Throws during initialization
case "create-thrower":
return {
module: () => ({
create() {
throw new Error("I am not a well-behaved plugin");
}
}),
error: undefined
};
// Adds another diagnostic
case "diagnostic-adder":
return {
module: () => ({
create(info: ts.server.PluginCreateInfo) {
const proxy = makeDefaultProxy(info);
proxy.getSemanticDiagnostics = function (filename: string) {
const prev = info.languageService.getSemanticDiagnostics(filename);
const sourceFile: ts.SourceFile = info.languageService.getSourceFile(filename);
prev.push({
category: ts.DiagnosticCategory.Warning,
file: sourceFile,
code: 9999,
length: 3,
messageText: `Plugin diagnostic`,
start: 0
});
return prev;
}
return proxy;
}
}),
error: undefined
};
default:
return {
module: undefined,
error: "Could not resolve module"
};
}
function makeDefaultProxy(info: ts.server.PluginCreateInfo) {
// tslint:disable-next-line:no-null-keyword
const proxy = Object.create(null);
const langSvc: any = info.languageService;
for (const k of Object.keys(langSvc)) {
proxy[k] = function () {
return langSvc[k].apply(langSvc, arguments);
};
}
return proxy;
}
}
}
export class ServerLanguageServiceAdapter implements LanguageServiceAdapter {

View File

@ -91,19 +91,38 @@ namespace ts.server {
}
}
export interface PluginCreateInfo {
project: Project;
languageService: LanguageService;
languageServiceHost: LanguageServiceHost;
serverHost: ServerHost;
config: any;
}
export interface PluginModule {
create(createInfo: PluginCreateInfo): LanguageService;
getExternalFiles?(proj: Project): string[];
}
export interface PluginModuleFactory {
(mod: { typescript: typeof ts }): PluginModule;
}
export abstract class Project {
private rootFiles: ScriptInfo[] = [];
private rootFilesMap: FileMap<ScriptInfo> = createFileMap<ScriptInfo>();
private lsHost: LSHost;
private program: ts.Program;
private cachedUnresolvedImportsPerFile = new UnresolvedImportsMap();
private lastCachedUnresolvedImportsList: SortedReadonlyArray<string>;
private readonly languageService: LanguageService;
// wrapper over the real language service that will suppress all semantic operations
protected languageService: LanguageService;
public languageServiceEnabled = true;
protected readonly lsHost: LSHost;
builder: Builder;
/**
* Set of files names that were updated since the last call to getChangesSinceVersion.
@ -150,6 +169,17 @@ namespace ts.server {
return this.cachedUnresolvedImportsPerFile;
}
public static resolveModule(moduleName: string, initialDir: string, host: ServerHost, log: (message: string) => void): {} {
const resolvedPath = normalizeSlashes(host.resolvePath(combinePaths(initialDir, "node_modules")));
log(`Loading ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`);
const result = host.require(resolvedPath, moduleName);
if (result.error) {
log(`Failed to load module: ${JSON.stringify(result.error)}`);
return undefined;
}
return result.module;
}
constructor(
private readonly projectName: string,
readonly projectKind: ProjectKind,
@ -237,6 +267,10 @@ namespace ts.server {
abstract getProjectRootPath(): string | undefined;
abstract getTypeAcquisition(): TypeAcquisition;
getExternalFiles(): string[] {
return [];
}
getSourceFile(path: Path) {
if (!this.program) {
return undefined;
@ -804,10 +838,12 @@ namespace ts.server {
private typeRootsWatchers: FileWatcher[];
readonly canonicalConfigFilePath: NormalizedPath;
private plugins: PluginModule[] = [];
/** Used for configured projects which may have multiple open roots */
openRefCount = 0;
constructor(configFileName: NormalizedPath,
constructor(private configFileName: NormalizedPath,
projectService: ProjectService,
documentRegistry: ts.DocumentRegistry,
hasExplicitListOfFiles: boolean,
@ -817,12 +853,64 @@ namespace ts.server {
public compileOnSaveEnabled: boolean) {
super(configFileName, ProjectKind.Configured, projectService, documentRegistry, hasExplicitListOfFiles, languageServiceEnabled, compilerOptions, compileOnSaveEnabled);
this.canonicalConfigFilePath = asNormalizedPath(projectService.toCanonicalFileName(configFileName));
this.enablePlugins();
}
getConfigFilePath() {
return this.getProjectName();
}
enablePlugins() {
const host = this.projectService.host;
const options = this.getCompilerOptions();
const log = (message: string) => {
this.projectService.logger.info(message);
};
if (!(options.plugins && options.plugins.length)) {
this.projectService.logger.info("No plugins exist");
// No plugins
return;
}
if (!host.require) {
this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
return;
}
for (const pluginConfigEntry of options.plugins) {
const searchPath = getDirectoryPath(this.configFileName);
const resolvedModule = <PluginModuleFactory>Project.resolveModule(pluginConfigEntry.name, searchPath, host, log);
if (resolvedModule) {
this.enableProxy(resolvedModule, pluginConfigEntry);
}
}
}
private enableProxy(pluginModuleFactory: PluginModuleFactory, configEntry: PluginImport) {
try {
if (typeof pluginModuleFactory !== "function") {
this.projectService.logger.info(`Skipped loading plugin ${configEntry.name} because it did expose a proper factory function`);
return;
}
const info: PluginCreateInfo = {
config: configEntry,
project: this,
languageService: this.languageService,
languageServiceHost: this.lsHost,
serverHost: this.projectService.host
};
const pluginModule = pluginModuleFactory({ typescript: ts });
this.languageService = pluginModule.create(info);
this.plugins.push(pluginModule);
}
catch (e) {
this.projectService.logger.info(`Plugin activation failed: ${e}`);
}
}
getProjectRootPath() {
return getDirectoryPath(this.getConfigFilePath());
}
@ -839,6 +927,21 @@ namespace ts.server {
return this.typeAcquisition;
}
getExternalFiles(): string[] {
const items: string[] = [];
for (const plugin of this.plugins) {
if (typeof plugin.getExternalFiles === "function") {
try {
items.push(...plugin.getExternalFiles(this));
}
catch (e) {
this.projectService.logger.info(`A plugin threw an exception in getExternalFiles: ${e}`);
}
}
}
return items;
}
watchConfigFile(callback: (project: ConfiguredProject) => void) {
this.projectFileWatcher = this.projectService.host.watchFile(this.getConfigFilePath(), _ => callback(this));
}

View File

@ -2267,6 +2267,7 @@ namespace ts.server.protocol {
outDir?: string;
outFile?: string;
paths?: MapLike<string[]>;
plugins?: PluginImport[];
preserveConstEnums?: boolean;
project?: string;
reactNamespace?: string;

View File

@ -99,6 +99,8 @@ namespace ts.server {
birthtime: Date;
}
type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} };
const readline: {
createInterface(options: ReadLineOptions): NodeJS.EventEmitter;
} = require("readline");
@ -593,6 +595,16 @@ namespace ts.server {
sys.gc = () => global.gc();
}
sys.require = (initialDir: string, moduleName: string): RequireResult => {
const result = nodeModuleNameResolver(moduleName, initialDir + "/program.ts", { moduleResolution: ts.ModuleResolutionKind.NodeJs, allowJs: true }, sys, undefined, /*jsOnly*/ true);
try {
return { module: require(result.resolvedModule.resolvedFileName), error: undefined };
}
catch (e) {
return { module: undefined, error: e };
}
};
let cancellationToken: ServerCancellationToken;
try {
const factory = require("./cancellationToken");

View File

@ -9,6 +9,7 @@ declare namespace ts.server {
data: any;
}
type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} };
export interface ServerHost extends System {
setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): any;
clearTimeout(timeoutId: any): void;
@ -16,6 +17,7 @@ declare namespace ts.server {
clearImmediate(timeoutId: any): void;
gc?(): void;
trace?(s: string): void;
require?(initialPath: string, moduleName: string): RequireResult;
}
export interface SortedReadonlyArray<T> extends ReadonlyArray<T> {

View File

@ -0,0 +1,19 @@
/// <reference path="../fourslash.ts"/>
// @Filename: tsconfig.json
//// {
//// "compilerOptions": {
//// "plugins": [
//// { "name": "quickinfo-augmeneter", "message": "hello world" }
//// ]
//// },
//// "files": ["a.ts"]
//// }
// @Filename: a.ts
//// let x = [1, 2];
//// x/**/
////
goTo.marker();
verify.quickInfoIs('Proxied x: number[]hello world');

View File

@ -0,0 +1,20 @@
/// <reference path="../fourslash.ts"/>
// @Filename: tsconfig.json
//// {
//// "compilerOptions": {
//// "plugins": [
//// { "name": "invalidmodulename" }
//// ]
//// },
//// "files": ["a.ts"]
//// }
// @Filename: a.ts
//// let x = [1, 2];
//// x/**/
////
// LS shouldn't crash/fail if a plugin fails to load
goTo.marker();
verify.quickInfoIs('let x: number[]');

View File

@ -0,0 +1,20 @@
/// <reference path="../fourslash.ts"/>
// @Filename: tsconfig.json
//// {
//// "compilerOptions": {
//// "plugins": [
//// { "name": "create-thrower" }
//// ]
//// },
//// "files": ["a.ts"]
//// }
// @Filename: a.ts
//// let x = [1, 2];
//// x/**/
////
// LS shouldn't crash/fail if a plugin fails to init correctly
goTo.marker();
verify.quickInfoIs('let x: number[]');

View File

@ -0,0 +1,20 @@
/// <reference path="../fourslash.ts"/>
// @Filename: tsconfig.json
//// {
//// "compilerOptions": {
//// "plugins": [
//// { "name": "diagnostic-adder" }
//// ]
//// },
//// "files": ["a.ts"]
//// }
// @Filename: a.ts
//// let x = [1, 2];
//// x/**/
////
// Test adding an error message
goTo.marker();
verify.numberOfErrorsInCurrentFile(1);