diff options
author | bors[bot] <26634292+bors[bot]@users.noreply.github.com> | 2020-02-09 15:21:12 +0000 |
---|---|---|
committer | GitHub <[email protected]> | 2020-02-09 15:21:12 +0000 |
commit | 360890fcec3af854c4848ba7ed3511b4bae2ff5e (patch) | |
tree | 5820313364f04233fe6a36794bc370ff25407cc5 /editors/code/src/installation | |
parent | 0db5525c445fb86a7fb7441267ffab2604d78a41 (diff) | |
parent | dfb81a8cd4b9a2efd8151b4ac36105c51df7d683 (diff) |
Merge #3053
3053: Feature: downloading lsp server from GitHub r=matklad a=Veetaha
This is currently very WIP, I may need to change this and that, add "download language server command", logging stuff (for future bug reports), etc., but it already works.
Also didn't test this on windows yet and mac (don't have the latter)
The quirks:
* Downloaded binary doesn't have executable permissions by default, that's why we ~~`chmod 111`~~ (**[UPD]** `chmod 755` as per @lnicola [suggestion](https://github.com/rust-analyzer/rust-analyzer/pull/3053#discussion_r376694456)) for it.
* To remove installed binary run `rm /${HOME}/.config/Code/User/globalStorage/matklad.rust-analyzer/ra_lsp_server-linux`, ~~note that `-f` flag is necessary, because of `111` permissions (I think this should be changed)~~ (**[UPD]** --force is no longer needed due to 755 permissions).
I also tried to keep things simple and not to use too many dependencies, all the ones added have 0 dependencies, (`ts-not-nil` is my personal npm package, that imitates `unwrap()` in TypeScript)
**[UPD]** I reduced throttle latency of progress indicator to 200ms for smoother UX
// TODO:
- [x] ~~Add `Rust Analyzer: Download latest language server` vscode command.~~ **[UPD]**: having reviewed the code and estimated available options I concluded that this feature requires too many code changes, I'd like to extract this into a separate PR after we merge this one.
- [x] Add some logging for future debugging
- [x] ~~Gracefully handle the case when language server is not available (e.g. no internet connection, user explicitly rejected the download, etc.)~~ **[UPD]** Decided to postpone better implementation of graceful degradation logic as per [conversation](https://rust-lang.zulipchat.com/#narrow/stream/185405-t-compiler.2Fwg-rls-2.2E0/topic/Deployment.20and.20installation/near/187758550).
Demo (**[UPD]** this is a bit outdated, but still mainly reflects the feature):

Related issue: #2988 #3007
Co-authored-by: Veetaha <[email protected]>
Co-authored-by: Veetaha <[email protected]>
Diffstat (limited to 'editors/code/src/installation')
-rw-r--r-- | editors/code/src/installation/download_file.ts | 34 | ||||
-rw-r--r-- | editors/code/src/installation/fetch_latest_artifact_metadata.ts | 46 | ||||
-rw-r--r-- | editors/code/src/installation/interfaces.ts | 55 | ||||
-rw-r--r-- | editors/code/src/installation/language_server.ts | 141 |
4 files changed, 276 insertions, 0 deletions
diff --git a/editors/code/src/installation/download_file.ts b/editors/code/src/installation/download_file.ts new file mode 100644 index 000000000..b51602ef9 --- /dev/null +++ b/editors/code/src/installation/download_file.ts | |||
@@ -0,0 +1,34 @@ | |||
1 | import fetch from "node-fetch"; | ||
2 | import * as fs from "fs"; | ||
3 | import { strict as assert } from "assert"; | ||
4 | |||
5 | /** | ||
6 | * Downloads file from `url` and stores it at `destFilePath`. | ||
7 | * `onProgress` callback is called on recieveing each chunk of bytes | ||
8 | * to track the progress of downloading, it gets the already read and total | ||
9 | * amount of bytes to read as its parameters. | ||
10 | */ | ||
11 | export async function downloadFile( | ||
12 | url: string, | ||
13 | destFilePath: fs.PathLike, | ||
14 | onProgress: (readBytes: number, totalBytes: number) => void | ||
15 | ): Promise<void> { | ||
16 | const response = await fetch(url); | ||
17 | |||
18 | const totalBytes = Number(response.headers.get('content-length')); | ||
19 | assert(!Number.isNaN(totalBytes), "Sanity check of content-length protocol"); | ||
20 | |||
21 | let readBytes = 0; | ||
22 | |||
23 | console.log("Downloading file of", totalBytes, "bytes size from", url, "to", destFilePath); | ||
24 | |||
25 | return new Promise<void>((resolve, reject) => response.body | ||
26 | .on("data", (chunk: Buffer) => { | ||
27 | readBytes += chunk.length; | ||
28 | onProgress(readBytes, totalBytes); | ||
29 | }) | ||
30 | .on("end", resolve) | ||
31 | .on("error", reject) | ||
32 | .pipe(fs.createWriteStream(destFilePath)) | ||
33 | ); | ||
34 | } | ||
diff --git a/editors/code/src/installation/fetch_latest_artifact_metadata.ts b/editors/code/src/installation/fetch_latest_artifact_metadata.ts new file mode 100644 index 000000000..7e3700603 --- /dev/null +++ b/editors/code/src/installation/fetch_latest_artifact_metadata.ts | |||
@@ -0,0 +1,46 @@ | |||
1 | import fetch from "node-fetch"; | ||
2 | import { GithubRepo, ArtifactMetadata } from "./interfaces"; | ||
3 | |||
4 | const GITHUB_API_ENDPOINT_URL = "https://api.github.com"; | ||
5 | |||
6 | /** | ||
7 | * Fetches the latest release from GitHub `repo` and returns metadata about | ||
8 | * `artifactFileName` shipped with this release or `null` if no such artifact was published. | ||
9 | */ | ||
10 | export async function fetchLatestArtifactMetadata( | ||
11 | repo: GithubRepo, artifactFileName: string | ||
12 | ): Promise<null | ArtifactMetadata> { | ||
13 | |||
14 | const repoOwner = encodeURIComponent(repo.owner); | ||
15 | const repoName = encodeURIComponent(repo.name); | ||
16 | |||
17 | const apiEndpointPath = `/repos/${repoOwner}/${repoName}/releases/latest`; | ||
18 | const requestUrl = GITHUB_API_ENDPOINT_URL + apiEndpointPath; | ||
19 | |||
20 | // We skip runtime type checks for simplicity (here we cast from `any` to `GithubRelease`) | ||
21 | |||
22 | console.log("Issuing request for released artifacts metadata to", requestUrl); | ||
23 | |||
24 | const response: GithubRelease = await fetch(requestUrl, { | ||
25 | headers: { Accept: "application/vnd.github.v3+json" } | ||
26 | }) | ||
27 | .then(res => res.json()); | ||
28 | |||
29 | const artifact = response.assets.find(artifact => artifact.name === artifactFileName); | ||
30 | |||
31 | if (!artifact) return null; | ||
32 | |||
33 | return { | ||
34 | releaseName: response.name, | ||
35 | downloadUrl: artifact.browser_download_url | ||
36 | }; | ||
37 | |||
38 | // We omit declaration of tremendous amount of fields that we are not using here | ||
39 | interface GithubRelease { | ||
40 | name: string; | ||
41 | assets: Array<{ | ||
42 | name: string; | ||
43 | browser_download_url: string; | ||
44 | }>; | ||
45 | } | ||
46 | } | ||
diff --git a/editors/code/src/installation/interfaces.ts b/editors/code/src/installation/interfaces.ts new file mode 100644 index 000000000..8039d0b90 --- /dev/null +++ b/editors/code/src/installation/interfaces.ts | |||
@@ -0,0 +1,55 @@ | |||
1 | export interface GithubRepo { | ||
2 | name: string; | ||
3 | owner: string; | ||
4 | } | ||
5 | |||
6 | /** | ||
7 | * Metadata about particular artifact retrieved from GitHub releases. | ||
8 | */ | ||
9 | export interface ArtifactMetadata { | ||
10 | releaseName: string; | ||
11 | downloadUrl: string; | ||
12 | } | ||
13 | |||
14 | /** | ||
15 | * Represents the source of a binary artifact which is either specified by the user | ||
16 | * explicitly, or bundled by this extension from GitHub releases. | ||
17 | */ | ||
18 | export type BinarySource = BinarySource.ExplicitPath | BinarySource.GithubRelease; | ||
19 | |||
20 | export namespace BinarySource { | ||
21 | /** | ||
22 | * Type tag for `BinarySource` discriminated union. | ||
23 | */ | ||
24 | export const enum Type { ExplicitPath, GithubRelease } | ||
25 | |||
26 | export interface ExplicitPath { | ||
27 | type: Type.ExplicitPath; | ||
28 | |||
29 | /** | ||
30 | * Filesystem path to the binary specified by the user explicitly. | ||
31 | */ | ||
32 | path: string; | ||
33 | } | ||
34 | |||
35 | export interface GithubRelease { | ||
36 | type: Type.GithubRelease; | ||
37 | |||
38 | /** | ||
39 | * Repository where the binary is stored. | ||
40 | */ | ||
41 | repo: GithubRepo; | ||
42 | |||
43 | /** | ||
44 | * Directory on the filesystem where the bundled binary is stored. | ||
45 | */ | ||
46 | dir: string; | ||
47 | |||
48 | /** | ||
49 | * Name of the binary file. It is stored under the same name on GitHub releases | ||
50 | * and in local `.dir`. | ||
51 | */ | ||
52 | file: string; | ||
53 | } | ||
54 | |||
55 | } | ||
diff --git a/editors/code/src/installation/language_server.ts b/editors/code/src/installation/language_server.ts new file mode 100644 index 000000000..1ce67b8b2 --- /dev/null +++ b/editors/code/src/installation/language_server.ts | |||
@@ -0,0 +1,141 @@ | |||
1 | import * as vscode from "vscode"; | ||
2 | import * as path from "path"; | ||
3 | import { strict as assert } from "assert"; | ||
4 | import { promises as fs } from "fs"; | ||
5 | import { promises as dns } from "dns"; | ||
6 | import { spawnSync } from "child_process"; | ||
7 | import { throttle } from "throttle-debounce"; | ||
8 | |||
9 | import { BinarySource } from "./interfaces"; | ||
10 | import { fetchLatestArtifactMetadata } from "./fetch_latest_artifact_metadata"; | ||
11 | import { downloadFile } from "./download_file"; | ||
12 | |||
13 | export async function downloadLatestLanguageServer( | ||
14 | {file: artifactFileName, dir: installationDir, repo}: BinarySource.GithubRelease | ||
15 | ) { | ||
16 | const { releaseName, downloadUrl } = (await fetchLatestArtifactMetadata( | ||
17 | repo, artifactFileName | ||
18 | ))!; | ||
19 | |||
20 | await fs.mkdir(installationDir).catch(err => assert.strictEqual( | ||
21 | err?.code, | ||
22 | "EEXIST", | ||
23 | `Couldn't create directory "${installationDir}" to download `+ | ||
24 | `language server binary: ${err.message}` | ||
25 | )); | ||
26 | |||
27 | const installationPath = path.join(installationDir, artifactFileName); | ||
28 | |||
29 | console.time("Downloading ra_lsp_server"); | ||
30 | await vscode.window.withProgress( | ||
31 | { | ||
32 | location: vscode.ProgressLocation.Notification, | ||
33 | cancellable: false, // FIXME: add support for canceling download? | ||
34 | title: `Downloading language server (${releaseName})` | ||
35 | }, | ||
36 | async (progress, _cancellationToken) => { | ||
37 | let lastPrecentage = 0; | ||
38 | await downloadFile(downloadUrl, installationPath, throttle( | ||
39 | 200, | ||
40 | /* noTrailing: */ true, | ||
41 | (readBytes, totalBytes) => { | ||
42 | const newPercentage = (readBytes / totalBytes) * 100; | ||
43 | progress.report({ | ||
44 | message: newPercentage.toFixed(0) + "%", | ||
45 | increment: newPercentage - lastPrecentage | ||
46 | }); | ||
47 | |||
48 | lastPrecentage = newPercentage; | ||
49 | }) | ||
50 | ); | ||
51 | } | ||
52 | ); | ||
53 | console.timeEnd("Downloading ra_lsp_server"); | ||
54 | |||
55 | await fs.chmod(installationPath, 0o755); // Set (rwx, r_x, r_x) permissions | ||
56 | } | ||
57 | export async function ensureLanguageServerBinary( | ||
58 | langServerSource: null | BinarySource | ||
59 | ): Promise<null | string> { | ||
60 | |||
61 | if (!langServerSource) { | ||
62 | vscode.window.showErrorMessage( | ||
63 | "Unfortunately we don't ship binaries for your platform yet. " + | ||
64 | "You need to manually clone rust-analyzer repository and " + | ||
65 | "run `cargo xtask install --server` to build the language server from sources. " + | ||
66 | "If you feel that your platform should be supported, please create an issue " + | ||
67 | "about that [here](https://github.com/rust-analyzer/rust-analyzer/issues) and we " + | ||
68 | "will consider it." | ||
69 | ); | ||
70 | return null; | ||
71 | } | ||
72 | |||
73 | switch (langServerSource.type) { | ||
74 | case BinarySource.Type.ExplicitPath: { | ||
75 | if (isBinaryAvailable(langServerSource.path)) { | ||
76 | return langServerSource.path; | ||
77 | } | ||
78 | |||
79 | vscode.window.showErrorMessage( | ||
80 | `Unable to run ${langServerSource.path} binary. ` + | ||
81 | `To use the pre-built language server, set "rust-analyzer.raLspServerPath" ` + | ||
82 | "value to `null` or remove it from the settings to use it by default." | ||
83 | ); | ||
84 | return null; | ||
85 | } | ||
86 | case BinarySource.Type.GithubRelease: { | ||
87 | const prebuiltBinaryPath = path.join(langServerSource.dir, langServerSource.file); | ||
88 | |||
89 | if (isBinaryAvailable(prebuiltBinaryPath)) { | ||
90 | return prebuiltBinaryPath; | ||
91 | } | ||
92 | |||
93 | const userResponse = await vscode.window.showInformationMessage( | ||
94 | "Language server binary for rust-analyzer was not found. " + | ||
95 | "Do you want to download it now?", | ||
96 | "Download now", "Cancel" | ||
97 | ); | ||
98 | if (userResponse !== "Download now") return null; | ||
99 | |||
100 | try { | ||
101 | await downloadLatestLanguageServer(langServerSource); | ||
102 | } catch (err) { | ||
103 | await vscode.window.showErrorMessage( | ||
104 | `Failed to download language server from ${langServerSource.repo.name} ` + | ||
105 | `GitHub repository: ${err.message}` | ||
106 | ); | ||
107 | |||
108 | await dns.resolve('www.google.com').catch(err => { | ||
109 | console.error("DNS resolution failed, there might be an issue with Internet availability"); | ||
110 | console.error(err); | ||
111 | }); | ||
112 | |||
113 | return null; | ||
114 | } | ||
115 | |||
116 | if (!isBinaryAvailable(prebuiltBinaryPath)) assert(false, | ||
117 | `Downloaded language server binary is not functional.` + | ||
118 | `Downloaded from: ${JSON.stringify(langServerSource)}` | ||
119 | ); | ||
120 | |||
121 | |||
122 | vscode.window.showInformationMessage( | ||
123 | "Rust analyzer language server was successfully installed 🦀" | ||
124 | ); | ||
125 | |||
126 | return prebuiltBinaryPath; | ||
127 | } | ||
128 | } | ||
129 | |||
130 | function isBinaryAvailable(binaryPath: string) { | ||
131 | const res = spawnSync(binaryPath, ["--version"]); | ||
132 | |||
133 | // ACHTUNG! `res` type declaration is inherently wrong, see | ||
134 | // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/42221 | ||
135 | |||
136 | console.log("Checked binary availablity via --version", res); | ||
137 | console.log(binaryPath, "--version output:", res.output?.map(String)); | ||
138 | |||
139 | return res.status === 0; | ||
140 | } | ||
141 | } | ||