aboutsummaryrefslogtreecommitdiff
path: root/editors/code
diff options
context:
space:
mode:
authorbors[bot] <26634292+bors[bot]@users.noreply.github.com>2020-03-16 10:26:31 +0000
committerGitHub <[email protected]>2020-03-16 10:26:31 +0000
commit200c275c2e9955371e61f6ad7684084655df46fc (patch)
treec4b61de644cec37cffca9010d56afc4136d23ca8 /editors/code
parenta99cac671c3e6105a0192acbb1a91cb83e453018 (diff)
parent5a0041c5aaeee49be84ce771fb0360ae55cbd8b2 (diff)
Merge #3534
3534: Feature: vscode impl nightlies download and installation r=Veetaha a=Veetaha I need to test things more, but the core shape of the code is quite well-formed. The main problem is that we save the release date only for nightlies and there are no means to get the release date of the stable extension (i.e. for this we would need to consult the github releases via a network request, or we would need to somehow save this info into package.json or any other file accessible from the extension code during the deployment step, but this will be very hard I guess). So there is an invariant that the users can install nightly only from our extension and they can't do it manually, because when installing the nightly `.vsix` we actually save its release date to `globalState` Closes: #3402 TODO: - [x] More manual tests and documentation cc @matklad @lnicola Co-authored-by: Veetaha <[email protected]> Co-authored-by: Veetaha <[email protected]>
Diffstat (limited to 'editors/code')
-rw-r--r--editors/code/package-lock.json2
-rw-r--r--editors/code/package.json17
-rw-r--r--editors/code/src/commands/server_version.ts3
-rw-r--r--editors/code/src/config.ts117
-rw-r--r--editors/code/src/installation/download_artifact.ts50
-rw-r--r--editors/code/src/installation/downloads.ts (renamed from editors/code/src/installation/download_file.ts)46
-rw-r--r--editors/code/src/installation/extension.ts144
-rw-r--r--editors/code/src/installation/fetch_artifact_release_info.ts3
-rw-r--r--editors/code/src/installation/interfaces.ts18
-rw-r--r--editors/code/src/installation/server.ts104
-rw-r--r--editors/code/src/main.ts9
-rw-r--r--editors/code/src/util.ts69
12 files changed, 435 insertions, 147 deletions
diff --git a/editors/code/package-lock.json b/editors/code/package-lock.json
index b07964546..575dc7c4a 100644
--- a/editors/code/package-lock.json
+++ b/editors/code/package-lock.json
@@ -1,6 +1,6 @@
1{ 1{
2 "name": "rust-analyzer", 2 "name": "rust-analyzer",
3 "version": "0.2.20200211-dev", 3 "version": "0.2.20200309-nightly",
4 "lockfileVersion": 1, 4 "lockfileVersion": 1,
5 "requires": true, 5 "requires": true,
6 "dependencies": { 6 "dependencies": {
diff --git a/editors/code/package.json b/editors/code/package.json
index 3aaae357a..faf10528d 100644
--- a/editors/code/package.json
+++ b/editors/code/package.json
@@ -6,7 +6,7 @@
6 "private": true, 6 "private": true,
7 "icon": "icon.png", 7 "icon": "icon.png",
8 "//": "The real version is in release.yaml, this one just needs to be bigger", 8 "//": "The real version is in release.yaml, this one just needs to be bigger",
9 "version": "0.2.20200211-dev", 9 "version": "0.2.20200309-nightly",
10 "publisher": "matklad", 10 "publisher": "matklad",
11 "repository": { 11 "repository": {
12 "url": "https://github.com/rust-analyzer/rust-analyzer.git", 12 "url": "https://github.com/rust-analyzer/rust-analyzer.git",
@@ -219,6 +219,19 @@
219 } 219 }
220 } 220 }
221 }, 221 },
222 "rust-analyzer.updates.channel": {
223 "type": "string",
224 "enum": [
225 "stable",
226 "nightly"
227 ],
228 "default": "stable",
229 "markdownEnumDescriptions": [
230 "`\"stable\"` updates are shipped weekly, they don't contain cutting-edge features from VSCode proposed APIs but have less bugs in general",
231 "`\"nightly\"` updates are shipped daily, they contain cutting-edge features and latest bug fixes. These releases help us get your feedback very quickly and speed up rust-analyzer development **drastically**"
232 ],
233 "markdownDescription": "Choose `\"nightly\"` updates to get the latest features and bug fixes every day. While `\"stable\"` releases occur weekly and don't contain cutting-edge features from VSCode proposed APIs"
234 },
222 "rust-analyzer.updates.askBeforeDownload": { 235 "rust-analyzer.updates.askBeforeDownload": {
223 "type": "boolean", 236 "type": "boolean",
224 "default": true, 237 "default": true,
@@ -235,7 +248,7 @@
235 "string" 248 "string"
236 ], 249 ],
237 "default": null, 250 "default": null,
238 "description": "Path to rust-analyzer executable (points to bundled binary by default)" 251 "description": "Path to rust-analyzer executable (points to bundled binary by default). If this is set, then \"rust-analyzer.updates.channel\" setting is not used"
239 }, 252 },
240 "rust-analyzer.excludeGlobs": { 253 "rust-analyzer.excludeGlobs": {
241 "type": "array", 254 "type": "array",
diff --git a/editors/code/src/commands/server_version.ts b/editors/code/src/commands/server_version.ts
index 421301b42..c4d84b443 100644
--- a/editors/code/src/commands/server_version.ts
+++ b/editors/code/src/commands/server_version.ts
@@ -5,7 +5,7 @@ import { spawnSync } from 'child_process';
5 5
6export function serverVersion(ctx: Ctx): Cmd { 6export function serverVersion(ctx: Ctx): Cmd {
7 return async () => { 7 return async () => {
8 const binaryPath = await ensureServerBinary(ctx.config.serverSource); 8 const binaryPath = await ensureServerBinary(ctx.config);
9 9
10 if (binaryPath == null) { 10 if (binaryPath == null) {
11 throw new Error( 11 throw new Error(
@@ -18,4 +18,3 @@ export function serverVersion(ctx: Ctx): Cmd {
18 vscode.window.showInformationMessage('rust-analyzer version : ' + version); 18 vscode.window.showInformationMessage('rust-analyzer version : ' + version);
19 }; 19 };
20} 20}
21
diff --git a/editors/code/src/config.ts b/editors/code/src/config.ts
index 6db073bec..f63e1d20e 100644
--- a/editors/code/src/config.ts
+++ b/editors/code/src/config.ts
@@ -1,7 +1,7 @@
1import * as os from "os"; 1import * as os from "os";
2import * as vscode from 'vscode'; 2import * as vscode from 'vscode';
3import { ArtifactSource } from "./installation/interfaces"; 3import { ArtifactSource } from "./installation/interfaces";
4import { log } from "./util"; 4import { log, vscodeReloadWindow } from "./util";
5 5
6const RA_LSP_DEBUG = process.env.__RA_LSP_SERVER_DEBUG; 6const RA_LSP_DEBUG = process.env.__RA_LSP_SERVER_DEBUG;
7 7
@@ -23,25 +23,40 @@ export interface CargoFeatures {
23 allFeatures: boolean; 23 allFeatures: boolean;
24 features: string[]; 24 features: string[];
25} 25}
26
27export const enum UpdatesChannel {
28 Stable = "stable",
29 Nightly = "nightly"
30}
31
32export const NIGHTLY_TAG = "nightly";
26export class Config { 33export class Config {
27 private static readonly rootSection = "rust-analyzer"; 34 readonly extensionId = "matklad.rust-analyzer";
28 private static readonly requiresReloadOpts = [ 35
36 private readonly rootSection = "rust-analyzer";
37 private readonly requiresReloadOpts = [
38 "serverPath",
29 "cargoFeatures", 39 "cargoFeatures",
30 "cargo-watch", 40 "cargo-watch",
31 "highlighting.semanticTokens", 41 "highlighting.semanticTokens",
32 "inlayHints", 42 "inlayHints",
33 ] 43 ]
34 .map(opt => `${Config.rootSection}.${opt}`); 44 .map(opt => `${this.rootSection}.${opt}`);
35 45
36 private static readonly extensionVersion: string = (() => { 46 readonly packageJsonVersion = vscode
37 const packageJsonVersion = vscode 47 .extensions
38 .extensions 48 .getExtension(this.extensionId)!
39 .getExtension("matklad.rust-analyzer")! 49 .packageJSON
40 .packageJSON 50 .version as string; // n.n.YYYYMMDD[-nightly]
41 .version as string; // n.n.YYYYMMDD 51
52 /**
53 * Either `nightly` or `YYYY-MM-DD` (i.e. `stable` release)
54 */
55 readonly extensionReleaseTag: string = (() => {
56 if (this.packageJsonVersion.endsWith(NIGHTLY_TAG)) return NIGHTLY_TAG;
42 57
43 const realVersionRegexp = /^\d+\.\d+\.(\d{4})(\d{2})(\d{2})/; 58 const realVersionRegexp = /^\d+\.\d+\.(\d{4})(\d{2})(\d{2})/;
44 const [, yyyy, mm, dd] = packageJsonVersion.match(realVersionRegexp)!; 59 const [, yyyy, mm, dd] = this.packageJsonVersion.match(realVersionRegexp)!;
45 60
46 return `${yyyy}-${mm}-${dd}`; 61 return `${yyyy}-${mm}-${dd}`;
47 })(); 62 })();
@@ -54,16 +69,19 @@ export class Config {
54 } 69 }
55 70
56 private refreshConfig() { 71 private refreshConfig() {
57 this.cfg = vscode.workspace.getConfiguration(Config.rootSection); 72 this.cfg = vscode.workspace.getConfiguration(this.rootSection);
58 const enableLogging = this.cfg.get("trace.extension") as boolean; 73 const enableLogging = this.cfg.get("trace.extension") as boolean;
59 log.setEnabled(enableLogging); 74 log.setEnabled(enableLogging);
60 log.debug("Using configuration:", this.cfg); 75 log.debug(
76 "Extension version:", this.packageJsonVersion,
77 "using configuration:", this.cfg
78 );
61 } 79 }
62 80
63 private async onConfigChange(event: vscode.ConfigurationChangeEvent) { 81 private async onConfigChange(event: vscode.ConfigurationChangeEvent) {
64 this.refreshConfig(); 82 this.refreshConfig();
65 83
66 const requiresReloadOpt = Config.requiresReloadOpts.find( 84 const requiresReloadOpt = this.requiresReloadOpts.find(
67 opt => event.affectsConfiguration(opt) 85 opt => event.affectsConfiguration(opt)
68 ); 86 );
69 87
@@ -75,7 +93,7 @@ export class Config {
75 ); 93 );
76 94
77 if (userResponse === "Reload now") { 95 if (userResponse === "Reload now") {
78 vscode.commands.executeCommand("workbench.action.reloadWindow"); 96 await vscodeReloadWindow();
79 } 97 }
80 } 98 }
81 99
@@ -121,8 +139,14 @@ export class Config {
121 } 139 }
122 } 140 }
123 141
142 get installedExtensionUpdateChannel(): UpdatesChannel {
143 return this.extensionReleaseTag === NIGHTLY_TAG
144 ? UpdatesChannel.Nightly
145 : UpdatesChannel.Stable;
146 }
147
124 get serverSource(): null | ArtifactSource { 148 get serverSource(): null | ArtifactSource {
125 const serverPath = RA_LSP_DEBUG ?? this.cfg.get<null | string>("serverPath"); 149 const serverPath = RA_LSP_DEBUG ?? this.serverPath;
126 150
127 if (serverPath) { 151 if (serverPath) {
128 return { 152 return {
@@ -135,13 +159,18 @@ export class Config {
135 159
136 if (!prebuiltBinaryName) return null; 160 if (!prebuiltBinaryName) return null;
137 161
162 return this.createGithubReleaseSource(
163 prebuiltBinaryName,
164 this.extensionReleaseTag
165 );
166 }
167
168 private createGithubReleaseSource(file: string, tag: string): ArtifactSource.GithubRelease {
138 return { 169 return {
139 type: ArtifactSource.Type.GithubRelease, 170 type: ArtifactSource.Type.GithubRelease,
171 file,
172 tag,
140 dir: this.ctx.globalStoragePath, 173 dir: this.ctx.globalStoragePath,
141 file: prebuiltBinaryName,
142 storage: this.ctx.globalState,
143 tag: Config.extensionVersion,
144 askBeforeDownload: this.cfg.get("updates.askBeforeDownload") as boolean,
145 repo: { 174 repo: {
146 name: "rust-analyzer", 175 name: "rust-analyzer",
147 owner: "rust-analyzer", 176 owner: "rust-analyzer",
@@ -149,9 +178,23 @@ export class Config {
149 }; 178 };
150 } 179 }
151 180
181 get nightlyVsixSource(): ArtifactSource.GithubRelease {
182 return this.createGithubReleaseSource("rust-analyzer.vsix", NIGHTLY_TAG);
183 }
184
185 readonly installedNightlyExtensionReleaseDate = new DateStorage(
186 "installed-nightly-extension-release-date",
187 this.ctx.globalState
188 );
189 readonly serverReleaseDate = new DateStorage("server-release-date", this.ctx.globalState);
190 readonly serverReleaseTag = new Storage<null | string>("server-release-tag", this.ctx.globalState, null);
191
152 // We don't do runtime config validation here for simplicity. More on stackoverflow: 192 // We don't do runtime config validation here for simplicity. More on stackoverflow:
153 // https://stackoverflow.com/questions/60135780/what-is-the-best-way-to-type-check-the-configuration-for-vscode-extension 193 // https://stackoverflow.com/questions/60135780/what-is-the-best-way-to-type-check-the-configuration-for-vscode-extension
154 194
195 private get serverPath() { return this.cfg.get("serverPath") as null | string; }
196 get updatesChannel() { return this.cfg.get("updates.channel") as UpdatesChannel; }
197 get askBeforeDownload() { return this.cfg.get("updates.askBeforeDownload") as boolean; }
155 get highlightingSemanticTokens() { return this.cfg.get("highlighting.semanticTokens") as boolean; } 198 get highlightingSemanticTokens() { return this.cfg.get("highlighting.semanticTokens") as boolean; }
156 get highlightingOn() { return this.cfg.get("highlightingOn") as boolean; } 199 get highlightingOn() { return this.cfg.get("highlightingOn") as boolean; }
157 get rainbowHighlightingOn() { return this.cfg.get("rainbowHighlightingOn") as boolean; } 200 get rainbowHighlightingOn() { return this.cfg.get("rainbowHighlightingOn") as boolean; }
@@ -189,3 +232,37 @@ export class Config {
189 // for internal use 232 // for internal use
190 get withSysroot() { return this.cfg.get("withSysroot", true) as boolean; } 233 get withSysroot() { return this.cfg.get("withSysroot", true) as boolean; }
191} 234}
235
236export class Storage<T> {
237 constructor(
238 private readonly key: string,
239 private readonly storage: vscode.Memento,
240 private readonly defaultVal: T
241 ) { }
242
243 get(): T {
244 const val = this.storage.get(this.key, this.defaultVal);
245 log.debug(this.key, "==", val);
246 return val;
247 }
248 async set(val: T) {
249 log.debug(this.key, "=", val);
250 await this.storage.update(this.key, val);
251 }
252}
253export class DateStorage {
254 inner: Storage<null | string>;
255
256 constructor(key: string, storage: vscode.Memento) {
257 this.inner = new Storage(key, storage, null);
258 }
259
260 get(): null | Date {
261 const dateStr = this.inner.get();
262 return dateStr ? new Date(dateStr) : null;
263 }
264
265 async set(date: null | Date) {
266 await this.inner.set(date ? date.toString() : null);
267 }
268}
diff --git a/editors/code/src/installation/download_artifact.ts b/editors/code/src/installation/download_artifact.ts
deleted file mode 100644
index 97e4d67c2..000000000
--- a/editors/code/src/installation/download_artifact.ts
+++ /dev/null
@@ -1,50 +0,0 @@
1import * as vscode from "vscode";
2import * as path from "path";
3import { promises as fs } from "fs";
4
5import { ArtifactReleaseInfo } from "./interfaces";
6import { downloadFile } from "./download_file";
7import { assert } from "../util";
8
9/**
10 * Downloads artifact from given `downloadUrl`.
11 * Creates `installationDir` if it is not yet created and put the artifact under
12 * `artifactFileName`.
13 * Displays info about the download progress in an info message printing the name
14 * of the artifact as `displayName`.
15 */
16export async function downloadArtifact(
17 { downloadUrl, releaseName }: ArtifactReleaseInfo,
18 artifactFileName: string,
19 installationDir: string,
20 displayName: string,
21) {
22 await fs.mkdir(installationDir).catch(err => assert(
23 err?.code === "EEXIST",
24 `Couldn't create directory "${installationDir}" to download ` +
25 `${artifactFileName} artifact: ${err?.message}`
26 ));
27
28 const installationPath = path.join(installationDir, artifactFileName);
29
30 await vscode.window.withProgress(
31 {
32 location: vscode.ProgressLocation.Notification,
33 cancellable: false, // FIXME: add support for canceling download?
34 title: `Downloading ${displayName} (${releaseName})`
35 },
36 async (progress, _cancellationToken) => {
37 let lastPrecentage = 0;
38 const filePermissions = 0o755; // (rwx, r_x, r_x)
39 await downloadFile(downloadUrl, installationPath, filePermissions, (readBytes, totalBytes) => {
40 const newPercentage = (readBytes / totalBytes) * 100;
41 progress.report({
42 message: newPercentage.toFixed(0) + "%",
43 increment: newPercentage - lastPrecentage
44 });
45
46 lastPrecentage = newPercentage;
47 });
48 }
49 );
50}
diff --git a/editors/code/src/installation/download_file.ts b/editors/code/src/installation/downloads.ts
index ee8949d61..7ce2e2960 100644
--- a/editors/code/src/installation/download_file.ts
+++ b/editors/code/src/installation/downloads.ts
@@ -1,8 +1,11 @@
1import fetch from "node-fetch"; 1import fetch from "node-fetch";
2import * as vscode from "vscode";
3import * as path from "path";
2import * as fs from "fs"; 4import * as fs from "fs";
3import * as stream from "stream"; 5import * as stream from "stream";
4import * as util from "util"; 6import * as util from "util";
5import { log, assert } from "../util"; 7import { log, assert } from "../util";
8import { ArtifactReleaseInfo } from "./interfaces";
6 9
7const pipeline = util.promisify(stream.pipeline); 10const pipeline = util.promisify(stream.pipeline);
8 11
@@ -49,3 +52,46 @@ export async function downloadFile(
49 // Issue at nodejs repo: https://github.com/nodejs/node/issues/31776 52 // Issue at nodejs repo: https://github.com/nodejs/node/issues/31776
50 }); 53 });
51} 54}
55
56/**
57 * Downloads artifact from given `downloadUrl`.
58 * Creates `installationDir` if it is not yet created and puts the artifact under
59 * `artifactFileName`.
60 * Displays info about the download progress in an info message printing the name
61 * of the artifact as `displayName`.
62 */
63export async function downloadArtifactWithProgressUi(
64 { downloadUrl, releaseName }: ArtifactReleaseInfo,
65 artifactFileName: string,
66 installationDir: string,
67 displayName: string,
68) {
69 await fs.promises.mkdir(installationDir).catch(err => assert(
70 err?.code === "EEXIST",
71 `Couldn't create directory "${installationDir}" to download ` +
72 `${artifactFileName} artifact: ${err?.message}`
73 ));
74
75 const installationPath = path.join(installationDir, artifactFileName);
76
77 await vscode.window.withProgress(
78 {
79 location: vscode.ProgressLocation.Notification,
80 cancellable: false, // FIXME: add support for canceling download?
81 title: `Downloading rust-analyzer ${displayName} (${releaseName})`
82 },
83 async (progress, _cancellationToken) => {
84 let lastPrecentage = 0;
85 const filePermissions = 0o755; // (rwx, r_x, r_x)
86 await downloadFile(downloadUrl, installationPath, filePermissions, (readBytes, totalBytes) => {
87 const newPercentage = (readBytes / totalBytes) * 100;
88 progress.report({
89 message: newPercentage.toFixed(0) + "%",
90 increment: newPercentage - lastPrecentage
91 });
92
93 lastPrecentage = newPercentage;
94 });
95 }
96 );
97}
diff --git a/editors/code/src/installation/extension.ts b/editors/code/src/installation/extension.ts
new file mode 100644
index 000000000..eea6fded2
--- /dev/null
+++ b/editors/code/src/installation/extension.ts
@@ -0,0 +1,144 @@
1import * as vscode from "vscode";
2import * as path from "path";
3import { promises as fs } from 'fs';
4
5import { vscodeReinstallExtension, vscodeReloadWindow, log, vscodeInstallExtensionFromVsix, assert, notReentrant } from "../util";
6import { Config, UpdatesChannel } from "../config";
7import { ArtifactReleaseInfo, ArtifactSource } from "./interfaces";
8import { downloadArtifactWithProgressUi } from "./downloads";
9import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info";
10
11const HEURISTIC_NIGHTLY_RELEASE_PERIOD_IN_HOURS = 25;
12
13/**
14 * Installs `stable` or latest `nightly` version or does nothing if the current
15 * extension version is what's needed according to `desiredUpdateChannel`.
16 */
17export async function ensureProperExtensionVersion(config: Config): Promise<never | void> {
18 // User has built lsp server from sources, she should manage updates manually
19 if (config.serverSource?.type === ArtifactSource.Type.ExplicitPath) return;
20
21 const currentUpdChannel = config.installedExtensionUpdateChannel;
22 const desiredUpdChannel = config.updatesChannel;
23
24 if (currentUpdChannel === UpdatesChannel.Stable) {
25 // Release date is present only when we are on nightly
26 await config.installedNightlyExtensionReleaseDate.set(null);
27 }
28
29 if (desiredUpdChannel === UpdatesChannel.Stable) {
30 // VSCode should handle updates for stable channel
31 if (currentUpdChannel === UpdatesChannel.Stable) return;
32
33 if (!await askToDownloadProperExtensionVersion(config)) return;
34
35 await vscodeReinstallExtension(config.extensionId);
36 await vscodeReloadWindow(); // never returns
37 }
38
39 if (currentUpdChannel === UpdatesChannel.Stable) {
40 if (!await askToDownloadProperExtensionVersion(config)) return;
41
42 return await tryDownloadNightlyExtension(config);
43 }
44
45 const currentExtReleaseDate = config.installedNightlyExtensionReleaseDate.get();
46
47 if (currentExtReleaseDate === null) {
48 void vscode.window.showErrorMessage(
49 "Nightly release date must've been set during the installation. " +
50 "Did you download and install the nightly .vsix package manually?"
51 );
52 throw new Error("Nightly release date was not set in globalStorage");
53 }
54
55 const dateNow = new Date;
56 const hoursSinceLastUpdate = diffInHours(currentExtReleaseDate, dateNow);
57 log.debug(
58 "Current rust-analyzer nightly was downloaded", hoursSinceLastUpdate,
59 "hours ago, namely:", currentExtReleaseDate, "and now is", dateNow
60 );
61
62 if (hoursSinceLastUpdate < HEURISTIC_NIGHTLY_RELEASE_PERIOD_IN_HOURS) {
63 return;
64 }
65 if (!await askToDownloadProperExtensionVersion(config, "The installed nightly version is most likely outdated. ")) {
66 return;
67 }
68
69 await tryDownloadNightlyExtension(config, releaseInfo => {
70 assert(
71 currentExtReleaseDate.getTime() === config.installedNightlyExtensionReleaseDate.get()?.getTime(),
72 "Other active VSCode instance has reinstalled the extension"
73 );
74
75 if (releaseInfo.releaseDate.getTime() === currentExtReleaseDate.getTime()) {
76 vscode.window.showInformationMessage(
77 "Whoops, it appears that your nightly version is up-to-date. " +
78 "There might be some problems with the upcomming nightly release " +
79 "or you traveled too far into the future. Sorry for that 😅! "
80 );
81 return false;
82 }
83 return true;
84 });
85}
86
87async function askToDownloadProperExtensionVersion(config: Config, reason = "") {
88 if (!config.askBeforeDownload) return true;
89
90 const stableOrNightly = config.updatesChannel === UpdatesChannel.Stable ? "stable" : "latest nightly";
91
92 // In case of reentering this function and showing the same info message
93 // (e.g. after we had shown this message, the user changed the config)
94 // vscode will dismiss the already shown one (i.e. return undefined).
95 // This behaviour is what we want, but likely it is not documented
96
97 const userResponse = await vscode.window.showInformationMessage(
98 reason + `Do you want to download the ${stableOrNightly} rust-analyzer extension ` +
99 `version and reload the window now?`,
100 "Download now", "Cancel"
101 );
102 return userResponse === "Download now";
103}
104
105/**
106 * Shutdowns the process in case of success (i.e. reloads the window) or throws an error.
107 *
108 * ACHTUNG!: this function has a crazy amount of state transitions, handling errors during
109 * each of them would result in a ton of code (especially accounting for cross-process
110 * shared mutable `globalState` access). Enforcing no reentrancy for this is best-effort.
111 */
112const tryDownloadNightlyExtension = notReentrant(async (
113 config: Config,
114 shouldDownload: (releaseInfo: ArtifactReleaseInfo) => boolean = () => true
115): Promise<never | void> => {
116 const vsixSource = config.nightlyVsixSource;
117 try {
118 const releaseInfo = await fetchArtifactReleaseInfo(vsixSource.repo, vsixSource.file, vsixSource.tag);
119
120 if (!shouldDownload(releaseInfo)) return;
121
122 await downloadArtifactWithProgressUi(releaseInfo, vsixSource.file, vsixSource.dir, "nightly extension");
123
124 const vsixPath = path.join(vsixSource.dir, vsixSource.file);
125
126 await vscodeInstallExtensionFromVsix(vsixPath);
127 await config.installedNightlyExtensionReleaseDate.set(releaseInfo.releaseDate);
128 await fs.unlink(vsixPath);
129
130 await vscodeReloadWindow(); // never returns
131 } catch (err) {
132 log.downloadError(err, "nightly extension", vsixSource.repo.name);
133 }
134});
135
136function diffInHours(a: Date, b: Date): number {
137 // Discard the time and time-zone information (to abstract from daylight saving time bugs)
138 // https://stackoverflow.com/a/15289883/9259330
139
140 const utcA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
141 const utcB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
142
143 return (utcA - utcB) / (1000 * 60 * 60);
144}
diff --git a/editors/code/src/installation/fetch_artifact_release_info.ts b/editors/code/src/installation/fetch_artifact_release_info.ts
index b1b5a3485..1ad3b8338 100644
--- a/editors/code/src/installation/fetch_artifact_release_info.ts
+++ b/editors/code/src/installation/fetch_artifact_release_info.ts
@@ -59,12 +59,15 @@ export async function fetchArtifactReleaseInfo(
59 59
60 return { 60 return {
61 releaseName: release.name, 61 releaseName: release.name,
62 releaseDate: new Date(release.published_at),
62 downloadUrl: artifact.browser_download_url 63 downloadUrl: artifact.browser_download_url
63 }; 64 };
64 65
65 // We omit declaration of tremendous amount of fields that we are not using here 66 // We omit declaration of tremendous amount of fields that we are not using here
66 interface GithubRelease { 67 interface GithubRelease {
67 name: string; 68 name: string;
69 // eslint-disable-next-line camelcase
70 published_at: string;
68 assets: Array<{ 71 assets: Array<{
69 name: string; 72 name: string;
70 // eslint-disable-next-line camelcase 73 // eslint-disable-next-line camelcase
diff --git a/editors/code/src/installation/interfaces.ts b/editors/code/src/installation/interfaces.ts
index 50b635921..1a8ea0884 100644
--- a/editors/code/src/installation/interfaces.ts
+++ b/editors/code/src/installation/interfaces.ts
@@ -1,5 +1,3 @@
1import * as vscode from "vscode";
2
3export interface GithubRepo { 1export interface GithubRepo {
4 name: string; 2 name: string;
5 owner: string; 3 owner: string;
@@ -9,6 +7,7 @@ export interface GithubRepo {
9 * Metadata about particular artifact retrieved from GitHub releases. 7 * Metadata about particular artifact retrieved from GitHub releases.
10 */ 8 */
11export interface ArtifactReleaseInfo { 9export interface ArtifactReleaseInfo {
10 releaseDate: Date;
12 releaseName: string; 11 releaseName: string;
13 downloadUrl: string; 12 downloadUrl: string;
14} 13}
@@ -42,6 +41,9 @@ export namespace ArtifactSource {
42 */ 41 */
43 repo: GithubRepo; 42 repo: GithubRepo;
44 43
44
45 // FIXME: add installationPath: string;
46
45 /** 47 /**
46 * Directory on the filesystem where the bundled binary is stored. 48 * Directory on the filesystem where the bundled binary is stored.
47 */ 49 */
@@ -57,17 +59,5 @@ export namespace ArtifactSource {
57 * Tag of github release that denotes a version required by this extension. 59 * Tag of github release that denotes a version required by this extension.
58 */ 60 */
59 tag: string; 61 tag: string;
60
61 /**
62 * Object that provides `get()/update()` operations to store metadata
63 * about the actual binary, e.g. its actual version.
64 */
65 storage: vscode.Memento;
66
67 /**
68 * Ask for the user permission before downloading the artifact.
69 */
70 askBeforeDownload: boolean;
71 } 62 }
72
73} 63}
diff --git a/editors/code/src/installation/server.ts b/editors/code/src/installation/server.ts
index ef1c45ff6..05730a778 100644
--- a/editors/code/src/installation/server.ts
+++ b/editors/code/src/installation/server.ts
@@ -1,14 +1,16 @@
1import * as vscode from "vscode"; 1import * as vscode from "vscode";
2import * as path from "path"; 2import * as path from "path";
3import { promises as dns } from "dns";
4import { spawnSync } from "child_process"; 3import { spawnSync } from "child_process";
5 4
6import { ArtifactSource } from "./interfaces"; 5import { ArtifactSource } from "./interfaces";
7import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info"; 6import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info";
8import { downloadArtifact } from "./download_artifact"; 7import { downloadArtifactWithProgressUi } from "./downloads";
9import { log, assert } from "../util"; 8import { log, assert, notReentrant } from "../util";
9import { Config, NIGHTLY_TAG } from "../config";
10
11export async function ensureServerBinary(config: Config): Promise<null | string> {
12 const source = config.serverSource;
10 13
11export async function ensureServerBinary(source: null | ArtifactSource): Promise<null | string> {
12 if (!source) { 14 if (!source) {
13 vscode.window.showErrorMessage( 15 vscode.window.showErrorMessage(
14 "Unfortunately we don't ship binaries for your platform yet. " + 16 "Unfortunately we don't ship binaries for your platform yet. " +
@@ -35,18 +37,11 @@ export async function ensureServerBinary(source: null | ArtifactSource): Promise
35 return null; 37 return null;
36 } 38 }
37 case ArtifactSource.Type.GithubRelease: { 39 case ArtifactSource.Type.GithubRelease: {
38 const prebuiltBinaryPath = path.join(source.dir, source.file); 40 if (!shouldDownloadServer(source, config)) {
39 41 return path.join(source.dir, source.file);
40 const installedVersion: null | string = getServerVersion(source.storage);
41 const requiredVersion: string = source.tag;
42
43 log.debug("Installed version:", installedVersion, "required:", requiredVersion);
44
45 if (isBinaryAvailable(prebuiltBinaryPath) && installedVersion === requiredVersion) {
46 return prebuiltBinaryPath;
47 } 42 }
48 43
49 if (source.askBeforeDownload) { 44 if (config.askBeforeDownload) {
50 const userResponse = await vscode.window.showInformationMessage( 45 const userResponse = await vscode.window.showInformationMessage(
51 `Language server version ${source.tag} for rust-analyzer is not installed. ` + 46 `Language server version ${source.tag} for rust-analyzer is not installed. ` +
52 "Do you want to download it now?", 47 "Do you want to download it now?",
@@ -55,38 +50,56 @@ export async function ensureServerBinary(source: null | ArtifactSource): Promise
55 if (userResponse !== "Download now") return null; 50 if (userResponse !== "Download now") return null;
56 } 51 }
57 52
58 if (!await downloadServer(source)) return null; 53 return await downloadServer(source, config);
59
60 return prebuiltBinaryPath;
61 } 54 }
62 } 55 }
63} 56}
64 57
65async function downloadServer(source: ArtifactSource.GithubRelease): Promise<boolean> { 58function shouldDownloadServer(
59 source: ArtifactSource.GithubRelease,
60 config: Config
61): boolean {
62 if (!isBinaryAvailable(path.join(source.dir, source.file))) return true;
63
64 const installed = {
65 tag: config.serverReleaseTag.get(),
66 date: config.serverReleaseDate.get()
67 };
68 const required = {
69 tag: source.tag,
70 date: config.installedNightlyExtensionReleaseDate.get()
71 };
72
73 log.debug("Installed server:", installed, "required:", required);
74
75 if (required.tag !== NIGHTLY_TAG || installed.tag !== NIGHTLY_TAG) {
76 return required.tag !== installed.tag;
77 }
78
79 assert(required.date !== null, "Extension release date should have been saved during its installation");
80 assert(installed.date !== null, "Server release date should have been saved during its installation");
81
82 return installed.date.getTime() !== required.date.getTime();
83}
84
85/**
86 * Enforcing no reentrancy for this is best-effort.
87 */
88const downloadServer = notReentrant(async (
89 source: ArtifactSource.GithubRelease,
90 config: Config,
91): Promise<null | string> => {
66 try { 92 try {
67 const releaseInfo = await fetchArtifactReleaseInfo(source.repo, source.file, source.tag); 93 const releaseInfo = await fetchArtifactReleaseInfo(source.repo, source.file, source.tag);
68 94
69 await downloadArtifact(releaseInfo, source.file, source.dir, "language server"); 95 await downloadArtifactWithProgressUi(releaseInfo, source.file, source.dir, "language server");
70 await setServerVersion(source.storage, releaseInfo.releaseName); 96 await Promise.all([
97 config.serverReleaseTag.set(releaseInfo.releaseName),
98 config.serverReleaseDate.set(releaseInfo.releaseDate)
99 ]);
71 } catch (err) { 100 } catch (err) {
72 vscode.window.showErrorMessage( 101 log.downloadError(err, "language server", source.repo.name);
73 `Failed to download language server from ${source.repo.name} ` + 102 return null;
74 `GitHub repository: ${err.message}`
75 );
76
77 log.error(err);
78
79 dns.resolve('example.com').then(
80 addrs => log.debug("DNS resolution for example.com was successful", addrs),
81 err => {
82 log.error(
83 "DNS resolution for example.com failed, " +
84 "there might be an issue with Internet availability"
85 );
86 log.error(err);
87 }
88 );
89 return false;
90 } 103 }
91 104
92 const binaryPath = path.join(source.dir, source.file); 105 const binaryPath = path.join(source.dir, source.file);
@@ -101,8 +114,8 @@ async function downloadServer(source: ArtifactSource.GithubRelease): Promise<boo
101 "Rust analyzer language server was successfully installed 🦀" 114 "Rust analyzer language server was successfully installed 🦀"
102 ); 115 );
103 116
104 return true; 117 return binaryPath;
105} 118});
106 119
107function isBinaryAvailable(binaryPath: string): boolean { 120function isBinaryAvailable(binaryPath: string): boolean {
108 const res = spawnSync(binaryPath, ["--version"]); 121 const res = spawnSync(binaryPath, ["--version"]);
@@ -115,14 +128,3 @@ function isBinaryAvailable(binaryPath: string): boolean {
115 128
116 return res.status === 0; 129 return res.status === 0;
117} 130}
118
119function getServerVersion(storage: vscode.Memento): null | string {
120 const version = storage.get<null | string>("server-version", null);
121 log.debug("Get server-version:", version);
122 return version;
123}
124
125async function setServerVersion(storage: vscode.Memento, version: string): Promise<void> {
126 log.debug("Set server-version:", version);
127 await storage.update("server-version", version.toString());
128}
diff --git a/editors/code/src/main.ts b/editors/code/src/main.ts
index e01c89cc7..bd4661a36 100644
--- a/editors/code/src/main.ts
+++ b/editors/code/src/main.ts
@@ -8,6 +8,7 @@ import { activateHighlighting } from './highlighting';
8import { ensureServerBinary } from './installation/server'; 8import { ensureServerBinary } from './installation/server';
9import { Config } from './config'; 9import { Config } from './config';
10import { log } from './util'; 10import { log } from './util';
11import { ensureProperExtensionVersion } from './installation/extension';
11 12
12let ctx: Ctx | undefined; 13let ctx: Ctx | undefined;
13 14
@@ -34,7 +35,13 @@ export async function activate(context: vscode.ExtensionContext) {
34 35
35 const config = new Config(context); 36 const config = new Config(context);
36 37
37 const serverPath = await ensureServerBinary(config.serverSource); 38 vscode.workspace.onDidChangeConfiguration(() => ensureProperExtensionVersion(config).catch(log.error));
39
40 // Don't await the user response here, otherwise we will block the lsp server bootstrap
41 void ensureProperExtensionVersion(config).catch(log.error);
42
43 const serverPath = await ensureServerBinary(config);
44
38 if (serverPath == null) { 45 if (serverPath == null) {
39 throw new Error( 46 throw new Error(
40 "Rust Analyzer Language Server is not available. " + 47 "Rust Analyzer Language Server is not available. " +
diff --git a/editors/code/src/util.ts b/editors/code/src/util.ts
index 95a5f1227..2bfc145e6 100644
--- a/editors/code/src/util.ts
+++ b/editors/code/src/util.ts
@@ -1,5 +1,6 @@
1import * as lc from "vscode-languageclient"; 1import * as lc from "vscode-languageclient";
2import * as vscode from "vscode"; 2import * as vscode from "vscode";
3import { promises as dns } from "dns";
3import { strict as nativeAssert } from "assert"; 4import { strict as nativeAssert } from "assert";
4 5
5export function assert(condition: boolean, explanation: string): asserts condition { 6export function assert(condition: boolean, explanation: string): asserts condition {
@@ -11,21 +12,40 @@ export function assert(condition: boolean, explanation: string): asserts conditi
11 } 12 }
12} 13}
13 14
14export const log = { 15export const log = new class {
15 enabled: true, 16 private enabled = true;
17
18 setEnabled(yes: boolean): void {
19 log.enabled = yes;
20 }
21
16 debug(message?: any, ...optionalParams: any[]): void { 22 debug(message?: any, ...optionalParams: any[]): void {
17 if (!log.enabled) return; 23 if (!log.enabled) return;
18 // eslint-disable-next-line no-console 24 // eslint-disable-next-line no-console
19 console.log(message, ...optionalParams); 25 console.log(message, ...optionalParams);
20 }, 26 }
27
21 error(message?: any, ...optionalParams: any[]): void { 28 error(message?: any, ...optionalParams: any[]): void {
22 if (!log.enabled) return; 29 if (!log.enabled) return;
23 debugger; 30 debugger;
24 // eslint-disable-next-line no-console 31 // eslint-disable-next-line no-console
25 console.error(message, ...optionalParams); 32 console.error(message, ...optionalParams);
26 }, 33 }
27 setEnabled(yes: boolean): void { 34
28 log.enabled = yes; 35 downloadError(err: Error, artifactName: string, repoName: string) {
36 vscode.window.showErrorMessage(
37 `Failed to download the rust-analyzer ${artifactName} from ${repoName} ` +
38 `GitHub repository: ${err.message}`
39 );
40 log.error(err);
41 dns.resolve('example.com').then(
42 addrs => log.debug("DNS resolution for example.com was successful", addrs),
43 err => log.error(
44 "DNS resolution for example.com failed, " +
45 "there might be an issue with Internet availability",
46 err
47 )
48 );
29 } 49 }
30}; 50};
31 51
@@ -66,6 +86,17 @@ function sleep(ms: number) {
66 return new Promise(resolve => setTimeout(resolve, ms)); 86 return new Promise(resolve => setTimeout(resolve, ms));
67} 87}
68 88
89export function notReentrant<TThis, TParams extends any[], TRet>(
90 fn: (this: TThis, ...params: TParams) => Promise<TRet>
91): typeof fn {
92 let entered = false;
93 return function(...params) {
94 assert(!entered, `Reentrancy invariant for ${fn.name} is violated`);
95 entered = true;
96 return fn.apply(this, params).finally(() => entered = false);
97 };
98}
99
69export type RustDocument = vscode.TextDocument & { languageId: "rust" }; 100export type RustDocument = vscode.TextDocument & { languageId: "rust" };
70export type RustEditor = vscode.TextEditor & { document: RustDocument; id: string }; 101export type RustEditor = vscode.TextEditor & { document: RustDocument; id: string };
71 102
@@ -79,3 +110,29 @@ export function isRustDocument(document: vscode.TextDocument): document is RustD
79export function isRustEditor(editor: vscode.TextEditor): editor is RustEditor { 110export function isRustEditor(editor: vscode.TextEditor): editor is RustEditor {
80 return isRustDocument(editor.document); 111 return isRustDocument(editor.document);
81} 112}
113
114/**
115 * @param extensionId The canonical extension identifier in the form of: `publisher.name`
116 */
117export async function vscodeReinstallExtension(extensionId: string) {
118 // Unfortunately there is no straightforward way as of now, these commands
119 // were found in vscode source code.
120
121 log.debug("Uninstalling extension", extensionId);
122 await vscode.commands.executeCommand("workbench.extensions.uninstallExtension", extensionId);
123 log.debug("Installing extension", extensionId);
124 await vscode.commands.executeCommand("workbench.extensions.installExtension", extensionId);
125}
126
127export async function vscodeReloadWindow(): Promise<never> {
128 await vscode.commands.executeCommand("workbench.action.reloadWindow");
129
130 assert(false, "unreachable");
131}
132
133export async function vscodeInstallExtensionFromVsix(vsixPath: string) {
134 await vscode.commands.executeCommand(
135 "workbench.extensions.installExtension",
136 vscode.Uri.file(vsixPath)
137 );
138}