aboutsummaryrefslogtreecommitdiff
path: root/editors/code/src
diff options
context:
space:
mode:
authorbors[bot] <26634292+bors[bot]@users.noreply.github.com>2020-02-09 15:21:12 +0000
committerGitHub <[email protected]>2020-02-09 15:21:12 +0000
commit360890fcec3af854c4848ba7ed3511b4bae2ff5e (patch)
tree5820313364f04233fe6a36794bc370ff25407cc5 /editors/code/src
parent0db5525c445fb86a7fb7441267ffab2604d78a41 (diff)
parentdfb81a8cd4b9a2efd8151b4ac36105c51df7d683 (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): ![ra-github-release-download-mvp](https://user-images.githubusercontent.com/36276403/74077961-4f248a80-4a2d-11ea-962f-27c650fd6c4c.gif) Related issue: #2988 #3007 Co-authored-by: Veetaha <[email protected]> Co-authored-by: Veetaha <[email protected]>
Diffstat (limited to 'editors/code/src')
-rw-r--r--editors/code/src/client.ts22
-rw-r--r--editors/code/src/config.ts77
-rw-r--r--editors/code/src/ctx.ts12
-rw-r--r--editors/code/src/installation/download_file.ts34
-rw-r--r--editors/code/src/installation/fetch_latest_artifact_metadata.ts46
-rw-r--r--editors/code/src/installation/interfaces.ts55
-rw-r--r--editors/code/src/installation/language_server.ts141
7 files changed, 361 insertions, 26 deletions
diff --git a/editors/code/src/client.ts b/editors/code/src/client.ts
index 7e7e909dd..2e3d4aba2 100644
--- a/editors/code/src/client.ts
+++ b/editors/code/src/client.ts
@@ -1,24 +1,18 @@
1import { homedir } from 'os';
2import * as lc from 'vscode-languageclient'; 1import * as lc from 'vscode-languageclient';
3import { spawnSync } from 'child_process';
4 2
5import { window, workspace } from 'vscode'; 3import { window, workspace } from 'vscode';
6import { Config } from './config'; 4import { Config } from './config';
5import { ensureLanguageServerBinary } from './installation/language_server';
7 6
8export function createClient(config: Config): lc.LanguageClient { 7export async function createClient(config: Config): Promise<null | lc.LanguageClient> {
9 // '.' Is the fallback if no folder is open 8 // '.' Is the fallback if no folder is open
10 // TODO?: Workspace folders support Uri's (eg: file://test.txt). 9 // TODO?: Workspace folders support Uri's (eg: file://test.txt).
11 // It might be a good idea to test if the uri points to a file. 10 // It might be a good idea to test if the uri points to a file.
12 const workspaceFolderPath = workspace.workspaceFolders?.[0]?.uri.fsPath ?? '.'; 11 const workspaceFolderPath = workspace.workspaceFolders?.[0]?.uri.fsPath ?? '.';
13 12
14 const raLspServerPath = expandPathResolving(config.raLspServerPath); 13 const raLspServerPath = await ensureLanguageServerBinary(config.langServerSource);
15 if (spawnSync(raLspServerPath, ["--version"]).status !== 0) { 14 if (!raLspServerPath) return null;
16 window.showErrorMessage( 15
17 `Unable to execute '${raLspServerPath} --version'\n\n` +
18 `Perhaps it is not in $PATH?\n\n` +
19 `PATH=${process.env.PATH}\n`
20 );
21 }
22 const run: lc.Executable = { 16 const run: lc.Executable = {
23 command: raLspServerPath, 17 command: raLspServerPath,
24 options: { cwd: workspaceFolderPath }, 18 options: { cwd: workspaceFolderPath },
@@ -87,9 +81,3 @@ export function createClient(config: Config): lc.LanguageClient {
87 res.registerProposedFeatures(); 81 res.registerProposedFeatures();
88 return res; 82 return res;
89} 83}
90function expandPathResolving(path: string) {
91 if (path.startsWith('~/')) {
92 return path.replace('~', homedir());
93 }
94 return path;
95}
diff --git a/editors/code/src/config.ts b/editors/code/src/config.ts
index 524620433..d5f3da2ed 100644
--- a/editors/code/src/config.ts
+++ b/editors/code/src/config.ts
@@ -1,4 +1,6 @@
1import * as os from "os";
1import * as vscode from 'vscode'; 2import * as vscode from 'vscode';
3import { BinarySource } from "./installation/interfaces";
2 4
3const RA_LSP_DEBUG = process.env.__RA_LSP_SERVER_DEBUG; 5const RA_LSP_DEBUG = process.env.__RA_LSP_SERVER_DEBUG;
4 6
@@ -16,10 +18,11 @@ export interface CargoFeatures {
16} 18}
17 19
18export class Config { 20export class Config {
21 langServerSource!: null | BinarySource;
22
19 highlightingOn = true; 23 highlightingOn = true;
20 rainbowHighlightingOn = false; 24 rainbowHighlightingOn = false;
21 enableEnhancedTyping = true; 25 enableEnhancedTyping = true;
22 raLspServerPath = RA_LSP_DEBUG || 'ra_lsp_server';
23 lruCapacity: null | number = null; 26 lruCapacity: null | number = null;
24 displayInlayHints = true; 27 displayInlayHints = true;
25 maxInlayHintLength: null | number = null; 28 maxInlayHintLength: null | number = null;
@@ -45,11 +48,72 @@ export class Config {
45 private prevCargoWatchOptions: null | CargoWatchOptions = null; 48 private prevCargoWatchOptions: null | CargoWatchOptions = null;
46 49
47 constructor(ctx: vscode.ExtensionContext) { 50 constructor(ctx: vscode.ExtensionContext) {
48 vscode.workspace.onDidChangeConfiguration(_ => this.refresh(), null, ctx.subscriptions); 51 vscode.workspace.onDidChangeConfiguration(_ => this.refresh(ctx), null, ctx.subscriptions);
49 this.refresh(); 52 this.refresh(ctx);
53 }
54
55 private static expandPathResolving(path: string) {
56 if (path.startsWith('~/')) {
57 return path.replace('~', os.homedir());
58 }
59 return path;
60 }
61
62 /**
63 * Name of the binary artifact for `ra_lsp_server` that is published for
64 * `platform` on GitHub releases. (It is also stored under the same name when
65 * downloaded by the extension).
66 */
67 private static prebuiltLangServerFileName(platform: NodeJS.Platform): null | string {
68 switch (platform) {
69 case "linux": return "ra_lsp_server-linux";
70 case "darwin": return "ra_lsp_server-mac";
71 case "win32": return "ra_lsp_server-windows.exe";
72
73 // Users on these platforms yet need to manually build from sources
74 case "aix":
75 case "android":
76 case "freebsd":
77 case "openbsd":
78 case "sunos":
79 case "cygwin":
80 case "netbsd": return null;
81 // The list of platforms is exhaustive (see `NodeJS.Platform` type definition)
82 }
83 }
84
85 private static langServerBinarySource(
86 ctx: vscode.ExtensionContext,
87 config: vscode.WorkspaceConfiguration
88 ): null | BinarySource {
89 const langServerPath = RA_LSP_DEBUG ?? config.get<null | string>("raLspServerPath");
90
91 if (langServerPath) {
92 return {
93 type: BinarySource.Type.ExplicitPath,
94 path: Config.expandPathResolving(langServerPath)
95 };
96 }
97
98 const prebuiltBinaryName = Config.prebuiltLangServerFileName(process.platform);
99
100 if (!prebuiltBinaryName) return null;
101
102 return {
103 type: BinarySource.Type.GithubRelease,
104 dir: ctx.globalStoragePath,
105 file: prebuiltBinaryName,
106 repo: {
107 name: "rust-analyzer",
108 owner: "rust-analyzer",
109 }
110 };
50 } 111 }
51 112
52 private refresh() { 113
114 // FIXME: revisit the logic for `if (.has(...)) config.get(...)` set default
115 // values only in one place (i.e. remove default values from non-readonly members declarations)
116 private refresh(ctx: vscode.ExtensionContext) {
53 const config = vscode.workspace.getConfiguration('rust-analyzer'); 117 const config = vscode.workspace.getConfiguration('rust-analyzer');
54 118
55 let requireReloadMessage = null; 119 let requireReloadMessage = null;
@@ -82,10 +146,7 @@ export class Config {
82 this.prevEnhancedTyping = this.enableEnhancedTyping; 146 this.prevEnhancedTyping = this.enableEnhancedTyping;
83 } 147 }
84 148
85 if (config.has('raLspServerPath')) { 149 this.langServerSource = Config.langServerBinarySource(ctx, config);
86 this.raLspServerPath =
87 RA_LSP_DEBUG || (config.get('raLspServerPath') as string);
88 }
89 150
90 if (config.has('cargo-watch.enable')) { 151 if (config.has('cargo-watch.enable')) {
91 this.cargoWatchOptions.enable = config.get<boolean>( 152 this.cargoWatchOptions.enable = config.get<boolean>(
diff --git a/editors/code/src/ctx.ts b/editors/code/src/ctx.ts
index aa75943bf..70042a479 100644
--- a/editors/code/src/ctx.ts
+++ b/editors/code/src/ctx.ts
@@ -11,6 +11,9 @@ export class Ctx {
11 // deal with it. 11 // deal with it.
12 // 12 //
13 // Ideally, this should be replaced with async getter though. 13 // Ideally, this should be replaced with async getter though.
14 // FIXME: this actually needs syncronization of some kind (check how
15 // vscode deals with `deactivate()` call when extension has some work scheduled
16 // on the event loop to get a better picture of what we can do here)
14 client: lc.LanguageClient | null = null; 17 client: lc.LanguageClient | null = null;
15 private extCtx: vscode.ExtensionContext; 18 private extCtx: vscode.ExtensionContext;
16 private onDidRestartHooks: Array<(client: lc.LanguageClient) => void> = []; 19 private onDidRestartHooks: Array<(client: lc.LanguageClient) => void> = [];
@@ -26,7 +29,14 @@ export class Ctx {
26 await old.stop(); 29 await old.stop();
27 } 30 }
28 this.client = null; 31 this.client = null;
29 const client = createClient(this.config); 32 const client = await createClient(this.config);
33 if (!client) {
34 throw new Error(
35 "Rust Analyzer Language Server is not available. " +
36 "Please, ensure its [proper installation](https://github.com/rust-analyzer/rust-analyzer/tree/master/docs/user#vs-code)."
37 );
38 }
39
30 this.pushCleanup(client.start()); 40 this.pushCleanup(client.start());
31 await client.onReady(); 41 await client.onReady();
32 42
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 @@
1import fetch from "node-fetch";
2import * as fs from "fs";
3import { 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 */
11export 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 @@
1import fetch from "node-fetch";
2import { GithubRepo, ArtifactMetadata } from "./interfaces";
3
4const 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 */
10export 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 @@
1export interface GithubRepo {
2 name: string;
3 owner: string;
4}
5
6/**
7 * Metadata about particular artifact retrieved from GitHub releases.
8 */
9export 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 */
18export type BinarySource = BinarySource.ExplicitPath | BinarySource.GithubRelease;
19
20export 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 @@
1import * as vscode from "vscode";
2import * as path from "path";
3import { strict as assert } from "assert";
4import { promises as fs } from "fs";
5import { promises as dns } from "dns";
6import { spawnSync } from "child_process";
7import { throttle } from "throttle-debounce";
8
9import { BinarySource } from "./interfaces";
10import { fetchLatestArtifactMetadata } from "./fetch_latest_artifact_metadata";
11import { downloadFile } from "./download_file";
12
13export 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}
57export 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}