From 03c455fa6dad2e46522d45c7c6cff444ac5645a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dirk=20B=C3=A4umer?= Date: Wed, 7 Aug 2024 19:16:18 +0200 Subject: [PATCH] Add support for dynamic text document content (#1532) * WIP * WIP * Add refresh support and test bed code. --- client-node-tests/src/integration.test.ts | 21 ++++ client-node-tests/src/servers/testServer.ts | 7 ++ client/src/common/client.ts | 10 +- client/src/common/textDocumentContent.ts | 84 ++++++++++++++++ .../common/protocol.textDocumentContent.ts | 98 +++++++++++++++++++ protocol/src/common/protocol.ts | 28 +++++- server/src/common/api.ts | 6 +- server/src/common/textDocumentContent.ts | 38 +++++++ server/src/common/workspaceFolder.ts | 1 - testbed/client/src/extension.ts | 12 ++- testbed/package.json | 8 ++ testbed/server/src/server.ts | 20 +++- 12 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 client/src/common/textDocumentContent.ts create mode 100644 protocol/src/common/protocol.textDocumentContent.ts create mode 100644 server/src/common/textDocumentContent.ts diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index 3b18c8777..dcac1430e 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -317,6 +317,9 @@ suite('Client integration', () => { }, willDelete: { filters: [{ scheme: fsProvider.scheme, pattern: { glob: '**/deleted-static/**{/,/*.txt}' } }] }, }, + textDocumentContent: { + scheme: 'content-test' + } }, linkedEditingRangeProvider: true, diagnosticProvider: { @@ -1536,6 +1539,24 @@ suite('Client integration', () => { rangeEqual(symbol.location.range, 1, 2, 3, 4); }); + test('Text Document Content', async () => { + const providers = client.getFeature(lsclient.TextDocumentContentRequest.method)?.getProviders(); + isDefined(providers); + assert.strictEqual(providers.length, 1); + const provider = providers[0].provider; + const result = await provider.provideTextDocumentContent(vscode.Uri.parse('content-test:///test.txt'), tokenSource.token); + assert.strictEqual(result, 'Some test content'); + + let middlewareCalled: boolean = false; + middleware.provideTextDocumentContent = (uri, token, next) => { + middlewareCalled = true; + return next(uri, token); + }; + await provider.provideTextDocumentContent(vscode.Uri.parse('content-test:///test.txt'), tokenSource.token); + middleware.provideTextDocumentContent = undefined; + assert.strictEqual(middlewareCalled, true); + }); + test('General middleware', async () => { let middlewareCallCount = 0; diff --git a/client-node-tests/src/servers/testServer.ts b/client-node-tests/src/servers/testServer.ts index 0423d356d..cd94a9f7e 100644 --- a/client-node-tests/src/servers/testServer.ts +++ b/client-node-tests/src/servers/testServer.ts @@ -144,6 +144,9 @@ connection.onInitialize((params: InitializeParams): any => { filters: [{ scheme: 'file-test', pattern: { glob: '**/deleted-static/**{/,/*.txt}' } }] }, }, + textDocumentContent: { + scheme: 'content-test' + } }, linkedEditingRangeProvider: true, diagnosticProvider: { @@ -522,6 +525,10 @@ connection.languages.inlineCompletion.on((_params) => { ]; }); +connection.workspace.textDocumentContent.on((_params) => { + return 'Some test content'; +}); + connection.onRequest( new ProtocolRequestType('testing/sendSampleProgress'), async (_, __) => { diff --git a/client/src/common/client.ts b/client/src/common/client.ts index ffff25533..5540cedd8 100644 --- a/client/src/common/client.ts +++ b/client/src/common/client.ts @@ -38,7 +38,7 @@ import { ConnectionOptions, PositionEncodingKind, DocumentDiagnosticRequest, NotebookDocumentSyncRegistrationType, NotebookDocumentSyncRegistrationOptions, ErrorCodes, MessageStrategy, DidOpenTextDocumentParams, CodeLensResolveRequest, CompletionResolveRequest, CodeActionResolveRequest, InlayHintResolveRequest, DocumentLinkResolveRequest, WorkspaceSymbolResolveRequest, CancellationToken as ProtocolCancellationToken, InlineCompletionRequest, InlineCompletionRegistrationOptions, ExecuteCommandRequest, ExecuteCommandOptions, HandlerResult, - type DidCloseTextDocumentParams + type DidCloseTextDocumentParams, type TextDocumentContentRequest } from 'vscode-languageserver-protocol'; import * as c2p from './codeConverter'; @@ -94,6 +94,7 @@ import { InlayHintsFeature, InlayHintsMiddleware, InlayHintsProviderShape } from import { WorkspaceFoldersFeature, WorkspaceFolderMiddleware } from './workspaceFolder'; import { DidCreateFilesFeature, DidDeleteFilesFeature, DidRenameFilesFeature, WillCreateFilesFeature, WillDeleteFilesFeature, WillRenameFilesFeature, FileOperationsMiddleware } from './fileOperations'; import { InlineCompletionItemFeature, InlineCompletionMiddleware } from './inlineCompletion'; +import { TextDocumentContentFeature, type TextDocumentContentMiddleware, type TextDocumentContentProviderShape } from './textDocumentContent'; import { FileSystemWatcherFeature } from './fileSystemWatcher'; import { ProgressFeature } from './progress'; @@ -342,7 +343,8 @@ export type Middleware = _Middleware & TextDocumentSynchronizationMiddleware & C DocumentHighlightMiddleware & DocumentSymbolMiddleware & WorkspaceSymbolMiddleware & ReferencesMiddleware & TypeDefinitionMiddleware & ImplementationMiddleware & ColorProviderMiddleware & CodeActionMiddleware & CodeLensMiddleware & FormattingMiddleware & RenameMiddleware & DocumentLinkMiddleware & ExecuteCommandMiddleware & FoldingRangeProviderMiddleware & DeclarationMiddleware & SelectionRangeProviderMiddleware & CallHierarchyMiddleware & SemanticTokensMiddleware & -LinkedEditingRangeMiddleware & TypeHierarchyMiddleware & InlineValueMiddleware & InlayHintsMiddleware & NotebookDocumentMiddleware & DiagnosticProviderMiddleware & InlineCompletionMiddleware & GeneralMiddleware; +LinkedEditingRangeMiddleware & TypeHierarchyMiddleware & InlineValueMiddleware & InlayHintsMiddleware & NotebookDocumentMiddleware & DiagnosticProviderMiddleware & +InlineCompletionMiddleware & TextDocumentContentMiddleware & GeneralMiddleware; export type LanguageClientOptions = { documentSelector?: DocumentSelector | string[]; @@ -1933,6 +1935,7 @@ export abstract class BaseLanguageClient implements FeatureClient & TextDocumentProviderFeature & DiagnosticFeatureShape; getFeature(request: typeof NotebookDocumentSyncRegistrationType.method): DynamicFeature & NotebookDocumentProviderShape; getFeature(request: typeof InlineCompletionRequest.method): (DynamicFeature & TextDocumentProviderFeature) | undefined; + getFeature(request: typeof TextDocumentContentRequest.method): DynamicFeature & WorkspaceProviderFeature | undefined; getFeature(request: typeof ExecuteCommandRequest.method): DynamicFeature; public getFeature(request: string): DynamicFeature | undefined { return this._dynamicFeatures.get(request); @@ -2462,7 +2465,8 @@ function createConnection(input: MessageReader, output: MessageWriter, errorHand export namespace ProposedFeatures { export function createAll(_client: FeatureClient): (StaticFeature | DynamicFeature)[] { const result: (StaticFeature | DynamicFeature)[] = [ - new InlineCompletionItemFeature(_client) + new InlineCompletionItemFeature(_client), + new TextDocumentContentFeature(_client) ]; return result; } diff --git a/client/src/common/textDocumentContent.ts b/client/src/common/textDocumentContent.ts new file mode 100644 index 000000000..3c7124df7 --- /dev/null +++ b/client/src/common/textDocumentContent.ts @@ -0,0 +1,84 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import * as vscode from 'vscode'; +import { StaticRegistrationOptions, TextDocumentContentRefreshRequest, TextDocumentContentRequest, type ClientCapabilities, type ServerCapabilities, type TextDocumentContentParams, type TextDocumentContentRegistrationOptions } from 'vscode-languageserver-protocol'; + +import { WorkspaceFeature, ensure, type FeatureClient } from './features'; +import * as UUID from './utils/uuid'; + + +export interface ProvideTextDocumentContentSignature { + (this: void, uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult; +} + +export interface TextDocumentContentMiddleware { + provideTextDocumentContent?: (this: void, uri: vscode.Uri, token: vscode.CancellationToken, next: ProvideTextDocumentContentSignature) => vscode.ProviderResult; +} + +export interface TextDocumentContentProviderShape { + provider: vscode.TextDocumentContentProvider; + onDidChangeEmitter: vscode.EventEmitter; +} + +export class TextDocumentContentFeature extends WorkspaceFeature { + + constructor(client: FeatureClient) { + super(client, TextDocumentContentRequest.type); + } + + public fillClientCapabilities(capabilities: ClientCapabilities): void { + const textDocumentContent = ensure(ensure(capabilities, 'workspace')!, 'textDocumentContent')!; + textDocumentContent.dynamicRegistration = true; + } + + public initialize(capabilities: ServerCapabilities): void { + const client = this._client; + client.onRequest(TextDocumentContentRefreshRequest.type, async (params) => { + const uri = client.protocol2CodeConverter.asUri(params.uri); + for (const provider of this.getProviders()) { + provider.onDidChangeEmitter.fire(uri); + } + }); + + if (!capabilities?.workspace?.textDocumentContent) { + return; + } + const capability = capabilities.workspace.textDocumentContent; + const id = StaticRegistrationOptions.hasId(capability) ? capability.id : UUID.generateUuid(); + this.register({ + id: id, + registerOptions: capability + }); + } + + protected registerLanguageProvider(options: TextDocumentContentRegistrationOptions): [vscode.Disposable, TextDocumentContentProviderShape] { + const eventEmitter: vscode.EventEmitter = new vscode.EventEmitter(); + const provider: vscode.TextDocumentContentProvider = { + onDidChange: eventEmitter.event, + provideTextDocumentContent: (uri, token) => { + const client = this._client; + const provideTextDocumentContent: ProvideTextDocumentContentSignature = (uri, token) => { + const params: TextDocumentContentParams = { + uri: client.code2ProtocolConverter.asUri(uri) + }; + return client.sendRequest(TextDocumentContentRequest.type, params, token).then((result) => { + if (token.isCancellationRequested) { + return null; + } + return result; + }, (error) => { + return client.handleFailedRequest(TextDocumentContentRequest.type, token, error, null); + }); + }; + const middleware = client.middleware; + return middleware.provideTextDocumentContent + ? middleware.provideTextDocumentContent(uri, token, provideTextDocumentContent) + : provideTextDocumentContent(uri, token); + } + }; + return [vscode.workspace.registerTextDocumentContentProvider(options.scheme, provider), { provider, onDidChangeEmitter: eventEmitter }]; + } +} \ No newline at end of file diff --git a/protocol/src/common/protocol.textDocumentContent.ts b/protocol/src/common/protocol.textDocumentContent.ts new file mode 100644 index 000000000..07733f1a4 --- /dev/null +++ b/protocol/src/common/protocol.textDocumentContent.ts @@ -0,0 +1,98 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import type { DocumentUri } from 'vscode-languageserver-types'; +import type { RequestHandler } from 'vscode-jsonrpc'; + +import { MessageDirection, ProtocolRequestType } from './messages'; +import type { StaticRegistrationOptions } from './protocol'; + +/** + * Client capabilities for a text document content provider. + * + * @since 3.18.0 + * @proposed + */ +export type TextDocumentContentClientCapabilities = { + /** + * Text document content provider supports dynamic registration. + */ + dynamicRegistration?: boolean; +}; + +/** + * Text document content provider options. + * + * @since 3.18.0 + * @proposed + */ +export type TextDocumentContentOptions = { + /** + * The scheme for which the server provides content. + */ + scheme: string; +}; + +/** + * Text document content provider registration options. + * + * @since 3.18.0 + * @proposed + */ +export type TextDocumentContentRegistrationOptions = TextDocumentContentOptions & StaticRegistrationOptions; + +/** + * Parameters for the `workspace/textDocumentContent` request. + * + * @since 3.18.0 + * @proposed + */ +export interface TextDocumentContentParams { + /** + * The uri of the text document. + */ + uri: DocumentUri; +} + +/** + * The `workspace/textDocumentContent` request is sent from the client to the + * server to request the content of a text document. + * + * @since 3.18.0 + * @proposed + */ +export namespace TextDocumentContentRequest { + export const method: 'workspace/textDocumentContent' = 'workspace/textDocumentContent'; + export const messageDirection: MessageDirection = MessageDirection.clientToServer; + export const type = new ProtocolRequestType(method); + export type HandlerSignature = RequestHandler; +} + +/** + * Parameters for the `workspace/textDocumentContent/refresh` request. + * + * @since 3.18.0 + * @proposed + */ +export interface TextDocumentContentRefreshParams { + /** + * The uri of the text document to refresh. + */ + uri: DocumentUri; +} + +/** + * The `workspace/textDocumentContent` request is sent from the server to the client to refresh + * the content of a specific text document. + * + * @since 3.18.0 + * @proposed + */ +export namespace TextDocumentContentRefreshRequest { + export const method: `workspace/textDocumentContent/refresh` = `workspace/textDocumentContent/refresh`; + export const messageDirection: MessageDirection = MessageDirection.serverToClient; + export const type = new ProtocolRequestType(method); + export type HandlerSignature = RequestHandler; +} \ No newline at end of file diff --git a/protocol/src/common/protocol.ts b/protocol/src/common/protocol.ts index 2bbb7a048..e1a3e1605 100644 --- a/protocol/src/common/protocol.ts +++ b/protocol/src/common/protocol.ts @@ -121,7 +121,14 @@ import { DidCloseNotebookDocumentNotification, NotebookDocumentFilterWithCells, NotebookDocumentFilterWithNotebook } from './protocol.notebook'; -import { InlineCompletionClientCapabilities, InlineCompletionOptions, InlineCompletionParams, InlineCompletionRegistrationOptions, InlineCompletionRequest } from './protocol.inlineCompletion'; +import { + InlineCompletionClientCapabilities, InlineCompletionOptions, InlineCompletionParams, InlineCompletionRegistrationOptions, InlineCompletionRequest +} from './protocol.inlineCompletion'; + +import { + TextDocumentContentClientCapabilities, TextDocumentContentOptions, TextDocumentContentRegistrationOptions, TextDocumentContentParams, + TextDocumentContentRequest, TextDocumentContentRefreshParams, TextDocumentContentRefreshRequest +} from './protocol.textDocumentContent'; // @ts-ignore: to avoid inlining LocationLink as dynamic import let __noDynamicImport: LocationLink | undefined; @@ -654,6 +661,14 @@ export interface WorkspaceClientCapabilities { * @proposed */ foldingRange?: FoldingRangeWorkspaceClientCapabilities; + + /** + * Capabilities specific to the `workspace/textDocumentContent` request. + * + * @since 3.18.0 + * @proposed + */ + textDocumentContent?: TextDocumentContentClientCapabilities; } /** @@ -1186,6 +1201,14 @@ export type WorkspaceOptions = { * @since 3.16.0 */ fileOperations?: FileOperationOptions; + + /** + * The server supports the `workspace/textDocumentContent` request. + * + * @since 3.18.0 + * @proposed + */ + textDocumentContent?: TextDocumentContentOptions | TextDocumentContentRegistrationOptions; }; /** @@ -4286,6 +4309,9 @@ export { DidCloseNotebookDocumentNotification, NotebookDocumentFilterWithCells, NotebookDocumentFilterWithNotebook, // Inline Completions InlineCompletionClientCapabilities, InlineCompletionOptions, InlineCompletionParams, InlineCompletionRegistrationOptions, InlineCompletionRequest, + // Text Document Content + TextDocumentContentClientCapabilities, TextDocumentContentOptions, TextDocumentContentRegistrationOptions, TextDocumentContentParams, TextDocumentContentRequest, + TextDocumentContentRefreshParams, TextDocumentContentRefreshRequest }; // To be backwards compatible diff --git a/server/src/common/api.ts b/server/src/common/api.ts index ddb01e24d..5bf884dec 100644 --- a/server/src/common/api.ts +++ b/server/src/common/api.ts @@ -8,6 +8,7 @@ import { SemanticTokensBuilder } from './semanticTokens'; import type { WorkDoneProgressReporter, WorkDoneProgressServerReporter, ResultProgressReporter } from './progress'; import * as ic from './inlineCompletion.proposed'; +import * as tdc from './textDocumentContent'; export * from 'vscode-languageserver-protocol'; export { WorkDoneProgressReporter, WorkDoneProgressServerReporter, ResultProgressReporter }; @@ -19,10 +20,11 @@ export { NotebookDocuments }; export * from './server'; export namespace ProposedFeatures { - export const all: Features<_, _, _, _, _, _, ic.InlineCompletionFeatureShape, _> = { + export const all: Features<_, _, _, _, _, tdc.TextDocumentContentFeatureShape, ic.InlineCompletionFeatureShape, _> = { __brand: 'features', + workspace: tdc.TextDocumentContentFeature, languages: ic.InlineCompletionFeature }; - export type Connection = _Connection<_, _, _, _, _, _, ic.InlineCompletionFeatureShape, _>; + export type Connection = _Connection<_, _, _, _, _, tdc.TextDocumentContentFeatureShape, ic.InlineCompletionFeatureShape, _>; } \ No newline at end of file diff --git a/server/src/common/textDocumentContent.ts b/server/src/common/textDocumentContent.ts new file mode 100644 index 000000000..c1d5794c3 --- /dev/null +++ b/server/src/common/textDocumentContent.ts @@ -0,0 +1,38 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import { TextDocumentContentRefreshRequest, TextDocumentContentRequest, type Disposable, type DocumentUri, type RequestHandler, type TextDocumentContentParams } from 'vscode-languageserver-protocol'; + +import type { Feature, _RemoteWorkspace } from './server'; + +/** + * Shape of the text document content feature + * + * @since 3.18.0 + * @proposed + */ +export interface TextDocumentContentFeatureShape { + textDocumentContent: { + refresh(uri: DocumentUri): Promise; + on(handler: RequestHandler): Disposable; + }; +} + +export const TextDocumentContentFeature: Feature<_RemoteWorkspace, TextDocumentContentFeatureShape> = (Base) => { + return class extends Base { + public get textDocumentContent() { + return { + refresh: (uri: DocumentUri): Promise => { + return this.connection.sendRequest(TextDocumentContentRefreshRequest.type, { uri }); + }, + on: (handler: RequestHandler): Disposable => { + return this.connection.onRequest(TextDocumentContentRequest.type, (params, cancel) => { + return handler(params, cancel); + }); + } + }; + } + }; +}; \ No newline at end of file diff --git a/server/src/common/workspaceFolder.ts b/server/src/common/workspaceFolder.ts index 0170bc04c..b3a3b345b 100644 --- a/server/src/common/workspaceFolder.ts +++ b/server/src/common/workspaceFolder.ts @@ -11,7 +11,6 @@ import { import type { Feature, _RemoteWorkspace } from './server'; - export interface WorkspaceFolders { getWorkspaceFolders(): Promise; onDidChangeWorkspaceFolders: Event; diff --git a/testbed/client/src/extension.ts b/testbed/client/src/extension.ts index 1f5519bd1..89575fd5e 100644 --- a/testbed/client/src/extension.ts +++ b/testbed/client/src/extension.ts @@ -5,7 +5,7 @@ 'use strict'; import * as path from 'path'; -import { commands, ExtensionContext, workspace, window } from 'vscode'; +import { commands, ExtensionContext, workspace, window, Uri } from 'vscode'; import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind, NotificationType } from 'vscode-languageclient/node'; let client: LanguageClient; @@ -106,6 +106,16 @@ REM or .bmp extension from c:\\source to c:\\images;;`; await window.showTextDocument(doc); }); }); + + commands.registerCommand('testbed.fileWithContent', async () => { + const doc = await workspace.openTextDocument(Uri.parse('test-content://file.txt')); + await window.showTextDocument(doc); + }); + + const refreshNotification: NotificationType = new NotificationType('testbed/refreshContent'); + commands.registerCommand('testbed.refreshContent', async () => { + await client.sendNotification(refreshNotification, 'test-content://file.txt'); + }); } export function deactivate() { diff --git a/testbed/package.json b/testbed/package.json index bb2b3abd9..b167a9312 100644 --- a/testbed/package.json +++ b/testbed/package.json @@ -17,6 +17,14 @@ { "command": "testbed.openFile", "title": "Open Test File" + }, + { + "command": "testbed.fileWithContent", + "title": "Open file with dynamic content" + }, + { + "command": "testbed.refreshContent", + "title": "Refresh dynamic content" } ], "configuration": { diff --git a/testbed/server/src/server.ts b/testbed/server/src/server.ts index d9690fbda..451b6a71d 100644 --- a/testbed/server/src/server.ts +++ b/testbed/server/src/server.ts @@ -17,7 +17,8 @@ import { TextEdit, ProposedFeatures, InsertTextFormat, SelectionRangeRequest, SelectionRange, InsertReplaceEdit, SemanticTokensClientCapabilities, SemanticTokensLegend, SemanticTokensBuilder, SemanticTokensRegistrationType, SemanticTokensRegistrationOptions, ProtocolNotificationType, ChangeAnnotation, WorkspaceChange, CompletionItemKind, DiagnosticSeverity, - DocumentDiagnosticReportKind, WorkspaceDiagnosticReport, NotebookDocuments, CompletionList, DidChangeConfigurationNotification + DocumentDiagnosticReportKind, WorkspaceDiagnosticReport, NotebookDocuments, CompletionList, DidChangeConfigurationNotification, + NotificationType } from 'vscode-languageserver/node'; import { @@ -155,6 +156,9 @@ connection.onInitialize((params, cancel, progress): Thenable | workspaceFolders: { supported: true, changeNotifications: true + }, + textDocumentContent: { + scheme: 'test-content' } }, implementationProvider: { @@ -177,7 +181,8 @@ connection.onInitialize((params, cancel, progress): Thenable | notebookSelector: [{ cells: [{ language: 'bat'}] }] - } + }, + } }; setTimeout(() => { @@ -692,6 +697,17 @@ connection.languages.semanticTokens.onRange((params) => { return { data: [] }; }); +let counter = 0; +connection.workspace.textDocumentContent.on((_param) => { + return `Text content version ${counter++}`; +}); + +const refreshNotification: NotificationType = new NotificationType('testbed/refreshContent'); +connection.onNotification(refreshNotification, async (uri) => { + await connection.workspace.textDocumentContent.refresh(uri); +}); + + const notebooks = new NotebookDocuments(TextDocument); notebooks.onDidOpen(() => { connection.console.log(`Notebook opened`);