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 3 commits
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
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
253 changes: 253 additions & 0 deletions packages/langium/src/lsp/normalized-text-documents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
/******************************************************************************
* 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 }> {
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 | 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.toString()));
msujew marked this conversation as resolved.
Show resolved Hide resolved
}

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);
this._onDidOpen.fire(Object.freeze({ document }));
return result;
}

public delete(uri: string | T | URI): void {
const uriString = UriUtils.normalize(typeof uri === 'string' ? uri : 'uri' in uri ? uri.uri : uri.toString());
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
Loading