Skip to content

Commit ab7eedb

Browse files
committed
Work around Uri round-tripping issue in VS Code for sourcekit-lsp scheme
`vscode.uri` fails to round-trip URIs that have both a `=` and `%3D` (percent-encoded `=`) in the query component. ```ts vscode.Uri.parse("scheme://host?outer=inner%3Dvalue").toString() -> 'scheme://host?outer%3Dinner%3Dvalue' vscode.Uri.parse("scheme://host?outer=inner%3Dvalue").toString(/*skipEncoding*/ true) -> 'scheme://host?outer=inner=value' ``` The SourceKit-LSP scheme relies heavily on encoding options in the query parameters, eg. for Swift macro expansions and the values of those query parameters might contain percent-encoded `=` signs. To work around the round-trip issue, use the URL type from Node.js to parse the URI and then map the URL components to the Uri components in VS Code. There’s still some mapping logic needed because the two don't agree on whether to include eg. `?` as part of the query or not, but it’s fairly straightforward.
1 parent 66c6393 commit ab7eedb

File tree

3 files changed

+239
-0
lines changed

3 files changed

+239
-0
lines changed

src/sourcekit-lsp/LanguageClientManager.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { LSPLogger, LSPOutputChannel } from "./LSPOutputChannel";
3030
import { SwiftOutputChannel } from "../ui/SwiftOutputChannel";
3131
import { promptForDiagnostics } from "../commands/captureDiagnostics";
3232
import { activateGetReferenceDocument } from "./getReferenceDocument";
33+
import { uriConverters } from "./uriConverters";
3334

3435
interface SourceKitLogMessageParams extends langclient.LogMessageParams {
3536
logName?: string;
@@ -574,6 +575,7 @@ export class LanguageClientManager {
574575
};
575576
})(),
576577
},
578+
uriConverters: uriConverters,
577579
errorHandler,
578580
// Avoid attempting to reinitialize multiple times. If we fail to initialize
579581
// we aren't doing anything different the second time and so will fail again.

src/sourcekit-lsp/uriConverters.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2024 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import * as vscode from "vscode";
16+
17+
export const uriConverters = {
18+
protocol2Code: (value: string): vscode.Uri => {
19+
if (!value.startsWith("sourcekit-lsp:")) {
20+
// Use the default implementation for all schemes other than sourcekit-lsp, as defined here:
21+
// https://github.com/microsoft/vscode-languageserver-node/blob/14ddabfc22187b698e83ecde072247aa40727308/client/src/common/protocolConverter.ts#L286
22+
return vscode.Uri.parse(value);
23+
}
24+
25+
// vscode.uri fails to round-trip URIs that have both a `=` and `%3D` (percent-encoded `=`) in the query component.
26+
// ```ts
27+
// vscode.Uri.parse("scheme://host?outer=inner%3Dvalue").toString() -> 'scheme://host?outer%3Dinner%3Dvalue'
28+
// vscode.Uri.parse("scheme://host?outer=inner%3Dvalue").toString(/*skipEncoding*/ true) -> 'scheme://host?outer=inner=value'
29+
// ```
30+
// The SourceKit-LSP scheme relies heavily on encoding options in the query parameters, eg. for Swift macro
31+
// expansions and the values of those query parameters might contain percent-encoded `=` signs.
32+
//
33+
// To work around the round-trip issue, use the URL type from Node.js to parse the URI and then map the URL
34+
// components to the Uri components in VS Code.
35+
const url = new URL(value);
36+
let scheme = url.protocol;
37+
if (scheme.endsWith(":")) {
38+
// URL considers ':' part of the protocol, `vscode.URI` does not consider it part of the scheme.
39+
scheme = scheme.substring(0, scheme.length - 1);
40+
}
41+
42+
let auth = url.username;
43+
if (url.password) {
44+
auth += ":" + url.password;
45+
}
46+
let host = url.host;
47+
if (auth) {
48+
host = auth + "@" + host;
49+
}
50+
51+
let query = url.search;
52+
if (query.startsWith("?")) {
53+
// URL considers '?' not part of the search, `vscode.URI` does consider '?' part of the query.
54+
query = query.substring(1);
55+
}
56+
57+
let fragment = url.hash;
58+
if (fragment.startsWith("#")) {
59+
// URL considers '#' not part of the hash, `vscode.URI` does consider '#' part of the fragment.
60+
fragment = fragment.substring(1);
61+
}
62+
63+
return vscode.Uri.from({
64+
scheme: scheme,
65+
authority: host,
66+
path: url.pathname,
67+
query: query,
68+
fragment: fragment,
69+
});
70+
},
71+
code2Protocol: (value: vscode.Uri): string => {
72+
if (value.scheme !== "sourcekit-lsp") {
73+
// Use the default implementation for all schemes other than sourcekit-lsp, as defined here:
74+
// https://github.com/microsoft/vscode-languageserver-node/blob/14ddabfc22187b698e83ecde072247aa40727308/client/src/common/codeConverter.ts#L155
75+
return value.toString();
76+
}
77+
// Create a dummy URL. We set all the components below.
78+
const url = new URL(value.scheme + "://");
79+
80+
// Uri encodes username and password in `authority`. Url has its custom fields for those.
81+
let host: string;
82+
let username: string;
83+
let password: string;
84+
const atInAuthority = value.authority.indexOf("@");
85+
if (atInAuthority != -1) {
86+
host = value.authority.substring(atInAuthority + 1);
87+
const auth = value.authority.substring(0, atInAuthority);
88+
const colonInAuth = auth.indexOf(":");
89+
if (colonInAuth == -1) {
90+
username = auth;
91+
password = "";
92+
} else {
93+
username = auth.substring(0, colonInAuth);
94+
password = auth.substring(colonInAuth + 1);
95+
}
96+
} else {
97+
host = value.authority;
98+
username = "";
99+
password = "";
100+
}
101+
102+
// Need to set host before username and password because otherwise setting username + password is a no-op (probably
103+
// because a URL can't have a username without a host).
104+
url.host = host;
105+
url.username = username;
106+
url.password = password;
107+
url.pathname = value.path;
108+
109+
let search = value.query;
110+
if (search) {
111+
// URL considers '?' not part of the search, vscode.URI does '?' part of the query.
112+
search = "?" + search;
113+
}
114+
url.search = search;
115+
116+
let hash = value.fragment;
117+
if (hash) {
118+
// URL considers '#' not part of the hash, vscode.URI does '#' part of the fragment.
119+
hash = "#" + hash;
120+
}
121+
url.hash = hash;
122+
123+
return url.toString();
124+
},
125+
};
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2024 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import * as assert from "assert";
16+
import * as vscode from "vscode";
17+
import { uriConverters } from "../../../src/sourcekit-lsp/uriConverters";
18+
19+
/// Check that decoding the given URI string and re-encoding it results in the original string and that the decoded Uri
20+
/// does not cause any assertion failures in `verifyUri`.
21+
function checkUri(input: string, verifyUri: (uri: vscode.Uri) => void) {
22+
const uri = uriConverters.protocol2Code(input);
23+
verifyUri(uri);
24+
assert.equal(uriConverters.code2Protocol(uri), input);
25+
}
26+
27+
suite("uriConverters Suite", () => {
28+
suite("Default Coding", () => {
29+
test("Space in host", () => {
30+
checkUri("file://host%20with%20space/", uri => {
31+
assert.equal(uri.authority, "host with space");
32+
});
33+
});
34+
35+
test("Space in path", () => {
36+
checkUri("file://host/with%20space", uri => {
37+
assert.equal(uri.path, "/with space");
38+
});
39+
});
40+
41+
test("Query does not round-trip", () => {
42+
// If this test starts passing, the underlying VS Code issue that requires us to have custom URI coding
43+
// has been fixed and we should be able to remove our custom uri converter.
44+
const uri = uriConverters.protocol2Code("scheme://host?outer=inner%3Dvalue");
45+
assert.equal(
46+
uri.toString(/*skipEncoding*/ false),
47+
"scheme://host?outer%3Dinner%3Dvalue"
48+
);
49+
assert.equal(uri.toString(/*skipEncoding*/ true), "scheme://host?outer=inner=value");
50+
});
51+
});
52+
53+
suite("Custom Coding", () => {
54+
test("Basic", () => {
55+
checkUri("sourcekit-lsp://host?outer=inner%3Dvalue", uri => {
56+
assert.equal(uri.query, "outer=inner%3Dvalue");
57+
});
58+
});
59+
60+
test("Percent-encoded hash in query", () => {
61+
checkUri("sourcekit-lsp://host?outer=with%23hash", uri => {
62+
assert.equal(uri.query, "outer=with%23hash");
63+
});
64+
});
65+
66+
test("Query and fragment", () => {
67+
checkUri("sourcekit-lsp://host?outer=with%23hash#fragment", uri => {
68+
assert.equal(uri.query, "outer=with%23hash");
69+
assert.equal(uri.fragment, "fragment");
70+
});
71+
});
72+
73+
test("Percent encoding in host", () => {
74+
// Technically, it would be nice to percent-decode the authority and path here but then we get into
75+
// ambiguities around username in the authority (see the `Encoded '@' in host` test).
76+
// For now, rely on SourceKit-LSP not using any characters that need percent-encoding here.
77+
checkUri("sourcekit-lsp://host%20with%20space", uri => {
78+
assert.equal(uri.authority, "host%20with%20space");
79+
});
80+
});
81+
82+
test("Encoded '@' in host", () => {
83+
checkUri("sourcekit-lsp://user%40with-at@host%40with-at", uri => {
84+
assert.equal(uri.authority, "user%40with-at@host%40with-at");
85+
});
86+
});
87+
88+
test("Percent encoding in path", () => {
89+
checkUri("sourcekit-lsp://host/with%20space", uri => {
90+
assert.equal(uri.path, "/with%20space");
91+
});
92+
});
93+
94+
test("No query", () => {
95+
checkUri("sourcekit-lsp://host/with/path", uri => {
96+
assert.equal(uri.query, "");
97+
});
98+
});
99+
100+
test("With username", () => {
101+
checkUri("sourcekit-lsp://user@host", uri => {
102+
assert.equal(uri.authority, "user@host");
103+
});
104+
});
105+
106+
test("With username and password", () => {
107+
checkUri("sourcekit-lsp://user:pass@host", uri => {
108+
assert.equal(uri.authority, "user:pass@host");
109+
});
110+
});
111+
});
112+
});

0 commit comments

Comments
 (0)