From 5d88c1db38200896d2e4af7836fec95097adf509 Mon Sep 17 00:00:00 2001 From: Veetaha Date: Sat, 8 Feb 2020 04:22:44 +0200 Subject: vscode: amended config to use binary from globalStoragePath, added ui for downloading --- editors/code/package-lock.json | 13 +++ editors/code/package.json | 6 +- editors/code/src/client.ts | 22 +--- editors/code/src/config.ts | 56 ++++++++-- editors/code/src/ctx.ts | 6 +- editors/code/src/github/download_file.ts | 26 ----- .../src/github/fetch_latest_artifact_metadata.ts | 55 ---------- editors/code/src/installation/download_file.ts | 26 +++++ .../installation/fetch_latest_artifact_metadata.ts | 47 ++++++++ editors/code/src/installation/interfaces.ts | 26 +++++ editors/code/src/installation/language_server.ts | 119 +++++++++++++++++++++ editors/code/tsconfig.json | 2 + 12 files changed, 296 insertions(+), 108 deletions(-) delete mode 100644 editors/code/src/github/download_file.ts delete mode 100644 editors/code/src/github/fetch_latest_artifact_metadata.ts create mode 100644 editors/code/src/installation/download_file.ts create mode 100644 editors/code/src/installation/fetch_latest_artifact_metadata.ts create mode 100644 editors/code/src/installation/interfaces.ts create mode 100644 editors/code/src/installation/language_server.ts (limited to 'editors/code') diff --git a/editors/code/package-lock.json b/editors/code/package-lock.json index 5c056463e..1b7c8910e 100644 --- a/editors/code/package-lock.json +++ b/editors/code/package-lock.json @@ -753,6 +753,19 @@ "os-tmpdir": "~1.0.1" } }, + "ts-not-nil": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ts-not-nil/-/ts-not-nil-1.0.1.tgz", + "integrity": "sha512-19+u+3okJddVZlrIdTOdFBaMsHYDInIGDPiujxfRa0RS2Ch5055zVG4GAqa+CZ/Rd1a+7ORSm8O4+2kesPymtw==", + "requires": { + "ts-typedefs": ">=3.2.0" + } + }, + "ts-typedefs": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ts-typedefs/-/ts-typedefs-3.2.0.tgz", + "integrity": "sha512-NglEH2YiY40YxNAvwBISqqXRTKlQq6x+qoCF+tkjPxwrPbrkmq7V3LXavmxrD63fENtMhFkcqgMJtOirtow9iA==" + }, "tslib": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", diff --git a/editors/code/package.json b/editors/code/package.json index 8e23718cd..c0a62619d 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -27,6 +27,7 @@ "jsonc-parser": "^2.1.0", "node-fetch": "^2.6.0", "throttle-debounce": "^2.1.0", + "ts-not-nil": "^1.0.1", "vscode-languageclient": "^6.1.0" }, "devDependencies": { @@ -173,10 +174,11 @@ }, "rust-analyzer.raLspServerPath": { "type": [ + "null", "string" ], - "default": "ra_lsp_server", - "description": "Path to ra_lsp_server executable" + "default": null, + "description": "Path to ra_lsp_server executable (points to bundled binary by default)" }, "rust-analyzer.excludeGlobs": { "type": "array", diff --git a/editors/code/src/client.ts b/editors/code/src/client.ts index 7e7e909dd..7639ed44b 100644 --- a/editors/code/src/client.ts +++ b/editors/code/src/client.ts @@ -1,24 +1,18 @@ -import { homedir } from 'os'; import * as lc from 'vscode-languageclient'; -import { spawnSync } from 'child_process'; import { window, workspace } from 'vscode'; import { Config } from './config'; +import { ensureLanguageServerBinary } from './installation/language_server'; -export function createClient(config: Config): lc.LanguageClient { +export async function createClient(config: Config): Promise { // '.' Is the fallback if no folder is open // TODO?: Workspace folders support Uri's (eg: file://test.txt). // It might be a good idea to test if the uri points to a file. const workspaceFolderPath = workspace.workspaceFolders?.[0]?.uri.fsPath ?? '.'; - const raLspServerPath = expandPathResolving(config.raLspServerPath); - if (spawnSync(raLspServerPath, ["--version"]).status !== 0) { - window.showErrorMessage( - `Unable to execute '${raLspServerPath} --version'\n\n` + - `Perhaps it is not in $PATH?\n\n` + - `PATH=${process.env.PATH}\n` - ); - } + const raLspServerPath = await ensureLanguageServerBinary(config.raLspServerSource); + if (!raLspServerPath) return null; + const run: lc.Executable = { command: raLspServerPath, options: { cwd: workspaceFolderPath }, @@ -87,9 +81,3 @@ export function createClient(config: Config): lc.LanguageClient { res.registerProposedFeatures(); return res; } -function expandPathResolving(path: string) { - if (path.startsWith('~/')) { - return path.replace('~', homedir()); - } - return path; -} diff --git a/editors/code/src/config.ts b/editors/code/src/config.ts index 524620433..aca5dab5a 100644 --- a/editors/code/src/config.ts +++ b/editors/code/src/config.ts @@ -1,4 +1,6 @@ +import * as os from "os"; import * as vscode from 'vscode'; +import { BinarySource, BinarySourceType } from "./installation/interfaces"; const RA_LSP_DEBUG = process.env.__RA_LSP_SERVER_DEBUG; @@ -16,10 +18,24 @@ export interface CargoFeatures { } export class Config { + readonly raLspServerGithubArtifactName = { + linux: "ra_lsp_server-linux", + darwin: "ra_lsp_server-mac", + win32: "ra_lsp_server-windows.exe", + aix: null, + android: null, + freebsd: null, + openbsd: null, + sunos: null, + cygwin: null, + netbsd: null, + }[process.platform]; + + raLspServerSource!: null | BinarySource; + highlightingOn = true; rainbowHighlightingOn = false; enableEnhancedTyping = true; - raLspServerPath = RA_LSP_DEBUG || 'ra_lsp_server'; lruCapacity: null | number = null; displayInlayHints = true; maxInlayHintLength: null | number = null; @@ -45,11 +61,20 @@ export class Config { private prevCargoWatchOptions: null | CargoWatchOptions = null; constructor(ctx: vscode.ExtensionContext) { - vscode.workspace.onDidChangeConfiguration(_ => this.refresh(), null, ctx.subscriptions); - this.refresh(); + vscode.workspace.onDidChangeConfiguration(_ => this.refresh(ctx), null, ctx.subscriptions); + this.refresh(ctx); + } + + private static expandPathResolving(path: string) { + if (path.startsWith('~/')) { + return path.replace('~', os.homedir()); + } + return path; } - private refresh() { + // FIXME: revisit the logic for `if (.has(...)) config.get(...)` set default + // values only in one place (i.e. remove default values from non-readonly members declarations) + private refresh(ctx: vscode.ExtensionContext) { const config = vscode.workspace.getConfiguration('rust-analyzer'); let requireReloadMessage = null; @@ -82,9 +107,26 @@ export class Config { this.prevEnhancedTyping = this.enableEnhancedTyping; } - if (config.has('raLspServerPath')) { - this.raLspServerPath = - RA_LSP_DEBUG || (config.get('raLspServerPath') as string); + { + const raLspServerPath = RA_LSP_DEBUG ?? config.get("raLspServerPath"); + if (raLspServerPath) { + this.raLspServerSource = { + type: BinarySourceType.ExplicitPath, + path: Config.expandPathResolving(raLspServerPath) + }; + } else if (this.raLspServerGithubArtifactName) { + this.raLspServerSource = { + type: BinarySourceType.GithubBinary, + dir: ctx.globalStoragePath, + file: this.raLspServerGithubArtifactName, + repo: { + name: "rust-analyzer", + owner: "rust-analyzer", + } + }; + } else { + this.raLspServerSource = null; + } } if (config.has('cargo-watch.enable')) { diff --git a/editors/code/src/ctx.ts b/editors/code/src/ctx.ts index a4dcc3037..f0e2d72f7 100644 --- a/editors/code/src/ctx.ts +++ b/editors/code/src/ctx.ts @@ -29,7 +29,11 @@ export class Ctx { await old.stop(); } this.client = null; - const client = createClient(this.config); + const client = await createClient(this.config); + if (!client) { + throw new Error("Rust Analyzer Language Server is not available"); + } + this.pushCleanup(client.start()); await client.onReady(); diff --git a/editors/code/src/github/download_file.ts b/editors/code/src/github/download_file.ts deleted file mode 100644 index f40750be9..000000000 --- a/editors/code/src/github/download_file.ts +++ /dev/null @@ -1,26 +0,0 @@ -import fetch from "node-fetch"; -import { throttle } from "throttle-debounce"; -import * as fs from "fs"; - -export async function downloadFile( - url: string, - destFilePath: fs.PathLike, - onProgress: (readBytes: number, totalBytes: number) => void -): Promise { - onProgress = throttle(100, /* noTrailing: */ true, onProgress); - - const response = await fetch(url); - - const totalBytes = Number(response.headers.get('content-length')); - let readBytes = 0; - - return new Promise((resolve, reject) => response.body - .on("data", (chunk: Buffer) => { - readBytes += chunk.length; - onProgress(readBytes, totalBytes); - }) - .on("end", resolve) - .on("error", reject) - .pipe(fs.createWriteStream(destFilePath)) - ); -} diff --git a/editors/code/src/github/fetch_latest_artifact_metadata.ts b/editors/code/src/github/fetch_latest_artifact_metadata.ts deleted file mode 100644 index 52641ca67..000000000 --- a/editors/code/src/github/fetch_latest_artifact_metadata.ts +++ /dev/null @@ -1,55 +0,0 @@ -import fetch from "node-fetch"; - -const GITHUB_API_ENDPOINT_URL = "https://api.github.com"; - -export interface FetchLatestArtifactMetadataOpts { - repoName: string; - repoOwner: string; - artifactFileName: string; -} - -export interface ArtifactMetadata { - releaseName: string; - releaseDate: Date; - downloadUrl: string; -} - -export async function fetchLatestArtifactMetadata( - opts: FetchLatestArtifactMetadataOpts -): Promise { - - const repoOwner = encodeURIComponent(opts.repoOwner); - const repoName = encodeURIComponent(opts.repoName); - - const apiEndpointPath = `/repos/${repoOwner}/${repoName}/releases/latest`; - const requestUrl = GITHUB_API_ENDPOINT_URL + apiEndpointPath; - - // We skip runtime type checks for simplicity (here we cast from `any` to `Release`) - - const response: GithubRelease = await fetch(requestUrl, { - headers: { Accept: "application/vnd.github.v3+json" } - }) - .then(res => res.json()); - - const artifact = response.assets.find(artifact => artifact.name === opts.artifactFileName); - - return !artifact ? null : { - releaseName: response.name, - releaseDate: new Date(response.published_at), - downloadUrl: artifact.browser_download_url - }; - - // Noise denotes tremendous amount of data that we are not using here - interface GithubRelease { - name: string; - published_at: Date; - assets: Array<{ - browser_download_url: string; - - [noise: string]: unknown; - }>; - - [noise: string]: unknown; - } - -} diff --git a/editors/code/src/installation/download_file.ts b/editors/code/src/installation/download_file.ts new file mode 100644 index 000000000..7b537e114 --- /dev/null +++ b/editors/code/src/installation/download_file.ts @@ -0,0 +1,26 @@ +import fetch from "node-fetch"; +import { throttle } from "throttle-debounce"; +import * as fs from "fs"; + +export async function downloadFile( + url: string, + destFilePath: fs.PathLike, + onProgress: (readBytes: number, totalBytes: number) => void +): Promise { + onProgress = throttle(1000, /* noTrailing: */ true, onProgress); + + const response = await fetch(url); + + const totalBytes = Number(response.headers.get('content-length')); + let readBytes = 0; + + return new Promise((resolve, reject) => response.body + .on("data", (chunk: Buffer) => { + readBytes += chunk.length; + onProgress(readBytes, totalBytes); + }) + .on("end", resolve) + .on("error", reject) + .pipe(fs.createWriteStream(destFilePath)) + ); +} 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..f07431aac --- /dev/null +++ b/editors/code/src/installation/fetch_latest_artifact_metadata.ts @@ -0,0 +1,47 @@ +import fetch from "node-fetch"; +import { GithubRepo, ArtifactMetadata } from "./interfaces"; + +const GITHUB_API_ENDPOINT_URL = "https://api.github.com"; + +export interface FetchLatestArtifactMetadataOpts { + repo: GithubRepo; + artifactFileName: string; +} + +export async function fetchLatestArtifactMetadata( + opts: FetchLatestArtifactMetadataOpts +): Promise { + + const repoOwner = encodeURIComponent(opts.repo.owner); + const repoName = encodeURIComponent(opts.repo.name); + + const apiEndpointPath = `/repos/${repoOwner}/${repoName}/releases/latest`; + const requestUrl = GITHUB_API_ENDPOINT_URL + apiEndpointPath; + + // We skip runtime type checks for simplicity (here we cast from `any` to `Release`) + + const response: GithubRelease = await fetch(requestUrl, { + headers: { Accept: "application/vnd.github.v3+json" } + }) + .then(res => res.json()); + + const artifact = response.assets.find(artifact => artifact.name === opts.artifactFileName); + + return !artifact ? null : { + releaseName: response.name, + downloadUrl: artifact.browser_download_url + }; + + // Noise denotes tremendous amount of data that we are not using here + interface GithubRelease { + name: string; + assets: Array<{ + browser_download_url: string; + + [noise: string]: unknown; + }>; + + [noise: string]: unknown; + } + +} diff --git a/editors/code/src/installation/interfaces.ts b/editors/code/src/installation/interfaces.ts new file mode 100644 index 000000000..f54e24e26 --- /dev/null +++ b/editors/code/src/installation/interfaces.ts @@ -0,0 +1,26 @@ +export interface GithubRepo { + name: string; + owner: string; +} + +export interface ArtifactMetadata { + releaseName: string; + downloadUrl: string; +} + + +export enum BinarySourceType { ExplicitPath, GithubBinary } + +export type BinarySource = EplicitPathSource | GithubBinarySource; + +export interface EplicitPathSource { + type: BinarySourceType.ExplicitPath; + path: string; +} + +export interface GithubBinarySource { + type: BinarySourceType.GithubBinary; + repo: GithubRepo; + dir: string; + file: string; +} diff --git a/editors/code/src/installation/language_server.ts b/editors/code/src/installation/language_server.ts new file mode 100644 index 000000000..2b3ce6621 --- /dev/null +++ b/editors/code/src/installation/language_server.ts @@ -0,0 +1,119 @@ +import { unwrapNotNil } from "ts-not-nil"; +import { spawnSync } from "child_process"; +import * as vscode from "vscode"; +import * as path from "path"; +import { strict as assert } from "assert"; +import { promises as fs } from "fs"; + +import { BinarySource, BinarySourceType, GithubBinarySource } from "./interfaces"; +import { fetchLatestArtifactMetadata } from "./fetch_latest_artifact_metadata"; +import { downloadFile } from "./download_file"; + +export async function downloadLatestLanguageServer( + {file: artifactFileName, dir: installationDir, repo}: GithubBinarySource +) { + const binaryMetadata = await fetchLatestArtifactMetadata({ artifactFileName, repo }); + + const { + releaseName, + downloadUrl + } = unwrapNotNil(binaryMetadata, `Latest GitHub release lacks "${artifactFileName}" file`); + + await fs.mkdir(installationDir).catch(err => assert.strictEqual( + err && err.code, + "EEXIST", + `Couldn't create directory "${installationDir}" to download `+ + `language server binary: ${err.message}` + )); + + const installationPath = path.join(installationDir, artifactFileName); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, // FIXME: add support for canceling download? + title: `Downloading language server ${releaseName}` + }, + async (progress, _) => { + let lastPrecentage = 0; + await downloadFile(downloadUrl, installationPath, (readBytes, totalBytes) => { + const newPercentage = (readBytes / totalBytes) * 100; + progress.report({ + message: newPercentage.toFixed(0) + "%", + increment: newPercentage - lastPrecentage + }); + + lastPrecentage = newPercentage; + }); + } + ); + + await fs.chmod(installationPath, 111); // Set xxx permissions +} +export async function ensureLanguageServerBinary( + langServerSource: null | BinarySource +): Promise { + + if (!langServerSource) { + vscode.window.showErrorMessage( + "Unfortunately we don't ship binaries for your platform yet. " + + "You need to manually clone rust-analyzer repository and " + + "run `cargo xtask install --server` to build the language server from sources. " + + "If you feel that your platform should be supported, please create an issue " + + "about that [here](https://github.com/rust-analyzer/rust-analyzer/issues) and we " + + "will consider it." + ); + return null; + } + + switch (langServerSource.type) { + case BinarySourceType.ExplicitPath: { + if (isBinaryAvailable(langServerSource.path)) { + return langServerSource.path; + } + vscode.window.showErrorMessage( + `Unable to execute ${'`'}${langServerSource.path} --version${'`'}. ` + + "To use the bundled language server, set `rust-analyzer.raLspServerPath` " + + "value to `null` or remove it from the settings to use it by default." + ); + return null; + } + case BinarySourceType.GithubBinary: { + const bundledBinaryPath = path.join(langServerSource.dir, langServerSource.file); + + if (!isBinaryAvailable(bundledBinaryPath)) { + const userResponse = await vscode.window.showInformationMessage( + `Language server binary for rust-analyzer was not found. ` + + `Do you want to download it now?`, + "Download now", "Cancel" + ); + if (userResponse !== "Download now") return null; + + try { + await downloadLatestLanguageServer(langServerSource); + } catch (err) { + await vscode.window.showErrorMessage( + `Failed to download language server from ${langServerSource.repo.name} ` + + `GitHub repository: ${err.message}` + ); + return null; + } + + + assert( + isBinaryAvailable(bundledBinaryPath), + "Downloaded language server binary is not functional" + ); + + vscode.window.showInformationMessage( + "Rust analyzer language server was successfully installed" + ); + } + return bundledBinaryPath; + } + } + + function isBinaryAvailable(binaryPath: string) { + return spawnSync(binaryPath, ["--version"]).status === 0; + } +} diff --git a/editors/code/tsconfig.json b/editors/code/tsconfig.json index e60eb8e5e..0c7702974 100644 --- a/editors/code/tsconfig.json +++ b/editors/code/tsconfig.json @@ -6,6 +6,8 @@ "lib": [ "es2019" ], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "sourceMap": true, "rootDir": "src", "strict": true, -- cgit v1.2.3