diff --git a/CHANGELOG.md b/CHANGELOG.md index e347e1e8..4f4722f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ### Unreleased +* Update built-in Rust grammar + ### 0.7.8 - 2020-05-13 * Rebrand extension as RLS-agnostic diff --git a/package.json b/package.json index ff858f4a..65b3dad7 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,24 @@ ".rs" ], "configuration": "./language-configuration.json" + }, + { + "id": "ra_syntax_tree", + "extensions": [ + ".rast" + ] + } + ], + "grammars": [ + { + "language": "rust", + "scopeName": "source.rust", + "path": "rust.tmGrammar.json" + }, + { + "language": "ra_syntax_tree", + "scopeName": "source.ra_syntax_tree", + "path": "ra_syntax_tree.tmGrammar.json" } ], "snippets": [ diff --git a/rust-analyzer/editors/code/ra_syntax_tree.tmGrammar.json b/ra_syntax_tree.tmGrammar.json similarity index 100% rename from rust-analyzer/editors/code/ra_syntax_tree.tmGrammar.json rename to ra_syntax_tree.tmGrammar.json diff --git a/rust-analyzer/editors/code/package.json b/rust-analyzer/editors/code/package.json index aac4ba94..b84eeecf 100644 --- a/rust-analyzer/editors/code/package.json +++ b/rust-analyzer/editors/code/package.json @@ -632,26 +632,6 @@ ] } ], - "languages": [ - { - "id": "ra_syntax_tree", - "extensions": [ - ".rast" - ] - } - ], - "grammars": [ - { - "language": "rust", - "scopeName": "source.rust", - "path": "rust.tmGrammar.json" - }, - { - "language": "ra_syntax_tree", - "scopeName": "source.ra_syntax_tree", - "path": "ra_syntax_tree.tmGrammar.json" - } - ], "problemMatchers": [ { "name": "rustc", diff --git a/rust-analyzer/editors/code/src/net.ts b/rust-analyzer/editors/code/src/net.ts deleted file mode 100644 index 681eaa9c..00000000 --- a/rust-analyzer/editors/code/src/net.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Replace with `import fetch from "node-fetch"` once this is fixed in rollup: -// https://github.com/rollup/plugins/issues/491 -const fetch = require("node-fetch") as typeof import("node-fetch")["default"]; - -import * as vscode from "vscode"; -import * as stream from "stream"; -import * as crypto from "crypto"; -import * as fs from "fs"; -import * as zlib from "zlib"; -import * as util from "util"; -import * as path from "path"; -import { log, assert } from "./util"; - -const pipeline = util.promisify(stream.pipeline); - -const GITHUB_API_ENDPOINT_URL = "https://api.github.com"; -const OWNER = "rust-analyzer"; -const REPO = "rust-analyzer"; - -export async function fetchRelease( - releaseTag: string -): Promise { - - const apiEndpointPath = `/repos/${OWNER}/${REPO}/releases/tags/${releaseTag}`; - - const requestUrl = GITHUB_API_ENDPOINT_URL + apiEndpointPath; - - log.debug("Issuing request for released artifacts metadata to", requestUrl); - - const response = await fetch(requestUrl, { headers: { Accept: "application/vnd.github.v3+json" } }); - - if (!response.ok) { - log.error("Error fetching artifact release info", { - requestUrl, - releaseTag, - response: { - headers: response.headers, - status: response.status, - body: await response.text(), - } - }); - - throw new Error( - `Got response ${response.status} when trying to fetch ` + - `release info for ${releaseTag} release` - ); - } - - // We skip runtime type checks for simplicity (here we cast from `any` to `GithubRelease`) - const release: GithubRelease = await response.json(); - return release; -} - -// We omit declaration of tremendous amount of fields that we are not using here -export interface GithubRelease { - name: string; - id: number; - // eslint-disable-next-line camelcase - published_at: string; - assets: Array<{ - name: string; - // eslint-disable-next-line camelcase - browser_download_url: string; - }>; -} - -interface DownloadOpts { - progressTitle: string; - url: string; - dest: string; - mode?: number; - gunzip?: boolean; -} - -export async function download(opts: DownloadOpts) { - // Put artifact into a temporary file (in the same dir for simplicity) - // to prevent partially downloaded files when user kills vscode - const dest = path.parse(opts.dest); - const randomHex = crypto.randomBytes(5).toString("hex"); - const tempFile = path.join(dest.dir, `${dest.name}${randomHex}`); - - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - cancellable: false, - title: opts.progressTitle - }, - async (progress, _cancellationToken) => { - let lastPercentage = 0; - await downloadFile(opts.url, tempFile, opts.mode, !!opts.gunzip, (readBytes, totalBytes) => { - const newPercentage = (readBytes / totalBytes) * 100; - progress.report({ - message: newPercentage.toFixed(0) + "%", - increment: newPercentage - lastPercentage - }); - - lastPercentage = newPercentage; - }); - } - ); - - await fs.promises.rename(tempFile, opts.dest); -} - -async function downloadFile( - url: string, - destFilePath: fs.PathLike, - mode: number | undefined, - gunzip: boolean, - onProgress: (readBytes: number, totalBytes: number) => void -): Promise { - const res = await fetch(url); - - if (!res.ok) { - log.error("Error", res.status, "while downloading file from", url); - log.error({ body: await res.text(), headers: res.headers }); - - throw new Error(`Got response ${res.status} when trying to download a file.`); - } - - const totalBytes = Number(res.headers.get('content-length')); - assert(!Number.isNaN(totalBytes), "Sanity check of content-length protocol"); - - log.debug("Downloading file of", totalBytes, "bytes size from", url, "to", destFilePath); - - let readBytes = 0; - res.body.on("data", (chunk: Buffer) => { - readBytes += chunk.length; - onProgress(readBytes, totalBytes); - }); - - const destFileStream = fs.createWriteStream(destFilePath, { mode }); - const srcStream = gunzip ? res.body.pipe(zlib.createGunzip()) : res.body; - - await pipeline(srcStream, destFileStream); - - await new Promise(resolve => { - destFileStream.on("close", resolve); - destFileStream.destroy(); - // This workaround is awaiting to be removed when vscode moves to newer nodejs version: - // https://github.com/rust-analyzer/rust-analyzer/issues/3167 - }); -} diff --git a/rust-analyzer/editors/code/rust.tmGrammar.json b/rust.tmGrammar.json similarity index 100% rename from rust-analyzer/editors/code/rust.tmGrammar.json rename to rust.tmGrammar.json diff --git a/src/extension.ts b/src/extension.ts index 51b45081..1b1b70ac 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,12 +11,13 @@ import { workspace, WorkspaceFolder, WorkspaceFoldersChangeEvent, + Memento, } from 'vscode'; import * as lc from 'vscode-languageclient'; import { RLSConfiguration } from './configuration'; import * as rls from './rls'; -import * as rustAnalyzer from './rustAnalyzer'; +import * as rustAnalyzer from './rust-analyzer'; import { rustupUpdate } from './rustup'; import { startSpinner, stopSpinner } from './spinner'; import { activateTaskProvider, Execution, runRlsCommand } from './tasks'; @@ -32,17 +33,21 @@ export interface Api { } export async function activate(context: ExtensionContext): Promise { + // Weave in global state when handling changed active text editor + const handleChangedActiveTextEd = (ed: TextEditor | undefined) => + onDidChangeActiveTextEditor(ed, context.globalState); + context.subscriptions.push( ...[ configureLanguage(), ...registerCommands(), workspace.onDidChangeWorkspaceFolders(whenChangingWorkspaceFolders), - window.onDidChangeActiveTextEditor(onDidChangeActiveTextEditor), + window.onDidChangeActiveTextEditor(handleChangedActiveTextEd), ], ); // Manually trigger the first event to start up server instance if necessary, // since VSCode doesn't do that on startup by itself. - onDidChangeActiveTextEditor(window.activeTextEditor); + handleChangedActiveTextEd(window.activeTextEditor); // Migrate the users of multi-project setup for RLS to disable the setting // entirely (it's always on now) @@ -81,13 +86,16 @@ export async function deactivate() { /** Tracks dynamically updated progress for the active client workspace for UI purposes. */ let progressObserver: Disposable | undefined; -function onDidChangeActiveTextEditor(editor: TextEditor | undefined) { +function onDidChangeActiveTextEditor( + editor: TextEditor | undefined, + globalState: Memento, +) { if (!editor || !editor.document) { return; } const { languageId, uri } = editor.document; - const workspace = clientWorkspaceForUri(uri, { + const workspace = clientWorkspaceForUri(uri, globalState, { initializeIfMissing: languageId === 'rust' || languageId === 'toml', }); if (!workspace) { @@ -135,6 +143,7 @@ const workspaces: Map = new Map(); */ function clientWorkspaceForUri( uri: Uri, + globalState: Memento, options?: { initializeIfMissing: boolean }, ): ClientWorkspace | undefined { const rootFolder = workspace.getWorkspaceFolder(uri); @@ -149,7 +158,7 @@ function clientWorkspaceForUri( const existing = workspaces.get(folder.uri.toString()); if (!existing && options && options.initializeIfMissing) { - const workspace = new ClientWorkspace(folder); + const workspace = new ClientWorkspace(folder, globalState); workspaces.set(folder.uri.toString(), workspace); workspace.autoStart(); } @@ -173,15 +182,17 @@ export class ClientWorkspace { private lc: lc.LanguageClient | null = null; private disposables: Disposable[]; private _progress: Observable; + private globalState: Memento; get progress() { return this._progress; } - constructor(folder: WorkspaceFolder) { + constructor(folder: WorkspaceFolder, globalState: Memento) { this.config = RLSConfiguration.loadFromWorkspace(folder.uri.fsPath); this.folder = folder; this.disposables = []; this._progress = new Observable({ state: 'standby' }); + this.globalState = globalState; } /** @@ -198,18 +209,22 @@ export class ClientWorkspace { const { createLanguageClient, setupClient, setupProgress } = this.config.engine === 'rls' ? rls : rustAnalyzer; - const client = await createLanguageClient(this.folder, { - updateOnStartup: this.config.updateOnStartup, - revealOutputChannelOn: this.config.revealOutputChannelOn, - logToFile: this.config.logToFile, - rustup: { - channel: this.config.channel, - path: this.config.rustupPath, - disabled: this.config.rustupDisabled, + const client = await createLanguageClient( + this.folder, + { + updateOnStartup: this.config.updateOnStartup, + revealOutputChannelOn: this.config.revealOutputChannelOn, + logToFile: this.config.logToFile, + rustup: { + channel: this.config.channel, + path: this.config.rustupPath, + disabled: this.config.rustupDisabled, + }, + rls: { path: this.config.rlsPath }, + rustAnalyzer: this.config.rustAnalyzer, }, - rls: { path: this.config.rlsPath }, - rustAnalyzer: this.config.rustAnalyzer, - }); + this.globalState, + ); client.onDidChangeState(({ newState }) => { if (newState === lc.State.Starting) { diff --git a/src/net.ts b/src/net.ts index 26cd05fb..939683c6 100644 --- a/src/net.ts +++ b/src/net.ts @@ -1,9 +1,12 @@ import * as assert from 'assert'; +import * as crypto from 'crypto'; import * as fs from 'fs'; import fetch from 'node-fetch'; +import * as path from 'path'; import * as stream from 'stream'; import * as util from 'util'; import * as vscode from 'vscode'; +import * as zlib from 'zlib'; const pipeline = util.promisify(stream.pipeline); @@ -62,24 +65,34 @@ export interface GithubRelease { }>; } -export async function download( - downloadUrl: string, - destinationPath: string, - progressTitle: string, - { mode }: { mode?: number } = {}, -) { +interface DownloadOpts { + progressTitle: string; + url: string; + dest: string; + mode?: number; + gunzip?: boolean; +} + +export async function download(opts: DownloadOpts) { + // Put artifact into a temporary file (in the same dir for simplicity) + // to prevent partially downloaded files when user kills vscode + const dest = path.parse(opts.dest); + const randomHex = crypto.randomBytes(5).toString('hex'); + const tempFile = path.join(dest.dir, `${dest.name}${randomHex}`); + await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, cancellable: false, - title: progressTitle, + title: opts.progressTitle, }, async (progress, _cancellationToken) => { let lastPercentage = 0; await downloadFile( - downloadUrl, - destinationPath, - mode, + opts.url, + tempFile, + opts.mode, + Boolean(opts.gunzip), (readBytes, totalBytes) => { const newPercentage = (readBytes / totalBytes) * 100; progress.report({ @@ -92,6 +105,8 @@ export async function download( ); }, ); + + return fs.promises.rename(tempFile, opts.dest); } /** @@ -104,6 +119,7 @@ async function downloadFile( url: string, destFilePath: fs.PathLike, mode: number | undefined, + gunzip: boolean, onProgress: (readBytes: number, totalBytes: number) => void, ): Promise { const res = await fetch(url); @@ -136,13 +152,13 @@ async function downloadFile( }); const destFileStream = fs.createWriteStream(destFilePath, { mode }); + const srcStream = gunzip ? res.body.pipe(zlib.createGunzip()) : res.body; - await pipeline(res.body, destFileStream); + await pipeline(srcStream, destFileStream); return new Promise(resolve => { destFileStream.on('close', resolve); destFileStream.destroy(); - - // Details on workaround: https://github.com/rust-analyzer/rust-analyzer/pull/3092#discussion_r378191131 - // Issue at nodejs repo: https://github.com/nodejs/node/issues/31776 + // This workaround is awaiting to be removed when vscode moves to newer nodejs version: + // https://github.com/rust-analyzer/rust-analyzer/issues/3167 }); } diff --git a/src/rust-analyzer/index.ts b/src/rust-analyzer/index.ts new file mode 100644 index 00000000..c45c8e84 --- /dev/null +++ b/src/rust-analyzer/index.ts @@ -0,0 +1,6 @@ +export { + createLanguageClient, + getServer, + setupClient, + setupProgress, +} from './rustAnalyzer'; diff --git a/src/rust-analyzer/persistent_state.ts b/src/rust-analyzer/persistent_state.ts new file mode 100644 index 00000000..37c3ff06 --- /dev/null +++ b/src/rust-analyzer/persistent_state.ts @@ -0,0 +1,37 @@ +import * as vscode from 'vscode'; + +export interface Release { + /** + * ID of a release. Used to disambiguate between different releases under *moving* tags. + */ + id: number; + tag: string; +} + +export class PersistentState { + constructor(private readonly globalState: vscode.Memento) { + const { lastCheck, installedRelease } = this; + console.info('PersistentState:', { lastCheck, installedRelease }); + } + + /** + * Used to check for *nightly* updates once an hour. + */ + get lastCheck(): number | undefined { + return this.globalState.get('lastCheck'); + } + async updateLastCheck(value: number) { + await this.globalState.update('lastCheck', value); + } + + /** + * Release tag of the installed server. + * Used to check if we need to update the server. + */ + get installedRelease(): Release | undefined { + return this.globalState.get('installedRelease'); + } + async updateInstalledRelease(value: Release | undefined) { + return this.globalState.update('installedRelease', value); + } +} diff --git a/src/rustAnalyzer.ts b/src/rust-analyzer/rustAnalyzer.ts similarity index 67% rename from src/rustAnalyzer.ts rename to src/rust-analyzer/rustAnalyzer.ts index 385986a3..287907c7 100644 --- a/src/rustAnalyzer.ts +++ b/src/rust-analyzer/rustAnalyzer.ts @@ -6,15 +6,14 @@ import { promisify } from 'util'; import * as vs from 'vscode'; import * as lc from 'vscode-languageclient'; -import { WorkspaceProgress } from './extension'; -import { download, fetchRelease } from './net'; -import * as rustup from './rustup'; -import { Observable } from './utils/observable'; +import { WorkspaceProgress } from '../extension'; +import { download, fetchRelease } from '../net'; +import * as rustup from '../rustup'; +import { Observable } from '../utils/observable'; +import { PersistentState } from './persistent_state'; const stat = promisify(fs.stat); const mkdir = promisify(fs.mkdir); -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); const REQUIRED_COMPONENTS = ['rust-src']; @@ -45,31 +44,6 @@ function installDir(): string | undefined { return undefined; } -/** Returns a path where persistent data for rust-analyzer should be installed. */ -function metadataDir(): string | undefined { - if (process.platform === 'linux' || process.platform === 'darwin') { - // Prefer, in this order: - // 1. $XDG_CONFIG_HOME/rust-analyzer - // 2. $HOME/.config/rust-analyzer - const { HOME, XDG_CONFIG_HOME } = process.env; - const baseDir = XDG_CONFIG_HOME || (HOME && path.join(HOME, '.config')); - - return baseDir && path.resolve(path.join(baseDir, 'rust-analyzer')); - } else if (process.platform === 'win32') { - // %LocalAppData%\rust-analyzer\ - const { LocalAppData } = process.env; - return ( - LocalAppData && path.resolve(path.join(LocalAppData, 'rust-analyzer')) - ); - } - - return undefined; -} - -function ensureDir(path: string) { - return !!path && stat(path).catch(() => mkdir(path, { recursive: true })); -} - interface RustAnalyzerConfig { askBeforeDownload?: boolean; package: { @@ -77,44 +51,10 @@ interface RustAnalyzerConfig { }; } -interface Metadata { - releaseTag: string; -} - -async function readMetadata(): Promise> { - const stateDir = metadataDir(); - if (!stateDir) { - return { kind: 'error', code: 'NotSupported' }; - } - - const filePath = path.join(stateDir, 'metadata.json'); - if (!(await stat(filePath).catch(() => false))) { - return { kind: 'error', code: 'FileMissing' }; - } - - const contents = await readFile(filePath, 'utf8'); - const obj = JSON.parse(contents) as unknown; - return typeof obj === 'object' ? (obj as Record) : {}; -} - -async function writeMetadata(config: Metadata) { - const stateDir = metadataDir(); - if (!stateDir) { - return false; - } - - if (!(await ensureDir(stateDir))) { - return false; - } - - const filePath = path.join(stateDir, 'metadata.json'); - return writeFile(filePath, JSON.stringify(config)).then(() => true); -} - -export async function getServer({ - askBeforeDownload, - package: pkg, -}: RustAnalyzerConfig): Promise { +export async function getServer( + config: RustAnalyzerConfig, + state: PersistentState, +): Promise { let binaryName: string | undefined; if (process.arch === 'x64' || process.arch === 'ia32') { if (process.platform === 'linux') { @@ -136,35 +76,39 @@ export async function getServer({ 'about that [here](https://github.com/rust-analyzer/rust-analyzer/issues) and we ' + 'will consider it.', ); - return undefined; + return; } const dir = installDir(); if (!dir) { return; + } else { + await stat(dir).catch(() => mkdir(dir, { recursive: true })); } - await ensureDir(dir); - - const metadata: Partial = await readMetadata().catch(() => ({})); const dest = path.join(dir, binaryName); const exists = await stat(dest).catch(() => false); - if (exists && metadata.releaseTag === pkg.releaseTag) { - return dest; + + if (!exists) { + await state.updateInstalledRelease(undefined); } - if (askBeforeDownload) { - const userResponse = await vs.window.showInformationMessage( - `${ - metadata.releaseTag && metadata.releaseTag !== pkg.releaseTag - ? `You seem to have installed release \`${metadata.releaseTag}\` but requested a different one.` - : '' - } - Release \`${pkg.releaseTag}\` of rust-analyzer is not installed.\n - Install to ${dir}?`, - 'Download', - ); - if (userResponse !== 'Download') { + const now = Date.now(); + if (state.installedRelease?.tag === config.package.releaseTag) { + // Release tags that are *moving* - these are expected to point to different + // commits and update as the time goes on. Make sure to poll the GitHub API + // (at most once per hour) to see if we need to update. + const MOVING_TAGS = ['nightly']; + const POLL_INTERVAL = 60 * 60 * 1000; + + const shouldCheckForNewRelease = MOVING_TAGS.includes( + config.package.releaseTag, + ) + ? state.installedRelease === undefined || + now - (state.lastCheck ?? 0) > POLL_INTERVAL + : false; + + if (!shouldCheckForNewRelease) { return dest; } } @@ -172,22 +116,48 @@ export async function getServer({ const release = await fetchRelease( 'rust-analyzer', 'rust-analyzer', - pkg.releaseTag, + config.package.releaseTag, ); + + if (state.installedRelease?.id === release.id) { + return dest; + } + const artifact = release.assets.find(asset => asset.name === binaryName); if (!artifact) { throw new Error(`Bad release: ${JSON.stringify(release)}`); } - await download( - artifact.browser_download_url, + if (config.askBeforeDownload) { + const userResponse = await vs.window.showInformationMessage( + `${ + state.installedRelease && + state.installedRelease.tag !== config.package.releaseTag + ? `You seem to have installed release \`${state.installedRelease?.tag}\` but requested a different one.` + : '' + } + Release \`${config.package.releaseTag}\` of rust-analyzer ${ + !state.installedRelease ? 'is not installed' : 'can be updated' + }.\n + Install to ${dir}?`, + 'Download', + ); + if (userResponse !== 'Download') { + return exists ? dest : undefined; + } + } + + await download({ + url: artifact.browser_download_url, dest, - 'Downloading rust-analyzer server', - { mode: 0o755 }, - ); + progressTitle: 'Downloading rust-analyzer server', + mode: 0o755, + }); - await writeMetadata({ releaseTag: pkg.releaseTag }).catch(() => { - vs.window.showWarningMessage(`Couldn't save rust-analyzer metadata`); + await state.updateLastCheck(now); + await state.updateInstalledRelease({ + id: release.id, + tag: config.package.releaseTag, }); return dest; @@ -215,17 +185,25 @@ export async function createLanguageClient( rustup: { disabled: boolean; path: string; channel: string }; rustAnalyzer: { path?: string; releaseTag: string }; }, + state: vs.Memento, ): Promise { if (!config.rustup.disabled) { await rustup.ensureToolchain(config.rustup); await rustup.ensureComponents(config.rustup, REQUIRED_COMPONENTS); } - if (!config.rustAnalyzer.path) { - await getServer({ - askBeforeDownload: true, - package: { releaseTag: config.rustAnalyzer.releaseTag }, - }); + const binPath = + config.rustAnalyzer.path || + (await getServer( + { + askBeforeDownload: true, + package: { releaseTag: config.rustAnalyzer.releaseTag }, + }, + new PersistentState(state), + )); + + if (!binPath) { + throw new Error("Couldn't fetch Rust Analyzer binary"); } if (INSTANCE) { @@ -233,16 +211,6 @@ export async function createLanguageClient( } const serverOptions: lc.ServerOptions = async () => { - const binPath = - config.rustAnalyzer.path || - (await getServer({ - package: { releaseTag: config.rustAnalyzer.releaseTag }, - })); - - if (!binPath) { - throw new Error("Couldn't fetch Rust Analyzer binary"); - } - const childProcess = child_process.exec(binPath); if (config.logToFile) { const logPath = path.join(folder.uri.fsPath, `ra-${Date.now()}.log`);