mirror of
https://github.com/microsoft/TypeScript.git
synced 2025-12-11 17:41:26 -06:00
Language service extensibility
This commit is contained in:
parent
81f4e38643
commit
aec310996c
@ -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;
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -2267,6 +2267,7 @@ namespace ts.server.protocol {
|
||||
outDir?: string;
|
||||
outFile?: string;
|
||||
paths?: MapLike<string[]>;
|
||||
plugins?: PluginImport[];
|
||||
preserveConstEnums?: boolean;
|
||||
project?: string;
|
||||
reactNamespace?: string;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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> {
|
||||
|
||||
19
tests/cases/fourslash/server/ngProxy1.ts
Normal file
19
tests/cases/fourslash/server/ngProxy1.ts
Normal 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');
|
||||
20
tests/cases/fourslash/server/ngProxy2.ts
Normal file
20
tests/cases/fourslash/server/ngProxy2.ts
Normal 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[]');
|
||||
20
tests/cases/fourslash/server/ngProxy3.ts
Normal file
20
tests/cases/fourslash/server/ngProxy3.ts
Normal 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[]');
|
||||
20
tests/cases/fourslash/server/ngProxy4.ts
Normal file
20
tests/cases/fourslash/server/ngProxy4.ts
Normal 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);
|
||||
Loading…
x
Reference in New Issue
Block a user