Skip to content

Commit aec3109

Browse files
committed
Language service extensibility
1 parent 81f4e38 commit aec3109

14 files changed

+325
-12
lines changed

scripts/buildProtocol.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ function generateProtocolFile(protocolTs: string, typeScriptServicesDts: string)
167167
const sanityCheckProgram = getProgramWithProtocolText(protocolDts, /*includeTypeScriptServices*/ false);
168168
const diagnostics = [...sanityCheckProgram.getSyntacticDiagnostics(), ...sanityCheckProgram.getSemanticDiagnostics(), ...sanityCheckProgram.getGlobalDiagnostics()];
169169
if (diagnostics.length) {
170-
const flattenedDiagnostics = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n")).join("\n");
170+
const flattenedDiagnostics = diagnostics.map(d => `${ts.flattenDiagnosticMessageText(d.messageText, "\n")} at ${d.file.fileName} line ${d.start}`).join("\n");
171171
throw new Error(`Unexpected errors during sanity check: ${flattenedDiagnostics}`);
172172
}
173173
return protocolDts;

src/compiler/commandLineParser.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,16 @@ namespace ts {
459459
name: "alwaysStrict",
460460
type: "boolean",
461461
description: Diagnostics.Parse_in_strict_mode_and_emit_use_strict_for_each_source_file
462+
},
463+
{
464+
// A list of plugins to load in the language service
465+
name: "plugins",
466+
type: "list",
467+
isTSConfigOnly: true,
468+
element: {
469+
name: "plugin",
470+
type: "object"
471+
}
462472
}
463473
];
464474

src/compiler/moduleNameResolver.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,13 +675,18 @@ namespace ts {
675675
}
676676

677677
export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache): ResolvedModuleWithFailedLookupLocations {
678+
return nodeModuleNameResolverWorker(moduleName, containingFile, compilerOptions, host, cache, /* jsOnly*/ false);
679+
}
680+
681+
/* @internal */
682+
export function nodeModuleNameResolverWorker(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, jsOnly = false): ResolvedModuleWithFailedLookupLocations {
678683
const containingDirectory = getDirectoryPath(containingFile);
679684
const traceEnabled = isTraceEnabled(compilerOptions, host);
680685

681686
const failedLookupLocations: string[] = [];
682687
const state: ModuleResolutionState = { compilerOptions, host, traceEnabled };
683688

684-
const result = tryResolve(Extensions.TypeScript) || tryResolve(Extensions.JavaScript);
689+
const result = jsOnly ? tryResolve(Extensions.JavaScript) : (tryResolve(Extensions.TypeScript) || tryResolve(Extensions.JavaScript));
685690
if (result && result.value) {
686691
const { resolved, isExternalLibraryImport } = result.value;
687692
return createResolvedModuleWithFailedLookupLocations(resolved, isExternalLibraryImport, failedLookupLocations);

src/compiler/types.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3215,7 +3215,11 @@
32153215
NodeJs = 2
32163216
}
32173217

3218-
export type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike<string[]>;
3218+
export interface PluginImport {
3219+
name: string
3220+
}
3221+
3222+
export type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike<string[]> | PluginImport[];
32193223

32203224
export interface CompilerOptions {
32213225
allowJs?: boolean;
@@ -3270,6 +3274,7 @@
32703274
outDir?: string;
32713275
outFile?: string;
32723276
paths?: MapLike<string[]>;
3277+
/*@internal*/ plugins?: PluginImport[];
32733278
preserveConstEnums?: boolean;
32743279
project?: string;
32753280
/* @internal */ pretty?: DiagnosticStyle;
@@ -3353,7 +3358,8 @@
33533358
JS = 1,
33543359
JSX = 2,
33553360
TS = 3,
3356-
TSX = 4
3361+
TSX = 4,
3362+
External = 5
33573363
}
33583364

33593365
export const enum ScriptTarget {
@@ -3428,7 +3434,7 @@
34283434
/* @internal */
34293435
export interface CommandLineOptionOfListType extends CommandLineOptionBase {
34303436
type: "list";
3431-
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType;
3437+
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType | TsConfigOnlyOption;
34323438
}
34333439

34343440
/* @internal */

src/harness/fourslash.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,20 @@ namespace FourSlash {
263263
// Create map between fileName and its content for easily looking up when resolveReference flag is specified
264264
this.inputFiles.set(file.fileName, file.content);
265265
if (ts.getBaseFileName(file.fileName).toLowerCase() === "tsconfig.json") {
266+
const configJson = ts.parseConfigFileTextToJson(file.fileName, file.content);
267+
if (configJson.config === undefined) {
268+
throw new Error(`Failed to parse test tsconfig.json: ${configJson.error.messageText}`);
269+
}
270+
271+
// Extend our existing compiler options so that we can also support tsconfig only options
272+
if (configJson.config.compilerOptions) {
273+
const baseDirectory = ts.normalizePath(ts.getDirectoryPath(file.fileName));
274+
const tsConfig = ts.convertCompilerOptionsFromJson(configJson.config.compilerOptions, baseDirectory, file.fileName);
275+
276+
if (!tsConfig.errors || !tsConfig.errors.length) {
277+
compilationOptions = ts.extend(compilationOptions, tsConfig.options);
278+
}
279+
}
266280
configFileName = file.fileName;
267281
}
268282

src/harness/harnessLanguageService.ts

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ namespace Harness.LanguageService {
126126
protected virtualFileSystem: Utils.VirtualFileSystem = new Utils.VirtualFileSystem(virtualFileSystemRoot, /*useCaseSensitiveFilenames*/false);
127127

128128
constructor(protected cancellationToken = DefaultHostCancellationToken.Instance,
129-
protected settings = ts.getDefaultCompilerOptions()) {
129+
protected settings = ts.getDefaultCompilerOptions()) {
130130
}
131131

132132
public getNewLine(): string {
@@ -135,7 +135,7 @@ namespace Harness.LanguageService {
135135

136136
public getFilenames(): string[] {
137137
const fileNames: string[] = [];
138-
for (const virtualEntry of this.virtualFileSystem.getAllFileEntries()){
138+
for (const virtualEntry of this.virtualFileSystem.getAllFileEntries()) {
139139
const scriptInfo = virtualEntry.content;
140140
if (scriptInfo.isRootFile) {
141141
// only include root files here
@@ -211,8 +211,8 @@ namespace Harness.LanguageService {
211211
readDirectory(path: string, extensions?: string[], exclude?: string[], include?: string[]): string[] {
212212
return ts.matchFiles(path, extensions, exclude, include,
213213
/*useCaseSensitiveFileNames*/false,
214-
this.getCurrentDirectory(),
215-
(p) => this.virtualFileSystem.getAccessibleFileSystemEntries(p));
214+
this.getCurrentDirectory(),
215+
(p) => this.virtualFileSystem.getAccessibleFileSystemEntries(p));
216216
}
217217
readFile(path: string): string {
218218
const snapshot = this.getScriptSnapshot(path);
@@ -724,6 +724,87 @@ namespace Harness.LanguageService {
724724
createHash(s: string) {
725725
return s;
726726
}
727+
728+
require(_initialDir: string, _moduleName: string): ts.server.RequireResult {
729+
switch (_moduleName) {
730+
// Adds to the Quick Info a fixed string and a string from the config file
731+
// and replaces the first display part
732+
case "quickinfo-augmeneter":
733+
return {
734+
module: () => ({
735+
create(info: ts.server.PluginCreateInfo) {
736+
const proxy = makeDefaultProxy(info);
737+
const langSvc: any = info.languageService;
738+
proxy.getQuickInfoAtPosition = function () {
739+
const parts = langSvc.getQuickInfoAtPosition.apply(langSvc, arguments);
740+
if (parts.displayParts.length > 0) {
741+
parts.displayParts[0].text = "Proxied";
742+
}
743+
parts.displayParts.push({ text: info.config.message, kind: "punctuation" });
744+
return parts;
745+
};
746+
747+
return proxy;
748+
}
749+
}),
750+
error: undefined
751+
};
752+
753+
// Throws during initialization
754+
case "create-thrower":
755+
return {
756+
module: () => ({
757+
create() {
758+
throw new Error("I am not a well-behaved plugin");
759+
}
760+
}),
761+
error: undefined
762+
};
763+
764+
// Adds another diagnostic
765+
case "diagnostic-adder":
766+
return {
767+
module: () => ({
768+
create(info: ts.server.PluginCreateInfo) {
769+
const proxy = makeDefaultProxy(info);
770+
proxy.getSemanticDiagnostics = function (filename: string) {
771+
const prev = info.languageService.getSemanticDiagnostics(filename);
772+
const sourceFile: ts.SourceFile = info.languageService.getSourceFile(filename);
773+
prev.push({
774+
category: ts.DiagnosticCategory.Warning,
775+
file: sourceFile,
776+
code: 9999,
777+
length: 3,
778+
messageText: `Plugin diagnostic`,
779+
start: 0
780+
});
781+
return prev;
782+
}
783+
return proxy;
784+
}
785+
}),
786+
error: undefined
787+
};
788+
789+
default:
790+
return {
791+
module: undefined,
792+
error: "Could not resolve module"
793+
};
794+
}
795+
796+
function makeDefaultProxy(info: ts.server.PluginCreateInfo) {
797+
// tslint:disable-next-line:no-null-keyword
798+
const proxy = Object.create(null);
799+
const langSvc: any = info.languageService;
800+
for (const k of Object.keys(langSvc)) {
801+
proxy[k] = function () {
802+
return langSvc[k].apply(langSvc, arguments);
803+
};
804+
}
805+
return proxy;
806+
}
807+
}
727808
}
728809

729810
export class ServerLanguageServiceAdapter implements LanguageServiceAdapter {

src/server/project.ts

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,19 +91,38 @@ namespace ts.server {
9191
}
9292
}
9393

94+
export interface PluginCreateInfo {
95+
project: Project;
96+
languageService: LanguageService;
97+
languageServiceHost: LanguageServiceHost;
98+
serverHost: ServerHost;
99+
config: any;
100+
}
101+
102+
export interface PluginModule {
103+
create(createInfo: PluginCreateInfo): LanguageService;
104+
getExternalFiles?(proj: Project): string[];
105+
}
106+
107+
export interface PluginModuleFactory {
108+
(mod: { typescript: typeof ts }): PluginModule;
109+
}
110+
94111
export abstract class Project {
95112
private rootFiles: ScriptInfo[] = [];
96113
private rootFilesMap: FileMap<ScriptInfo> = createFileMap<ScriptInfo>();
97-
private lsHost: LSHost;
98114
private program: ts.Program;
99115

100116
private cachedUnresolvedImportsPerFile = new UnresolvedImportsMap();
101117
private lastCachedUnresolvedImportsList: SortedReadonlyArray<string>;
102118

103-
private readonly languageService: LanguageService;
119+
// wrapper over the real language service that will suppress all semantic operations
120+
protected languageService: LanguageService;
104121

105122
public languageServiceEnabled = true;
106123

124+
protected readonly lsHost: LSHost;
125+
107126
builder: Builder;
108127
/**
109128
* Set of files names that were updated since the last call to getChangesSinceVersion.
@@ -150,6 +169,17 @@ namespace ts.server {
150169
return this.cachedUnresolvedImportsPerFile;
151170
}
152171

172+
public static resolveModule(moduleName: string, initialDir: string, host: ServerHost, log: (message: string) => void): {} {
173+
const resolvedPath = normalizeSlashes(host.resolvePath(combinePaths(initialDir, "node_modules")));
174+
log(`Loading ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`);
175+
const result = host.require(resolvedPath, moduleName);
176+
if (result.error) {
177+
log(`Failed to load module: ${JSON.stringify(result.error)}`);
178+
return undefined;
179+
}
180+
return result.module;
181+
}
182+
153183
constructor(
154184
private readonly projectName: string,
155185
readonly projectKind: ProjectKind,
@@ -237,6 +267,10 @@ namespace ts.server {
237267
abstract getProjectRootPath(): string | undefined;
238268
abstract getTypeAcquisition(): TypeAcquisition;
239269

270+
getExternalFiles(): string[] {
271+
return [];
272+
}
273+
240274
getSourceFile(path: Path) {
241275
if (!this.program) {
242276
return undefined;
@@ -804,10 +838,12 @@ namespace ts.server {
804838
private typeRootsWatchers: FileWatcher[];
805839
readonly canonicalConfigFilePath: NormalizedPath;
806840

841+
private plugins: PluginModule[] = [];
842+
807843
/** Used for configured projects which may have multiple open roots */
808844
openRefCount = 0;
809845

810-
constructor(configFileName: NormalizedPath,
846+
constructor(private configFileName: NormalizedPath,
811847
projectService: ProjectService,
812848
documentRegistry: ts.DocumentRegistry,
813849
hasExplicitListOfFiles: boolean,
@@ -817,12 +853,64 @@ namespace ts.server {
817853
public compileOnSaveEnabled: boolean) {
818854
super(configFileName, ProjectKind.Configured, projectService, documentRegistry, hasExplicitListOfFiles, languageServiceEnabled, compilerOptions, compileOnSaveEnabled);
819855
this.canonicalConfigFilePath = asNormalizedPath(projectService.toCanonicalFileName(configFileName));
856+
this.enablePlugins();
820857
}
821858

822859
getConfigFilePath() {
823860
return this.getProjectName();
824861
}
825862

863+
enablePlugins() {
864+
const host = this.projectService.host;
865+
const options = this.getCompilerOptions();
866+
const log = (message: string) => {
867+
this.projectService.logger.info(message);
868+
};
869+
870+
if (!(options.plugins && options.plugins.length)) {
871+
this.projectService.logger.info("No plugins exist");
872+
// No plugins
873+
return;
874+
}
875+
876+
if (!host.require) {
877+
this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
878+
return;
879+
}
880+
881+
for (const pluginConfigEntry of options.plugins) {
882+
const searchPath = getDirectoryPath(this.configFileName);
883+
const resolvedModule = <PluginModuleFactory>Project.resolveModule(pluginConfigEntry.name, searchPath, host, log);
884+
if (resolvedModule) {
885+
this.enableProxy(resolvedModule, pluginConfigEntry);
886+
}
887+
}
888+
}
889+
890+
private enableProxy(pluginModuleFactory: PluginModuleFactory, configEntry: PluginImport) {
891+
try {
892+
if (typeof pluginModuleFactory !== "function") {
893+
this.projectService.logger.info(`Skipped loading plugin ${configEntry.name} because it did expose a proper factory function`);
894+
return;
895+
}
896+
897+
const info: PluginCreateInfo = {
898+
config: configEntry,
899+
project: this,
900+
languageService: this.languageService,
901+
languageServiceHost: this.lsHost,
902+
serverHost: this.projectService.host
903+
};
904+
905+
const pluginModule = pluginModuleFactory({ typescript: ts });
906+
this.languageService = pluginModule.create(info);
907+
this.plugins.push(pluginModule);
908+
}
909+
catch (e) {
910+
this.projectService.logger.info(`Plugin activation failed: ${e}`);
911+
}
912+
}
913+
826914
getProjectRootPath() {
827915
return getDirectoryPath(this.getConfigFilePath());
828916
}
@@ -839,6 +927,21 @@ namespace ts.server {
839927
return this.typeAcquisition;
840928
}
841929

930+
getExternalFiles(): string[] {
931+
const items: string[] = [];
932+
for (const plugin of this.plugins) {
933+
if (typeof plugin.getExternalFiles === "function") {
934+
try {
935+
items.push(...plugin.getExternalFiles(this));
936+
}
937+
catch (e) {
938+
this.projectService.logger.info(`A plugin threw an exception in getExternalFiles: ${e}`);
939+
}
940+
}
941+
}
942+
return items;
943+
}
944+
842945
watchConfigFile(callback: (project: ConfiguredProject) => void) {
843946
this.projectFileWatcher = this.projectService.host.watchFile(this.getConfigFilePath(), _ => callback(this));
844947
}

0 commit comments

Comments
 (0)