Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Normalize all LSP URIs #1660

Merged
merged 4 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/langium/src/lsp/default-lsp-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -95,7 +96,7 @@ export function createDefaultSharedLSPModule(context: DefaultSharedModuleContext
FuzzyMatcher: () => new DefaultFuzzyMatcher(),
},
workspace: {
TextDocuments: () => new TextDocuments(TextDocument)
TextDocuments: () => new NormalizedTextDocuments(TextDocument)
}
};
}
1 change: 1 addition & 0 deletions packages/langium/src/lsp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 2 additions & 1 deletion packages/langium/src/lsp/lsp-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
217 changes: 217 additions & 0 deletions packages/langium/src/lsp/normalized-text-documents.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { uri: string }> {
msujew marked this conversation as resolved.
Show resolved Hide resolved
/**
* An event that fires when a text document managed by this manager
* has been opened.
*/
readonly onDidOpen: Event<TextDocumentChangeEvent<T>>;
/**
* An event that fires when a text document managed by this manager
* has been opened or the content changes.
*/
readonly onDidChangeContent: Event<TextDocumentChangeEvent<T>>;
/**
* An event that fires when a text document managed by this manager
* will be saved.
*/
readonly onWillSave: Event<TextDocumentWillSaveEvent<T>>;
/**
* Sets a handler that will be called if a participant wants to provide
* edits during a text document save.
*/
onWillSaveWaitUntil(handler: RequestHandler<TextDocumentWillSaveEvent<T>, TextEdit[], void>): void;
/**
* An event that fires when a text document managed by this manager
* has been saved.
*/
readonly onDidSave: Event<TextDocumentChangeEvent<T>>;
/**
* An event that fires when a text document managed by this manager
* has been closed.
*/
readonly onDidClose: Event<TextDocumentChangeEvent<T>>;
/**
* 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<T extends { uri: string }> implements TextDocuments<T> {

private readonly _configuration: TextDocumentsConfiguration<T>;

private readonly _syncedDocuments: Map<string, T>;

private readonly _onDidChangeContent: Emitter<TextDocumentChangeEvent<T>>;
private readonly _onDidOpen: Emitter<TextDocumentChangeEvent<T>>;
private readonly _onDidClose: Emitter<TextDocumentChangeEvent<T>>;
private readonly _onDidSave: Emitter<TextDocumentChangeEvent<T>>;
private readonly _onWillSave: Emitter<TextDocumentWillSaveEvent<T>>;
private _willSaveWaitUntil: RequestHandler<TextDocumentWillSaveEvent<T>, TextEdit[], void> | undefined;

public constructor(configuration: TextDocumentsConfiguration<T>) {
this._configuration = configuration;
this._syncedDocuments = new Map();

this._onDidChangeContent = new Emitter<TextDocumentChangeEvent<T>>();
this._onDidOpen = new Emitter<TextDocumentChangeEvent<T>>();
this._onDidClose = new Emitter<TextDocumentChangeEvent<T>>();
this._onDidSave = new Emitter<TextDocumentChangeEvent<T>>();
this._onWillSave = new Emitter<TextDocumentWillSaveEvent<T>>();
}

public get onDidOpen(): Event<TextDocumentChangeEvent<T>> {
return this._onDidOpen.event;
}

public get onDidChangeContent(): Event<TextDocumentChangeEvent<T>> {
return this._onDidChangeContent.event;
}

public get onWillSave(): Event<TextDocumentWillSaveEvent<T>> {
return this._onWillSave.event;
}

public onWillSaveWaitUntil(handler: RequestHandler<TextDocumentWillSaveEvent<T>, TextEdit[], void>) {
this._willSaveWaitUntil = handler;
}

public get onDidSave(): Event<TextDocumentChangeEvent<T>> {
return this._onDidSave.event;
}

public get onDidClose(): Event<TextDocumentChangeEvent<T>> {
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()); });
}
}
4 changes: 4 additions & 0 deletions packages/langium/src/utils/uri-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ export namespace UriUtils {
return backPart + toPart;
}

export function normalize(uri: URI | string): string {
return URI.parse(uri.toString()).toString();
}

}
20 changes: 20 additions & 0 deletions packages/langium/test/utils/uri-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

});
Loading