Skip to content

Commit c01b9a9

Browse files
authored
Work around Uri round-tripping issue in VS Code for sourcekit-lsp scheme (#1026)
1 parent 8d031b0 commit c01b9a9

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,
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)