From 0f59d1b5f0dcf91996151e163c725547d4bbac8e Mon Sep 17 00:00:00 2001 From: lalten Date: Fri, 11 Jul 2025 01:02:01 +0200 Subject: [PATCH] Enable automatic downloading of buildifier release binaries --- package.json | 2 +- src/buildifier/buildifier.ts | 48 ++++----- src/buildifier/buildifier_availability.ts | 100 +++++++++++------- src/buildifier/buildifier_downloader.ts | 119 ++++++++++++++++++++++ src/extension/extension.ts | 11 ++ tsconfig.json | 1 + 6 files changed, 216 insertions(+), 65 deletions(-) create mode 100644 src/buildifier/buildifier_downloader.ts diff --git a/package.json b/package.json index 20ce771d..70b002f4 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "bazel.buildifierExecutable": { "type": "string", "default": "", - "markdownDescription": "The name of the Buildifier executable. This may be an absolute path, or a simple name that will be searched for on the system path. Paths starting with `@` are interpreted as Bazel targets and are run via `bazel run`. If empty, \"buildifier\" on the system path will be used.\n\nBuildifier can be downloaded from https://github.com/bazelbuild/buildtools/releases.", + "markdownDescription": "The name of the Buildifier executable. This may be an absolute path, or a simple name that will be searched for on the system path. Paths starting with `@` are interpreted as Bazel targets and are run via `bazel run`. If the value matches a Buildifier release (e.g. `v8.2.1`) this will be downloaded (for the current platform) and used. If empty, \"buildifier\" on the system path will be used.\n\nBuildifier can be downloaded from https://github.com/bazelbuild/buildtools/releases.", "scope": "machine-overridable" }, "bazel.buildifierConfigJsonPath": { diff --git a/src/buildifier/buildifier.ts b/src/buildifier/buildifier.ts index 882efa6a..c3a86435 100644 --- a/src/buildifier/buildifier.ts +++ b/src/buildifier/buildifier.ts @@ -18,7 +18,7 @@ import * as util from "util"; import * as vscode from "vscode"; import { IBuildifierResult, IBuildifierWarning } from "./buildifier_result"; -import { getDefaultBazelExecutablePath } from "../extension/configuration"; +import { extensionContext } from "../extension/extension"; const execFile = util.promisify(child_process.execFile); type PromiseExecFileException = child_process.ExecFileException & { @@ -164,25 +164,6 @@ export function getBuildifierFileType(fsPath: string): BuildifierFileType { return "default"; } -/** - * Gets the path to the buildifier executable specified by the workspace - * configuration, if present. - * - * @returns The path to the buildifier executable specified in the workspace - * configuration, or just "buildifier" if not present (in which case the - * system path will be searched). - */ -export function getDefaultBuildifierExecutablePath(): string { - // Try to retrieve the executable from VS Code's settings. If it's not set, - // just use "buildifier" as the default and get it from the system PATH. - const bazelConfig = vscode.workspace.getConfiguration("bazel"); - const buildifierExecutable = bazelConfig.get("buildifierExecutable"); - if (buildifierExecutable.length === 0) { - return "buildifier"; - } - return buildifierExecutable; -} - /** * Gets the path to the buildifier json configuration file specified by the * workspace configuration, if present. @@ -195,6 +176,15 @@ export function getDefaultBuildifierJsonConfigPath(): string { return bazelConfig.get("buildifierConfigJsonPath", ""); } +/** A description of an executable and the arguments to pass to it. */ +export interface IExecutable { + /** The path to the executable. */ + path: string; + + /** The arguments that should be passed to the executable. */ + args: string[]; +} + /** * Executes buildifier with the given file content and arguments. * @@ -210,16 +200,18 @@ export async function executeBuildifier( acceptNonSevereErrors: boolean, ): Promise<{ stdout: string; stderr: string }> { // Determine the executable - let executable = getDefaultBuildifierExecutablePath(); + const state = extensionContext.workspaceState.get( + "buildifierExecutable", + ); + if (state !== undefined) { + return Promise.reject("No buildifier executable set."); + } + const { path: executable, args: execArgs } = state; + args = execArgs.concat(args); + const buildifierConfigJsonPath = getDefaultBuildifierJsonConfigPath(); if (buildifierConfigJsonPath.length !== 0) { - args = ["--config", buildifierConfigJsonPath, ...args]; - } - // Paths starting with an `@` are referring to Bazel targets - if (executable.startsWith("@")) { - const targetName = executable; - executable = getDefaultBazelExecutablePath(); - args = ["run", targetName, "--", ...args]; + args.push("--config", buildifierConfigJsonPath); } const execOptions = { maxBuffer: Number.MAX_SAFE_INTEGER, diff --git a/src/buildifier/buildifier_availability.ts b/src/buildifier/buildifier_availability.ts index adbcaacb..798a0f77 100644 --- a/src/buildifier/buildifier_availability.ts +++ b/src/buildifier/buildifier_availability.ts @@ -12,29 +12,77 @@ // See the License for the specific language governing permissions and // limitations under the License. -import * as fs from "fs/promises"; -import * as path from "path"; +import * as fs from "fs"; import * as vscode from "vscode"; import * as which from "which"; +import { executeBuildifier, IExecutable } from "./buildifier"; +import { getDefaultBazelExecutablePath } from "../extension/configuration"; import { - executeBuildifier, - getDefaultBuildifierExecutablePath, -} from "./buildifier"; - -async function fileExists(filename: string) { - try { - await fs.stat(filename); - return true; - } catch { - return false; - } -} + downloadBuildifier, + getBuildifierExecutablePath, +} from "./buildifier_downloader"; +import { extensionContext } from "../extension/extension"; /** The URL to load for buildifier's releases. */ const BUILDTOOLS_RELEASES_URL = "https://github.com/bazelbuild/buildtools/releases"; +/** + * Gets the buildifier configuration. + * + * @returns The buildifier configuration. + */ +export function getBuildifierConfiguration(): string { + const bazelConfig = vscode.workspace.getConfiguration("bazel"); + return bazelConfig.get("buildifierExecutable", "buildifier"); +} + +/** + * Returns the path to the buildifier executable and arguments to use. + * + * This is the central point for resolving the buildifier executable. It may + * involve downloading a buildifier release. + * + * @returns The path to the buildifier executable and arguments to use. + */ +async function getBuildifierExecutable(): Promise { + const configValue = getBuildifierConfiguration(); + // Bazel target + if (configValue.startsWith("@")) { + return { + path: getDefaultBazelExecutablePath(), + args: ["run", configValue, "--"], + }; + } + // Absolute path + if (fs.existsSync(configValue)) { + return { path: configValue, args: [] }; + } + // File on $PATH + try { + return { path: await which(configValue), args: [] }; + } catch (e) { + // nothing on PATH + } + // Release binary + try { + const dst = getBuildifierExecutablePath(configValue); + if (fs.existsSync(dst.fsPath)) { + return { path: dst.fsPath, args: [] }; + } + return { + path: (await downloadBuildifier(configValue)).fsPath, + args: [], + }; + } catch (e) { + // Can't download a release with that version + } + await showBuildifierDownloadPrompt( + `Did not find a buildifier for "${configValue}"`, + ); +} + /** * Checks whether buildifier is available (either at the system PATH or a * user-specified path, depending on the value in Settings). @@ -43,28 +91,8 @@ const BUILDTOOLS_RELEASES_URL = * Download button that they can use to go to the GitHub releases page. */ export async function checkBuildifierIsAvailable() { - const buildifierExecutable = getDefaultBuildifierExecutablePath(); - - // Check if the program exists (in case it's an actual executable and not - // an target name starting with `@`). - const isTarget = buildifierExecutable.startsWith("@"); - - // Check if the program exists as a relative path of the workspace - const pathExists = await fileExists( - path.join( - vscode.workspace.workspaceFolders?.[0]?.uri?.fsPath, - buildifierExecutable, - ), - ); - - if (!isTarget && !pathExists) { - try { - await which(buildifierExecutable); - } catch (e) { - await showBuildifierDownloadPrompt("Buildifier was not found"); - return; - } - } + const state = await getBuildifierExecutable(); + await extensionContext.workspaceState.update("buildifierExecutable", state); // Make sure it's a compatible version by running // buildifier on an empty input and see if it exits successfully and the diff --git a/src/buildifier/buildifier_downloader.ts b/src/buildifier/buildifier_downloader.ts new file mode 100644 index 00000000..48c6fc3b --- /dev/null +++ b/src/buildifier/buildifier_downloader.ts @@ -0,0 +1,119 @@ +// Copyright 2025 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as fs from "fs"; +import * as os from "os"; +import { pipeline } from "stream/promises"; +import * as vscode from "vscode"; +import { extensionContext } from "../extension/extension"; + +/** + * Returns the expected name of the buildifier executable for the current + * platform. + * @returns The name of the buildifier executable. + */ +function getBuildifierExecutableName(): string { + const platform = os.platform(); + const arch = os.arch(); + + let name: string; + switch (platform) { + case "darwin": + name = + arch === "arm64" + ? "buildifier-darwin-arm64" + : "buildifier-darwin-amd64"; + break; + case "linux": + name = + arch === "arm64" ? "buildifier-linux-arm64" : "buildifier-linux-amd64"; + break; + case "win32": + name = "buildifier-windows-amd64.exe"; + break; + default: + throw new Error(`Unsupported platform: ${platform}`); + } + return name; +} + +function getBuildifierExecutableDir(version: string): vscode.Uri { + return vscode.Uri.joinPath( + extensionContext.globalStorageUri, + "buildifier", + version, + ); +} + +export function getBuildifierExecutablePath(version: string): vscode.Uri { + return vscode.Uri.joinPath( + getBuildifierExecutableDir(version), + getBuildifierExecutableName(), + ); +} + +/** + * Downloads the buildifier executable for the current platform and the given + * version. + * @param version The version of buildifier to download. + * @returns The path to the downloaded executable. + */ +export async function downloadBuildifier(version: string): Promise { + const exe = getBuildifierExecutableName(); + const url = `https://github.com/bazelbuild/buildtools/releases/download/${version}/${exe}`; + const dir = getBuildifierExecutableDir(version); + // example: '/Users/laurenz/Library/Application Support/Code/User/globalStorage/bazelbuild.vscode-bazel/buildifier/v8.2.1/buildifier-darwin-arm64' + const dst = getBuildifierExecutablePath(version); + + if (!fs.existsSync(dir.fsPath)) { + fs.mkdirSync(dir.fsPath, { recursive: true }); + } + + // Show progress notification while downloading the buildifier executable + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Downloading buildifier ${version}...`, + cancellable: false, + }, + async () => { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download buildifier from ${url}: ${response.statusText}`, + ); + } + if (!response.body) { + throw new Error(`Response body of ${url} is empty.`); + } + + const fileStream = fs.createWriteStream(dst.fsPath); + await pipeline(response.body as any, fileStream); + + makeExecutable(dst); + }, + ); + + return dst; +} + +/** + * Makes the given file executable. + * @param uri The URI of the file to make executable. + */ +function makeExecutable(uri: vscode.Uri) { + if (os.platform() !== "win32") { + fs.chmodSync(uri.fsPath, 0o755); + } +} diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 55cb7eea..9b911d01 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -33,6 +33,8 @@ import { activateCommandVariables } from "./command_variables"; import { activateTesting } from "../test-explorer"; import { activateWrapperCommands } from "./bazel_wrapper_commands"; +export let extensionContext: vscode.ExtensionContext; + /** * Called when the extension is activated; that is, when its first command is * executed. @@ -40,6 +42,8 @@ import { activateWrapperCommands } from "./bazel_wrapper_commands"; * @param context The extension context. */ export async function activate(context: vscode.ExtensionContext) { + extensionContext = context; + const workspaceTreeProvider = BazelWorkspaceTreeProvider.fromExtensionContext(context); context.subscriptions.push(workspaceTreeProvider); @@ -157,6 +161,13 @@ export async function activate(context: vscode.ExtensionContext) { ...activateCommandVariables(), // Test provider ...activateTesting(), + + // Listen for configuration changes that affect buildifier + vscode.workspace.onDidChangeConfiguration(async (e) => { + if (e.affectsConfiguration("bazel.buildifierExecutable")) { + await checkBuildifierIsAvailable(); + } + }), ); // Notify the user if buildifier is not available on their path (or where diff --git a/tsconfig.json b/tsconfig.json index b1acc4e9..033973e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "outDir": "out", "lib": [ "es2022", + "dom" ], "sourceMap": true, "rootDir": ".",