Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 2 additions & 4 deletions language-server/src/models/JsonDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@ import { TextDocumentContentChangeEvent } from "vscode-languageserver";
import { TextDocument } from "vscode-languageserver-textdocument";
import * as jsonc from "jsonc-parser";
import * as JsonPointer from "@hyperjump/json-pointer";
import { normalizeIri, resolveIri } from "@hyperjump/uri";
import { resolveIri } from "@hyperjump/uri";
import { parse } from "../parser/parse.ts";

import type { Position, Range } from "vscode-languageserver-textdocument";
import type { SyntaxError } from "../parser/parse.ts";

export class JsonDocument implements TextDocument {
private textDocument: TextDocument;
private normalizedUri: string;
private documentSchemaUri: string | undefined;
private ast: jsonc.Node | undefined;
private parseErrors: SyntaxError[] = [];

constructor(textDocument: TextDocument) {
this.textDocument = textDocument;
this.normalizedUri = normalizeIri(textDocument.uri);

this.parse();
}
Expand All @@ -41,7 +39,7 @@ export class JsonDocument implements TextDocument {
}

get uri() {
return this.normalizedUri;
return this.textDocument.uri;
}

get languageId() {
Expand Down
4 changes: 2 additions & 2 deletions language-server/src/services/JsonSchemaRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Pact from "@hyperjump/pact";
import { normalizeIri, resolveIri, toAbsoluteIri, toRelativeIri } from "@hyperjump/uri";
import { resolveIri, toAbsoluteIri, toRelativeIri } from "@hyperjump/uri";
import ignore from "ignore";
import * as jsonc from "jsonc-parser";

Expand Down Expand Up @@ -69,7 +69,7 @@ export class JsonSchemaRegistry {
workspace.onDidChangeWatchedFiles(async (params) => {
// Changes are applied in the order they're received. Otherwise, which of two schemas with the
// same id gets registered would depend on which file read finishes first.
const applied = this.pending.then(() => this.applyChanges(params.changes.map((change) => normalizeIri(change.uri))));
const applied = this.pending.then(() => this.applyChanges(params.changes.map((change) => change.uri)));
this.pending = applied.then(() => undefined, () => undefined);

// Handlers aren't part of the queue because they can wait on `ready`
Expand Down
100 changes: 69 additions & 31 deletions language-server/src/services/Server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { merge } from "merge-anything";
import { normalizeIri } from "@hyperjump/uri";

import type {
Connection,
Expand All @@ -13,6 +14,13 @@ import type {
ServerRequestHandler
} from "vscode-languageserver";

type UriParams = {
textDocument?: { uri: string };
changes?: unknown[];
};

type RegisterHandler = (handler: never) => Disposable;

export class Server implements Connection {
private connection: Connection;
private initializeHandlers: Set<ServerRequestHandler<InitializeParams, InitializeResult, never, InitializeError>>;
Expand Down Expand Up @@ -79,8 +87,15 @@ export class Server implements Connection {
let initializeResult: InitializeResult = {
capabilities: {}
};
const normalizedParams: InitializeParams = {
...params,
workspaceFolders: params.workspaceFolders?.map((workspaceFolder) => {
return { ...workspaceFolder, uri: normalizeIri(workspaceFolder.uri) };
})
};

for (const handler of this.initializeHandlers) {
const handlerResult = handler(params, token, workDoneProgress);
const handlerResult = handler(normalizedParams, token, workDoneProgress);
initializeResult = merge(initializeResult, handlerResult as InitializeResult);
}

Expand Down Expand Up @@ -125,43 +140,43 @@ export class Server implements Connection {
this.onProgress = this.connection.onProgress.bind(this.connection);
this.sendProgress = this.connection.sendProgress.bind(this.connection);
this.onDidChangeConfiguration = this.connection.onDidChangeConfiguration.bind(this.connection);
this.onDidChangeWatchedFiles = this.connection.onDidChangeWatchedFiles.bind(this.connection);
this.onDidOpenTextDocument = this.connection.onDidOpenTextDocument.bind(this.connection);
this.onDidChangeTextDocument = this.connection.onDidChangeTextDocument.bind(this.connection);
this.onDidCloseTextDocument = this.connection.onDidCloseTextDocument.bind(this.connection);
this.onWillSaveTextDocument = this.connection.onWillSaveTextDocument.bind(this.connection);
this.onWillSaveTextDocumentWaitUntil = this.connection.onWillSaveTextDocumentWaitUntil.bind(this.connection);
this.onDidSaveTextDocument = this.connection.onDidSaveTextDocument.bind(this.connection);
this.onDidChangeWatchedFiles = withNormalizedUris(this.connection.onDidChangeWatchedFiles.bind(this.connection));
this.onDidOpenTextDocument = withNormalizedUris(this.connection.onDidOpenTextDocument.bind(this.connection));
this.onDidChangeTextDocument = withNormalizedUris(this.connection.onDidChangeTextDocument.bind(this.connection));
this.onDidCloseTextDocument = withNormalizedUris(this.connection.onDidCloseTextDocument.bind(this.connection));
this.onWillSaveTextDocument = withNormalizedUris(this.connection.onWillSaveTextDocument.bind(this.connection));
this.onWillSaveTextDocumentWaitUntil = withNormalizedUris(this.connection.onWillSaveTextDocumentWaitUntil.bind(this.connection));
this.onDidSaveTextDocument = withNormalizedUris(this.connection.onDidSaveTextDocument.bind(this.connection));
this.sendDiagnostics = this.connection.sendDiagnostics.bind(this.connection);
this.onHover = this.connection.onHover.bind(this.connection);
this.onCompletion = this.connection.onCompletion.bind(this.connection);
this.onHover = withNormalizedUris(this.connection.onHover.bind(this.connection));
this.onCompletion = withNormalizedUris(this.connection.onCompletion.bind(this.connection));
this.onCompletionResolve = this.connection.onCompletionResolve.bind(this.connection);
this.onSignatureHelp = this.connection.onSignatureHelp.bind(this.connection);
this.onDeclaration = this.connection.onDeclaration.bind(this.connection);
this.onDefinition = this.connection.onDefinition.bind(this.connection);
this.onTypeDefinition = this.connection.onTypeDefinition.bind(this.connection);
this.onImplementation = this.connection.onImplementation.bind(this.connection);
this.onReferences = this.connection.onReferences.bind(this.connection);
this.onDocumentHighlight = this.connection.onDocumentHighlight.bind(this.connection);
this.onDocumentSymbol = this.connection.onDocumentSymbol.bind(this.connection);
this.onSignatureHelp = withNormalizedUris(this.connection.onSignatureHelp.bind(this.connection));
this.onDeclaration = withNormalizedUris(this.connection.onDeclaration.bind(this.connection));
this.onDefinition = withNormalizedUris(this.connection.onDefinition.bind(this.connection));
this.onTypeDefinition = withNormalizedUris(this.connection.onTypeDefinition.bind(this.connection));
this.onImplementation = withNormalizedUris(this.connection.onImplementation.bind(this.connection));
this.onReferences = withNormalizedUris(this.connection.onReferences.bind(this.connection));
this.onDocumentHighlight = withNormalizedUris(this.connection.onDocumentHighlight.bind(this.connection));
this.onDocumentSymbol = withNormalizedUris(this.connection.onDocumentSymbol.bind(this.connection));
this.onWorkspaceSymbol = this.connection.onWorkspaceSymbol.bind(this.connection);
this.onWorkspaceSymbolResolve = this.connection.onWorkspaceSymbolResolve.bind(this.connection);
this.onCodeAction = this.connection.onCodeAction.bind(this.connection);
this.onCodeAction = withNormalizedUris(this.connection.onCodeAction.bind(this.connection));
this.onCodeActionResolve = this.connection.onCodeActionResolve.bind(this.connection);
this.onCodeLens = this.connection.onCodeLens.bind(this.connection);
this.onCodeLens = withNormalizedUris(this.connection.onCodeLens.bind(this.connection));
this.onCodeLensResolve = this.connection.onCodeLensResolve.bind(this.connection);
this.onDocumentFormatting = this.connection.onDocumentFormatting.bind(this.connection);
this.onDocumentRangeFormatting = this.connection.onDocumentRangeFormatting.bind(this.connection);
this.onDocumentRangesFormatting = this.connection.onDocumentRangesFormatting.bind(this.connection);
this.onDocumentOnTypeFormatting = this.connection.onDocumentOnTypeFormatting.bind(this.connection);
this.onRenameRequest = this.connection.onRenameRequest.bind(this.connection);
this.onPrepareRename = this.connection.onPrepareRename.bind(this.connection);
this.onDocumentLinks = this.connection.onDocumentLinks.bind(this.connection);
this.onDocumentFormatting = withNormalizedUris(this.connection.onDocumentFormatting.bind(this.connection));
this.onDocumentRangeFormatting = withNormalizedUris(this.connection.onDocumentRangeFormatting.bind(this.connection));
this.onDocumentRangesFormatting = withNormalizedUris(this.connection.onDocumentRangesFormatting.bind(this.connection));
this.onDocumentOnTypeFormatting = withNormalizedUris(this.connection.onDocumentOnTypeFormatting.bind(this.connection));
this.onRenameRequest = withNormalizedUris(this.connection.onRenameRequest.bind(this.connection));
this.onPrepareRename = withNormalizedUris(this.connection.onPrepareRename.bind(this.connection));
this.onDocumentLinks = withNormalizedUris(this.connection.onDocumentLinks.bind(this.connection));
this.onDocumentLinkResolve = this.connection.onDocumentLinkResolve.bind(this.connection);
this.onDocumentColor = this.connection.onDocumentColor.bind(this.connection);
this.onColorPresentation = this.connection.onColorPresentation.bind(this.connection);
this.onFoldingRanges = this.connection.onFoldingRanges.bind(this.connection);
this.onSelectionRanges = this.connection.onSelectionRanges.bind(this.connection);
this.onDocumentColor = withNormalizedUris(this.connection.onDocumentColor.bind(this.connection));
this.onColorPresentation = withNormalizedUris(this.connection.onColorPresentation.bind(this.connection));
this.onFoldingRanges = withNormalizedUris(this.connection.onFoldingRanges.bind(this.connection));
this.onSelectionRanges = withNormalizedUris(this.connection.onSelectionRanges.bind(this.connection));
this.onExecuteCommand = this.connection.onExecuteCommand.bind(this.connection);
this.dispose = this.connection.dispose.bind(this.connection);
}
Expand Down Expand Up @@ -238,3 +253,26 @@ export class Server implements Connection {
return this.connection.notebooks;
}
}

// URIs from the client are normalized here so they match URIs the server produces with @hyperjump/uri.
// Everything past this point can compare URIs without normalizing them again.
const normalizeUris = <P extends UriParams>(params: P): P => {
if (params.textDocument) {
return { ...params, textDocument: { ...params.textDocument, uri: normalizeIri(params.textDocument.uri) } };
} else if (params.changes) {
// Watched file changes. Text document changes have a textDocument, so they're handled above.
const changes = params.changes as { uri: string }[];
return { ...params, changes: changes.map((change) => ({ ...change, uri: normalizeIri(change.uri) })) };
} else {
return params;
}
};

// The handler types vary too much between methods to express, but every wrapped method takes a handler whose
// first argument is the params
const withNormalizedUris = <R extends RegisterHandler>(register: R): R => {
const wrapped = (handler: (params: UriParams, ...args: unknown[]) => unknown) => {
return register(((params: UriParams, ...args: unknown[]) => handler(normalizeUris(params), ...args)) as never);
};
return wrapped as unknown as R;
};
2 changes: 1 addition & 1 deletion language-server/src/services/Workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class Workspace {
server.onInitialize(({ capabilities, workspaceFolders }) => {
if (workspaceFolders) {
for (const workspaceFolder of workspaceFolders) {
this.workspaceFolders.add(normalizeIri(workspaceFolder.uri));
this.workspaceFolders.add(workspaceFolder.uri);
}
}

Expand Down
25 changes: 16 additions & 9 deletions language-server/src/test/TestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "vscode-languageserver";
import { createConnection } from "vscode-languageserver/node";
import { normalizeIri, resolveIri } from "@hyperjump/uri";
import { URI } from "vscode-uri";
import { merge } from "merge-anything";
import { MockAgent, setGlobalDispatcher } from "undici";
import ignore from "ignore";
Expand All @@ -37,6 +38,10 @@ import type {
} from "vscode-languageserver";
import type { LanguageServerSettings } from "../build-server.js";

// Encodes a URI the way VS Code does, e.g. "file:///c:/a@b" becomes "file:///c%3A/a%40b". Every URI sent to
// the server goes through this so tests cover URIs that aren't normalized.
const toClientUri = (uri: string) => URI.parse(uri).toString();

export class TestClient {
private client: Connection;
private serverName: string;
Expand All @@ -61,8 +66,10 @@ export class TestClient {
this.serverName = serverName;
this.watchEnabled = false;
this.openDocuments = new Set();
this.workspaceFolder = mkdtemp(join(tmpdir(), "test-workspace-"))
.then((path) => normalizeIri(pathToFileURL(path).toString()));
// The "@" is percent-encoded by toClientUri, so URIs from the client never match normalized URIs as-is.
// URIs are kept in the form the server sees them, which includes VS Code's lowercase drive letter on Windows.
this.workspaceFolder = mkdtemp(join(tmpdir(), "test-workspace@"))
.then((path) => normalizeIri(toClientUri(pathToFileURL(path).toString())));
this.gitignore = this.workspaceFolder.then(async (rootPath) => {
const gitignorePath = join(rootPath, ".gitignore");
try {
Expand Down Expand Up @@ -134,7 +141,7 @@ export class TestClient {
glob(params.include, { cwd: workspacePath }),
Pact.asyncFilter((file) => !ig.ignores(file)),
Pact.asyncMap((relativePath: string) => join(workspacePath, relativePath)),
Pact.asyncMap((fullPath: string) => pathToFileURL(fullPath).toString()),
Pact.asyncMap((fullPath: string) => URI.file(fullPath).toString()),
Pact.asyncTake(params.maxResults ?? Number.MAX_SAFE_INTEGER),
Pact.asyncCollectArray
);
Expand Down Expand Up @@ -222,7 +229,7 @@ export class TestClient {
workspaceFolders: [
{
name: "root",
uri: await this.workspaceFolder
uri: toClientUri(await this.workspaceFolder)
}
]
};
Expand Down Expand Up @@ -273,7 +280,7 @@ export class TestClient {
await this.client.sendNotification(DidChangeWatchedFilesNotification.type, {
changes: [{
type: exists ? FileChangeType.Changed : FileChangeType.Created,
uri: fullUri.toString()
uri: toClientUri(fullUri)
}]
});
}
Expand All @@ -289,7 +296,7 @@ export class TestClient {
await this.client.sendNotification(DidChangeWatchedFilesNotification.type, {
changes: [{
type: FileChangeType.Deleted,
uri: fullUri
uri: toClientUri(fullUri)
}]
});
}
Expand All @@ -303,7 +310,7 @@ export class TestClient {

await this.client.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: {
uri: fullUri,
uri: toClientUri(fullUri),
languageId: fullPath.endsWith(".jsonc") ? "jsonc" : "json",
version: 0,
text: await readFile(fullPath, "utf-8")
Expand All @@ -320,7 +327,7 @@ export class TestClient {

await this.client.sendNotification(DidChangeTextDocumentNotification.type, {
textDocument: {
uri: fullUri,
uri: toClientUri(fullUri),
version: 1
},
contentChanges: [{ text }]
Expand All @@ -335,7 +342,7 @@ export class TestClient {

await this.client.sendNotification(DidCloseTextDocumentNotification.type, {
textDocument: {
uri: fullUri
uri: toClientUri(fullUri)
}
});
}
Expand Down
Loading