Skip to content

Commit

Permalink
Language service extensibility
Browse files Browse the repository at this point in the history
  • Loading branch information
RyanCavanaugh committed Feb 14, 2017
1 parent 81f4e38 commit aec3109
Show file tree
Hide file tree
Showing 14 changed files with 325 additions and 12 deletions.
2 changes: 1 addition & 1 deletion scripts/buildProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/compiler/commandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
];

Expand Down
7 changes: 6 additions & 1 deletion src/compiler/moduleNameResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 9 additions & 3 deletions src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3270,6 +3274,7 @@
outDir?: string;
outFile?: string;
paths?: MapLike<string[]>;
/*@internal*/ plugins?: PluginImport[];
preserveConstEnums?: boolean;
project?: string;
/* @internal */ pretty?: DiagnosticStyle;
Expand Down Expand Up @@ -3353,7 +3358,8 @@
JS = 1,
JSX = 2,
TS = 3,
TSX = 4
TSX = 4,
External = 5
}

export const enum ScriptTarget {
Expand Down Expand Up @@ -3428,7 +3434,7 @@
/* @internal */
export interface CommandLineOptionOfListType extends CommandLineOptionBase {
type: "list";
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType;
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType | TsConfigOnlyOption;
}

/* @internal */
Expand Down
14 changes: 14 additions & 0 deletions src/harness/fourslash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
89 changes: 85 additions & 4 deletions src/harness/harnessLanguageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
109 changes: 106 additions & 3 deletions src/server/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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());
}
Expand All @@ -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));
}
Expand Down
Loading

0 comments on commit aec3109

Please sign in to comment.