From a140a64fb9254f49d6398941d41dd8065d69cb67 Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Mon, 2 Sep 2024 15:11:35 +0200 Subject: [PATCH] Normalize all LSP URIs --- .../langium/src/lsp/default-lsp-module.ts | 5 +- packages/langium/src/lsp/index.ts | 1 + packages/langium/src/lsp/lsp-services.ts | 3 +- .../src/lsp/normalized-text-documents.ts | 217 ++++++++++++++++++ packages/langium/src/utils/uri-utils.ts | 4 + packages/langium/test/utils/uri-utils.test.ts | 20 ++ 6 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 packages/langium/src/lsp/normalized-text-documents.ts diff --git a/packages/langium/src/lsp/default-lsp-module.ts b/packages/langium/src/lsp/default-lsp-module.ts index b5e1a3326..fa972ef25 100644 --- a/packages/langium/src/lsp/default-lsp-module.ts +++ b/packages/langium/src/lsp/default-lsp-module.ts @@ -4,7 +4,7 @@ * terms of the MIT License, which is available in the project root. ******************************************************************************/ -import { type Connection, TextDocuments } from 'vscode-languageserver'; +import type { Connection } from 'vscode-languageserver'; import { createDefaultCoreModule, createDefaultSharedCoreModule, type DefaultCoreModuleContext, type DefaultSharedCoreModuleContext } from '../default-module.js'; import { Module } from '../dependency-injection.js'; import type { LangiumDefaultCoreServices, LangiumDefaultSharedCoreServices } from '../services.js'; @@ -23,6 +23,7 @@ import { DefaultNodeKindProvider } from './node-kind-provider.js'; import { DefaultReferencesProvider } from './references-provider.js'; import { DefaultRenameProvider } from './rename-provider.js'; import { DefaultWorkspaceSymbolProvider } from './workspace-symbol-provider.js'; +import { NormalizedTextDocuments } from './normalized-text-documents.js'; /** * Context required for creating the default language-specific dependency injection module. @@ -95,7 +96,7 @@ export function createDefaultSharedLSPModule(context: DefaultSharedModuleContext FuzzyMatcher: () => new DefaultFuzzyMatcher(), }, workspace: { - TextDocuments: () => new TextDocuments(TextDocument) + TextDocuments: () => new NormalizedTextDocuments(TextDocument) } }; } diff --git a/packages/langium/src/lsp/index.ts b/packages/langium/src/lsp/index.ts index bd5698128..34b746669 100644 --- a/packages/langium/src/lsp/index.ts +++ b/packages/langium/src/lsp/index.ts @@ -27,6 +27,7 @@ export * from './inlay-hint-provider.js'; export * from './language-server.js'; export * from './lsp-services.js'; export * from './node-kind-provider.js'; +export * from './normalized-text-documents.js'; export * from './references-provider.js'; export * from './rename-provider.js'; export * from './semantic-token-provider.js'; diff --git a/packages/langium/src/lsp/lsp-services.ts b/packages/langium/src/lsp/lsp-services.ts index 25d1929c9..19d904ac1 100644 --- a/packages/langium/src/lsp/lsp-services.ts +++ b/packages/langium/src/lsp/lsp-services.ts @@ -4,7 +4,7 @@ * terms of the MIT License, which is available in the project root. ******************************************************************************/ -import type { Connection, TextDocuments } from 'vscode-languageserver'; +import type { Connection } from 'vscode-languageserver'; import type { DeepPartial, LangiumCoreServices, LangiumSharedCoreServices } from '../services.js'; import type { TextDocument } from '../workspace/documents.js'; import type { CallHierarchyProvider } from './call-hierarchy-provider.js'; @@ -34,6 +34,7 @@ import type { SignatureHelpProvider } from './signature-help-provider.js'; import type { TypeHierarchyProvider } from './type-hierarchy-provider.js'; import type { TypeDefinitionProvider } from './type-provider.js'; import type { WorkspaceSymbolProvider } from './workspace-symbol-provider.js'; +import type { TextDocuments } from './normalized-text-documents.js'; /** * Combined Core + LSP services of Langium (total services) diff --git a/packages/langium/src/lsp/normalized-text-documents.ts b/packages/langium/src/lsp/normalized-text-documents.ts new file mode 100644 index 000000000..88465bc92 --- /dev/null +++ b/packages/langium/src/lsp/normalized-text-documents.ts @@ -0,0 +1,217 @@ +/****************************************************************************** + * Copyright 2024 TypeFox GmbH + * This program and the accompanying materials are made available under the + * terms of the MIT License, which is available in the project root. + ******************************************************************************/ + +import type { + Connection, DidOpenTextDocumentParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, TextDocumentsConfiguration, TextDocumentChangeEvent, + TextDocumentWillSaveEvent, RequestHandler, TextEdit, Event, WillSaveTextDocumentParams, CancellationToken, DidSaveTextDocumentParams +} from 'vscode-languageserver'; +import { TextDocumentSyncKind, Disposable, Emitter } from 'vscode-languageserver'; +import { UriUtils } from '../utils/uri-utils.js'; + +export interface TextDocuments { + /** + * An event that fires when a text document managed by this manager + * has been opened. + */ + readonly onDidOpen: Event>; + /** + * An event that fires when a text document managed by this manager + * has been opened or the content changes. + */ + readonly onDidChangeContent: Event>; + /** + * An event that fires when a text document managed by this manager + * will be saved. + */ + readonly onWillSave: Event>; + /** + * Sets a handler that will be called if a participant wants to provide + * edits during a text document save. + */ + onWillSaveWaitUntil(handler: RequestHandler, TextEdit[], void>): void; + /** + * An event that fires when a text document managed by this manager + * has been saved. + */ + readonly onDidSave: Event>; + /** + * An event that fires when a text document managed by this manager + * has been closed. + */ + readonly onDidClose: Event>; + /** + * Returns the document for the given URI. Returns undefined if + * the document is not managed by this instance. + * + * @param uri The text document's URI to retrieve. + * @return the text document or `undefined`. + */ + get(uri: string): T | undefined; + /** + * Returns all text documents managed by this instance. + * + * @return all text documents. + */ + all(): T[]; + /** + * Returns the URIs of all text documents managed by this instance. + * + * @return the URI's of all text documents. + */ + keys(): string[]; + /** + * Listens for `low level` notification on the given connection to + * update the text documents managed by this instance. + * + * Please note that the connection only provides handlers not an event model. Therefore + * listening on a connection will overwrite the following handlers on a connection: + * `onDidOpenTextDocument`, `onDidChangeTextDocument`, `onDidCloseTextDocument`, + * `onWillSaveTextDocument`, `onWillSaveTextDocumentWaitUntil` and `onDidSaveTextDocument`. + * + * Use the corresponding events on the TextDocuments instance instead. + * + * @param connection The connection to listen on. + */ + listen(connection: Connection): Disposable; +} + +// Adapted from: +// https://github.com/microsoft/vscode-languageserver-node/blob/8f5fa710d3a9f60ff5e7583a9e61b19f86e39da3/server/src/common/textDocuments.ts + +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +/** + * Normalizing text document manager. Normalizes all incoming URIs to the same format used by VS Code. + */ +export class NormalizedTextDocuments implements TextDocuments { + + private readonly _configuration: TextDocumentsConfiguration; + + private readonly _syncedDocuments: Map; + + private readonly _onDidChangeContent: Emitter>; + private readonly _onDidOpen: Emitter>; + private readonly _onDidClose: Emitter>; + private readonly _onDidSave: Emitter>; + private readonly _onWillSave: Emitter>; + private _willSaveWaitUntil: RequestHandler, TextEdit[], void> | undefined; + + public constructor(configuration: TextDocumentsConfiguration) { + this._configuration = configuration; + this._syncedDocuments = new Map(); + + this._onDidChangeContent = new Emitter>(); + this._onDidOpen = new Emitter>(); + this._onDidClose = new Emitter>(); + this._onDidSave = new Emitter>(); + this._onWillSave = new Emitter>(); + } + + public get onDidOpen(): Event> { + return this._onDidOpen.event; + } + + public get onDidChangeContent(): Event> { + return this._onDidChangeContent.event; + } + + public get onWillSave(): Event> { + return this._onWillSave.event; + } + + public onWillSaveWaitUntil(handler: RequestHandler, TextEdit[], void>) { + this._willSaveWaitUntil = handler; + } + + public get onDidSave(): Event> { + return this._onDidSave.event; + } + + public get onDidClose(): Event> { + return this._onDidClose.event; + } + + public get(uri: string): T | undefined { + return this._syncedDocuments.get(UriUtils.normalize(uri)); + } + + public all(): T[] { + return Array.from(this._syncedDocuments.values()); + } + + public keys(): string[] { + return Array.from(this._syncedDocuments.keys()); + } + + public listen(connection: Connection): Disposable { + // Required for interoperability with the the vscode-languageserver package + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (connection as any).__textDocumentSync = TextDocumentSyncKind.Incremental; + const disposables: Disposable[] = []; + disposables.push(connection.onDidOpenTextDocument((event: DidOpenTextDocumentParams) => { + const td = event.textDocument; + const uri = UriUtils.normalize(td.uri); + const document = this._configuration.create(uri, td.languageId, td.version, td.text); + + this._syncedDocuments.set(uri, document); + const toFire = Object.freeze({ document }); + this._onDidOpen.fire(toFire); + this._onDidChangeContent.fire(toFire); + })); + disposables.push(connection.onDidChangeTextDocument((event: DidChangeTextDocumentParams) => { + const td = event.textDocument; + const changes = event.contentChanges; + if (changes.length === 0) { + return; + } + + const { version } = td; + if (version === null || version === undefined) { + throw new Error(`Received document change event for ${td.uri} without valid version identifier`); + } + const uri = UriUtils.normalize(td.uri); + + let syncedDocument = this._syncedDocuments.get(uri); + if (syncedDocument !== undefined) { + syncedDocument = this._configuration.update(syncedDocument, changes, version); + this._syncedDocuments.set(uri, syncedDocument); + this._onDidChangeContent.fire(Object.freeze({ document: syncedDocument })); + } + })); + disposables.push(connection.onDidCloseTextDocument((event: DidCloseTextDocumentParams) => { + const uri = UriUtils.normalize(event.textDocument.uri); + const syncedDocument = this._syncedDocuments.get(uri); + if (syncedDocument !== undefined) { + this._syncedDocuments.delete(uri); + this._onDidClose.fire(Object.freeze({ document: syncedDocument })); + } + })); + disposables.push(connection.onWillSaveTextDocument((event: WillSaveTextDocumentParams) => { + const syncedDocument = this._syncedDocuments.get(UriUtils.normalize(event.textDocument.uri)); + if (syncedDocument !== undefined) { + this._onWillSave.fire(Object.freeze({ document: syncedDocument, reason: event.reason })); + } + })); + disposables.push(connection.onWillSaveTextDocumentWaitUntil((event: WillSaveTextDocumentParams, token: CancellationToken) => { + const syncedDocument = this._syncedDocuments.get(UriUtils.normalize(event.textDocument.uri)); + if (syncedDocument !== undefined && this._willSaveWaitUntil) { + return this._willSaveWaitUntil(Object.freeze({ document: syncedDocument, reason: event.reason }), token); + } else { + return []; + } + })); + disposables.push(connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => { + const syncedDocument = this._syncedDocuments.get(UriUtils.normalize(event.textDocument.uri)); + if (syncedDocument !== undefined) { + this._onDidSave.fire(Object.freeze({ document: syncedDocument })); + } + })); + return Disposable.create(() => { disposables.forEach(disposable => disposable.dispose()); }); + } +} diff --git a/packages/langium/src/utils/uri-utils.ts b/packages/langium/src/utils/uri-utils.ts index c7d0d5760..81fb80d43 100644 --- a/packages/langium/src/utils/uri-utils.ts +++ b/packages/langium/src/utils/uri-utils.ts @@ -36,4 +36,8 @@ export namespace UriUtils { return backPart + toPart; } + export function normalize(uri: URI | string): string { + return URI.parse(uri.toString()).toString(); + } + } diff --git a/packages/langium/test/utils/uri-utils.test.ts b/packages/langium/test/utils/uri-utils.test.ts index 004d6818e..b4686d238 100644 --- a/packages/langium/test/utils/uri-utils.test.ts +++ b/packages/langium/test/utils/uri-utils.test.ts @@ -52,3 +52,23 @@ describe('URI Utils', () => { expect(UriUtils.equals(uri1, undefined)).toBeFalsy(); }); }); + +describe('URIUtils#normalize', () => { + + test('Should normalize document URIs', () => { + const vscodeWindowsUri = 'file:///c%3A/path/to/file.txt'; + const upperCaseDriveUri = 'file:///C:/path/to/file.txt'; + const lowerCaseDriveUri = 'file:///c:/path/to/file.txt'; + + const normalized = vscodeWindowsUri; + expect(UriUtils.normalize(vscodeWindowsUri)).toBe(normalized); + expect(UriUtils.normalize(upperCaseDriveUri)).toBe(normalized); + expect(UriUtils.normalize(lowerCaseDriveUri)).toBe(normalized); + }); + + test('Should work as usual with POSIX URIs', () => { + const uri = 'file:///path/to/file.txt'; + expect(UriUtils.normalize(uri)).toBe(uri); + }); + +});