Skip to content

Commit

Permalink
Normalize all LSP URIs (#1660)
Browse files Browse the repository at this point in the history
  • Loading branch information
msujew authored Sep 4, 2024
1 parent 99fb880 commit 27ab6b2
Show file tree
Hide file tree
Showing 14 changed files with 336 additions and 66 deletions.
8 changes: 4 additions & 4 deletions examples/domainmodel/test/refs-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import type { AstNode, LangiumDocument, ReferenceDescription, URI } from 'langium';
import { AstUtils, EmptyFileSystem, TextDocument } from 'langium';
import { parseDocument, setTextDocument } from 'langium/test';
import { parseDocument } from 'langium/test';
import { describe, expect, test } from 'vitest';
import { createDomainModelServices } from '../src/language-server/domain-model-module.js';
import type { Domainmodel } from '../src/language-server/generated/ast.js';
Expand All @@ -20,15 +20,15 @@ describe('Cross references indexed after affected process', () => {
let allRefs = await getReferences((superDoc.parseResult.value as Domainmodel).elements[0]);
expect(allRefs.length).toEqual(0); // linking error

setTextDocument(
shared,
shared.workspace.TextDocuments.set(
TextDocument.create(
superDoc.textDocument.uri.toString(),
superDoc.textDocument.uri,
superDoc.textDocument.languageId,
0,
'entity SuperEntity {}'
)
);

await shared.workspace.DocumentBuilder.update([superDoc.uri], []);

const updatedSuperDoc = await shared.workspace.LangiumDocuments.getOrCreateDocument(superDoc.uri);
Expand Down
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
255 changes: 255 additions & 0 deletions packages/langium/src/lsp/normalized-text-documents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/******************************************************************************
* 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 type { URI } from '../utils/uri-utils.js';
import { UriUtils } from '../utils/uri-utils.js';

/**
* A manager service that keeps track of all currently opened text documents.
*
* Designed to be compatible with the `TextDocuments` class in the `vscode-languageserver` package.
*/
export interface TextDocuments<T extends { uri: string }> {
/**
* 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 | URI): T | undefined;
/**
* Sets the text document managed by this instance.
* @param document The text document to add.
* @returns `true` if the document didn't exist yet, `false` if it was already present.
*/
set(document: T): boolean;
/**
* Deletes a text document managed by this instance.
*/
delete(uri: string | URI | T): void;
/**
* 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 | URI): T | undefined {
return this._syncedDocuments.get(UriUtils.normalize(uri));
}

public set(document: T): boolean {
const uri = UriUtils.normalize(document.uri);
let result = true;
if (this._syncedDocuments.has(uri)) {
result = false;
}
this._syncedDocuments.set(uri, document);
const toFire = Object.freeze({ document });
this._onDidOpen.fire(toFire);
this._onDidChangeContent.fire(toFire);
return result;
}

public delete(uri: string | T | URI): void {
const uriString = UriUtils.normalize(typeof uri === 'object' && 'uri' in uri ? uri.uri : uri);
const syncedDocument = this._syncedDocuments.get(uriString);
if (syncedDocument !== undefined) {
this._syncedDocuments.delete(uriString);
this._onDidClose.fire(Object.freeze({ document: syncedDocument }));
}
}

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()); });
}
}
2 changes: 1 addition & 1 deletion packages/langium/src/service-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class DefaultServiceRegistry implements ServiceRegistry {
if (this.languageIdMap.size === 0) {
throw new Error('The service registry is empty. Use `register` to register the services of a language.');
}
const languageId = this.textDocuments?.get(uri.toString())?.languageId;
const languageId = this.textDocuments?.get(uri)?.languageId;
if (languageId !== undefined) {
const services = this.languageIdMap.get(languageId);
if (services) {
Expand Down
9 changes: 5 additions & 4 deletions packages/langium/src/test/langium-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,14 +701,15 @@ export function expectWarning<T extends AstNode = AstNode, N extends AstNode = A

/**
* Add the given document to the `TextDocuments` service, simulating it being opened in an editor.
*
* @deprecated Since 3.2.0. Use `set`/`delete` from `TextDocuments` instead.
*/
export function setTextDocument(services: LangiumServices | LangiumSharedLSPServices, document: TextDocument): Disposable {
const shared = 'shared' in services ? services.shared : services;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const textDocuments = shared.workspace.TextDocuments as any;
textDocuments._syncedDocuments.set(document.uri, document);
const textDocuments = shared.workspace.TextDocuments;
textDocuments.set(document);
return Disposable.create(() => {
textDocuments._syncedDocuments.delete(document.uri);
textDocuments.delete(document.uri);
});
}

Expand Down
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();
}

}
2 changes: 1 addition & 1 deletion packages/langium/src/workspace/document-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ export class DefaultDocumentBuilder implements DocumentBuilder {
}

private hasTextDocument(doc: LangiumDocument): boolean {
return Boolean(this.textDocuments?.get(doc.uri.toString()));
return Boolean(this.textDocuments?.get(doc.uri));
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/langium/src/workspace/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export interface DocumentSegment {
* No implementation object is expected to be offered by `LangiumCoreServices`, but only by `LangiumLSPServices`.
*/
export type TextDocumentProvider = {
get(uri: string): TextDocument | undefined
get(uri: string | URI): TextDocument | undefined
}

/**
Expand Down
Loading

0 comments on commit 27ab6b2

Please sign in to comment.