diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index 7221f2d..423efa2 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -2,7 +2,7 @@ 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"; @@ -10,14 +10,12 @@ 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(); } @@ -41,7 +39,7 @@ export class JsonDocument implements TextDocument { } get uri() { - return this.normalizedUri; + return this.textDocument.uri; } get languageId() { diff --git a/language-server/src/services/JsonSchemaRegistry.ts b/language-server/src/services/JsonSchemaRegistry.ts index 3af192e..575cb53 100644 --- a/language-server/src/services/JsonSchemaRegistry.ts +++ b/language-server/src/services/JsonSchemaRegistry.ts @@ -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"; @@ -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` diff --git a/language-server/src/services/Server.ts b/language-server/src/services/Server.ts index 16e9672..e4ca48b 100644 --- a/language-server/src/services/Server.ts +++ b/language-server/src/services/Server.ts @@ -1,4 +1,5 @@ import { merge } from "merge-anything"; +import { normalizeIri } from "@hyperjump/uri"; import type { Connection, @@ -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>; @@ -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); } @@ -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); } @@ -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 =

(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 = (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; +}; diff --git a/language-server/src/services/Workspace.ts b/language-server/src/services/Workspace.ts index d912648..b53e898 100644 --- a/language-server/src/services/Workspace.ts +++ b/language-server/src/services/Workspace.ts @@ -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); } } diff --git a/language-server/src/test/TestClient.ts b/language-server/src/test/TestClient.ts index ddb275d..45e6b9f 100644 --- a/language-server/src/test/TestClient.ts +++ b/language-server/src/test/TestClient.ts @@ -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"; @@ -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; @@ -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 { @@ -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 ); @@ -222,7 +229,7 @@ export class TestClient { workspaceFolders: [ { name: "root", - uri: await this.workspaceFolder + uri: toClientUri(await this.workspaceFolder) } ] }; @@ -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) }] }); } @@ -289,7 +296,7 @@ export class TestClient { await this.client.sendNotification(DidChangeWatchedFilesNotification.type, { changes: [{ type: FileChangeType.Deleted, - uri: fullUri + uri: toClientUri(fullUri) }] }); } @@ -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") @@ -320,7 +327,7 @@ export class TestClient { await this.client.sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { - uri: fullUri, + uri: toClientUri(fullUri), version: 1 }, contentChanges: [{ text }] @@ -335,7 +342,7 @@ export class TestClient { await this.client.sendNotification(DidCloseTextDocumentNotification.type, { textDocument: { - uri: fullUri + uri: toClientUri(fullUri) } }); }