diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index ee35ab360ca29..ccad290aed7ea 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -1,4 +1,18 @@ namespace Harness.LanguageService { + + export function makeDefaultProxy(info: ts.server.PluginCreateInfo): ts.LanguageService { + // tslint:disable-next-line:no-null-keyword + const proxy = Object.create(/*prototype*/ null); + const langSvc: any = info.languageService; + for (const k of Object.keys(langSvc)) { + // tslint:disable-next-line only-arrow-functions + proxy[k] = function () { + return langSvc[k].apply(langSvc, arguments); + }; + } + return proxy; + } + export class ScriptInfo { public version = 1; public editRanges: { length: number; textChangeRange: ts.TextChangeRange; }[] = []; @@ -869,19 +883,6 @@ namespace Harness.LanguageService { error: new Error("Could not resolve module") }; } - - function makeDefaultProxy(info: ts.server.PluginCreateInfo): ts.LanguageService { - // tslint:disable-next-line:no-null-keyword - const proxy = Object.create(/*prototype*/ null); - const langSvc: any = info.languageService; - for (const k of Object.keys(langSvc)) { - // tslint:disable-next-line only-arrow-functions - proxy[k] = function () { - return langSvc[k].apply(langSvc, arguments); - }; - } - return proxy; - } } } diff --git a/src/harness/virtualFileSystemWithWatch.ts b/src/harness/virtualFileSystemWithWatch.ts index 3f8d29abed645..9b030e212f195 100644 --- a/src/harness/virtualFileSystemWithWatch.ts +++ b/src/harness/virtualFileSystemWithWatch.ts @@ -341,6 +341,7 @@ interface Array {}` private readonly currentDirectory: string; private readonly dynamicPriorityWatchFile: HostWatchFile | undefined; private readonly customRecursiveWatchDirectory: HostWatchDirectory | undefined; + public require: (initialPath: string, moduleName: string) => server.RequireResult; constructor(public withSafeList: boolean, public useCaseSensitiveFileNames: boolean, executingFilePath: string, currentDirectory: string, fileOrFolderorSymLinkList: ReadonlyArray, public readonly newLine = "\n", public readonly useWindowsStylePath?: boolean, private readonly environmentVariables?: Map) { this.getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames); diff --git a/src/server/protocol.ts b/src/server/protocol.ts index e956290181a47..193621da82a3d 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -221,6 +221,11 @@ namespace ts.server.protocol { * Contains message body if success === true. */ body?: any; + + /** + * Contains extra information that plugin can include to be passed on + */ + metadata?: unknown; } /** diff --git a/src/server/session.ts b/src/server/session.ts index 42d3994db098f..10399e2168077 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -688,7 +688,26 @@ namespace ts.server { success, }; if (success) { - res.body = info; + let metadata: unknown; + if (isArray(info)) { + res.body = info; + metadata = (info as WithMetadata>).metadata; + delete (info as WithMetadata>).metadata; + } + else if (typeof info === "object") { + if ((info as WithMetadata<{}>).metadata) { + const { metadata: infoMetadata, ...body } = (info as WithMetadata<{}>); + res.body = body; + metadata = infoMetadata; + } + else { + res.body = info; + } + } + else { + res.body = info; + } + if (metadata) res.metadata = metadata; } else { Debug.assert(info === undefined); @@ -1467,7 +1486,7 @@ namespace ts.server { }); } - private getCompletions(args: protocol.CompletionsRequestArgs, kind: protocol.CommandTypes.CompletionInfo | protocol.CommandTypes.Completions | protocol.CommandTypes.CompletionsFull): ReadonlyArray | protocol.CompletionInfo | CompletionInfo | undefined { + private getCompletions(args: protocol.CompletionsRequestArgs, kind: protocol.CommandTypes.CompletionInfo | protocol.CommandTypes.Completions | protocol.CommandTypes.CompletionsFull): WithMetadata> | protocol.CompletionInfo | CompletionInfo | undefined { const { file, project } = this.getFileAndProject(args); const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file)!; const position = this.getPosition(args, scriptInfo); @@ -1492,7 +1511,10 @@ namespace ts.server { } }).sort((a, b) => compareStringsCaseSensitiveUI(a.name, b.name)); - if (kind === protocol.CommandTypes.Completions) return entries; + if (kind === protocol.CommandTypes.Completions) { + if (completions.metadata) (entries as WithMetadata>).metadata = completions.metadata; + return entries; + } const res: protocol.CompletionInfo = { ...completions, diff --git a/src/services/types.ts b/src/services/types.ts index b856d15f61a5a..73e41309a6390 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -238,6 +238,8 @@ namespace ts { /* @internal */ export const emptyOptions = {}; + export type WithMetadata = T & { metadata?: unknown; }; + // // Public services of a language service instance associated // with a language service host instance @@ -268,7 +270,7 @@ namespace ts { getEncodedSyntacticClassifications(fileName: string, span: TextSpan): Classifications; getEncodedSemanticClassifications(fileName: string, span: TextSpan): Classifications; - getCompletionsAtPosition(fileName: string, position: number, options: GetCompletionsAtPositionOptions | undefined): CompletionInfo | undefined; + getCompletionsAtPosition(fileName: string, position: number, options: GetCompletionsAtPositionOptions | undefined): WithMetadata | undefined; // "options" and "source" are optional only for backwards-compatibility getCompletionEntryDetails( fileName: string, diff --git a/src/testRunner/unittests/tsserverProjectSystem.ts b/src/testRunner/unittests/tsserverProjectSystem.ts index 4656e5282ba68..5cd8358c0eeb2 100644 --- a/src/testRunner/unittests/tsserverProjectSystem.ts +++ b/src/testRunner/unittests/tsserverProjectSystem.ts @@ -17,6 +17,14 @@ namespace ts.projectSystem { import safeList = TestFSWithWatch.safeList; import Tsc_WatchDirectory = TestFSWithWatch.Tsc_WatchDirectory; + const outputEventRegex = /Content\-Length: [\d]+\r\n\r\n/; + function mapOutputToJson(s: string) { + return convertToObject( + parseJsonText("json.json", s.replace(outputEventRegex, "")), + [] + ); + } + export const customTypesMap = { path: "/typesMap.json", content: `{ @@ -353,12 +361,8 @@ namespace ts.projectSystem { }; function getEvents() { - const outputEventRegex = /Content\-Length: [\d]+\r\n\r\n/; return mapDefined(host.getOutput(), s => { - const e = convertToObject( - parseJsonText("json.json", s.replace(outputEventRegex, "")), - [] - ); + const e = mapOutputToJson(s); return (isArray(eventNames) ? eventNames.some(eventName => e.event === eventName) : e.event === eventNames) ? e as T : undefined; }); } @@ -10735,6 +10739,104 @@ declare class TestLib { }); }); + describe("tsserverProjectSystem with metadata in response", () => { + const metadata = "Extra Info"; + function verifyOutput(host: TestServerHost, expectedResponse: protocol.Response) { + const output = host.getOutput().map(mapOutputToJson); + assert.deepEqual(output, [expectedResponse]); + host.clearOutput(); + } + + function verifyCommandWithMetadata(session: TestSession, host: TestServerHost, command: Partial, expectedResponseBody: U) { + command.seq = session.getSeq(); + command.type = "request"; + session.onMessage(JSON.stringify(command)); + verifyOutput(host, expectedResponseBody ? + { seq: 0, type: "response", command: command.command!, request_seq: command.seq, success: true, body: expectedResponseBody, metadata } : + { seq: 0, type: "response", command: command.command!, request_seq: command.seq, success: false, message: "No content available." } + ); + } + + const aTs: File = { path: "/a.ts", content: `class c { prop = "hello"; foo() { return this.prop; } }` }; + const tsconfig: File = { + path: "/tsconfig.json", + content: JSON.stringify({ + compilerOptions: { plugins: [{ name: "myplugin" }] } + }) + }; + function createHostWithPlugin(files: ReadonlyArray) { + const host = createServerHost(files); + host.require = (_initialPath, moduleName) => { + assert.equal(moduleName, "myplugin"); + return { + module: () => ({ + create(info: server.PluginCreateInfo) { + const proxy = Harness.LanguageService.makeDefaultProxy(info); + proxy.getCompletionsAtPosition = (filename, position, options) => { + const result = info.languageService.getCompletionsAtPosition(filename, position, options); + if (result) { + result.metadata = metadata; + } + return result; + }; + return proxy; + } + }), + error: undefined + }; + }; + return host; + } + + describe("With completion requests", () => { + const completionRequestArgs: protocol.CompletionsRequestArgs = { + file: aTs.path, + line: 1, + offset: aTs.content.indexOf("this.") + 1 + "this.".length + }; + const expectedCompletionEntries: ReadonlyArray = [ + { name: "foo", kind: ScriptElementKind.memberFunctionElement, kindModifiers: "", sortText: "0" }, + { name: "prop", kind: ScriptElementKind.memberVariableElement, kindModifiers: "", sortText: "0" } + ]; + + it("can pass through metadata when the command returns array", () => { + const host = createHostWithPlugin([aTs, tsconfig]); + const session = createSession(host); + openFilesForSession([aTs], session); + verifyCommandWithMetadata>(session, host, { + command: protocol.CommandTypes.Completions, + arguments: completionRequestArgs + }, expectedCompletionEntries); + }); + + it("can pass through metadata when the command returns object", () => { + const host = createHostWithPlugin([aTs, tsconfig]); + const session = createSession(host); + openFilesForSession([aTs], session); + verifyCommandWithMetadata(session, host, { + command: protocol.CommandTypes.CompletionInfo, + arguments: completionRequestArgs + }, { + isGlobalCompletion: false, + isMemberCompletion: true, + isNewIdentifierLocation: false, + entries: expectedCompletionEntries + }); + }); + + it("returns undefined correctly", () => { + const aTs: File = { path: "/a.ts", content: `class c { prop = "hello"; foo() { const x = 0; } }` }; + const host = createHostWithPlugin([aTs, tsconfig]); + const session = createSession(host); + openFilesForSession([aTs], session); + verifyCommandWithMetadata(session, host, { + command: protocol.CommandTypes.Completions, + arguments: { file: aTs.path, line: 1, offset: aTs.content.indexOf("x") + 1 } + }, /*expectedResponseBody*/ undefined); + }); + }); + }); + function makeReferenceItem(file: File, isDefinition: boolean, text: string, lineText: string, options?: SpanFromSubstringOptions): protocol.ReferencesResponseItem { return { ...protocolFileSpanFromSubstring(file, text, options), diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index f43b14a3cb917..f7399eebce2ad 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -4688,6 +4688,9 @@ declare namespace ts { installPackage?(options: InstallPackageOptions): Promise; writeFile?(fileName: string, content: string): void; } + type WithMetadata = T & { + metadata?: unknown; + }; interface LanguageService { cleanupSemanticCache(): void; getSyntacticDiagnostics(fileName: string): DiagnosticWithLocation[]; @@ -4705,7 +4708,7 @@ declare namespace ts { getSemanticClassifications(fileName: string, span: TextSpan): ClassifiedSpan[]; getEncodedSyntacticClassifications(fileName: string, span: TextSpan): Classifications; getEncodedSemanticClassifications(fileName: string, span: TextSpan): Classifications; - getCompletionsAtPosition(fileName: string, position: number, options: GetCompletionsAtPositionOptions | undefined): CompletionInfo | undefined; + getCompletionsAtPosition(fileName: string, position: number, options: GetCompletionsAtPositionOptions | undefined): WithMetadata | undefined; getCompletionEntryDetails(fileName: string, position: number, name: string, formatOptions: FormatCodeOptions | FormatCodeSettings | undefined, source: string | undefined, preferences: UserPreferences | undefined): CompletionEntryDetails | undefined; getCompletionEntrySymbol(fileName: string, position: number, name: string, source: string | undefined): Symbol | undefined; getQuickInfoAtPosition(fileName: string, position: number): QuickInfo | undefined; @@ -5787,6 +5790,10 @@ declare namespace ts.server.protocol { * Contains message body if success === true. */ body?: any; + /** + * Contains extra information that plugin can include to be passed on + */ + metadata?: unknown; } /** * Arguments for FileRequest messages. diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index 39b4c5111bab8..9cb6a040f2a0a 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -4688,6 +4688,9 @@ declare namespace ts { installPackage?(options: InstallPackageOptions): Promise; writeFile?(fileName: string, content: string): void; } + type WithMetadata = T & { + metadata?: unknown; + }; interface LanguageService { cleanupSemanticCache(): void; getSyntacticDiagnostics(fileName: string): DiagnosticWithLocation[]; @@ -4705,7 +4708,7 @@ declare namespace ts { getSemanticClassifications(fileName: string, span: TextSpan): ClassifiedSpan[]; getEncodedSyntacticClassifications(fileName: string, span: TextSpan): Classifications; getEncodedSemanticClassifications(fileName: string, span: TextSpan): Classifications; - getCompletionsAtPosition(fileName: string, position: number, options: GetCompletionsAtPositionOptions | undefined): CompletionInfo | undefined; + getCompletionsAtPosition(fileName: string, position: number, options: GetCompletionsAtPositionOptions | undefined): WithMetadata | undefined; getCompletionEntryDetails(fileName: string, position: number, name: string, formatOptions: FormatCodeOptions | FormatCodeSettings | undefined, source: string | undefined, preferences: UserPreferences | undefined): CompletionEntryDetails | undefined; getCompletionEntrySymbol(fileName: string, position: number, name: string, source: string | undefined): Symbol | undefined; getQuickInfoAtPosition(fileName: string, position: number): QuickInfo | undefined;