Skip to content

Commit 8f6daef

Browse files
authored
Add bazelisk completion bash/fish command (#706)
* Add `bazelisk completion bash/fish` command This command prints shell completion scripts for the active Bazel version. The completion scripts are downloaded on-demand and cached to Bazelisk CAS cache. This is to support generating completion scripts for older Bazel versions. * Add completion script installation instructions
1 parent cfa90e9 commit 8f6daef

File tree

4 files changed

+843
-0
lines changed

4 files changed

+843
-0
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,48 @@ bazelisk --bisect=~6.0.0..HEAD test //foo:bar_test
159159

160160
Note that, Bazelisk uses prebuilt Bazel binaries at commits on the main and release branches, therefore you cannot bisect your local commits.
161161

162+
### Command-line completion
163+
164+
Bazelisk offers a new `completion` command that allows you to generate
165+
command-line completion scripts for Bazel commands.
166+
167+
#### bash
168+
169+
You can enable Bash completion by either:
170+
171+
1. Source the generated completion script while inside a Bazel workspace:
172+
173+
```shell
174+
source <(bazelisk completion bash)
175+
```
176+
177+
2. Or emit the completion script into a file:
178+
179+
```shell
180+
bazelisk completion bash > bash-complete.bash
181+
```
182+
183+
then copy this file to `/etc/bash_completion.d` (on Ubuntu) or source it in your
184+
`~/.bashrc` (on Ubuntu) or `~/.bash_profile` (on macOS).
185+
186+
```shell
187+
source /path/to/bazel-complete.bash
188+
```
189+
190+
#### fish
191+
192+
Generate a completion script and save it into your fish completion directory:
193+
194+
```shell
195+
bazelisk completion fish > ~/.config/fish/completions/gh.fish
196+
```
197+
198+
Note that the generated completion script is tied to the active Bazel version.
199+
200+
The bazel completion scripts are taken from installer binaries. If you use a
201+
custom base URL, make sure the installer URLs are available alongside with
202+
bazel binaries.
203+
162204
### Useful environment variables for --migrate and --bisect
163205

164206
You can set `BAZELISK_INCOMPATIBLE_FLAGS` to set a list of incompatible flags (separated by `,`) to be tested, otherwise Bazelisk tests all flags starting with `--incompatible_`.

core/core.go

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ package core
44
// TODO: split this file into multiple smaller ones in dedicated packages (e.g. execution, incompatible, ...).
55

66
import (
7+
"archive/zip"
78
"bufio"
9+
"bytes"
810
"context"
911
"crypto/rand"
1012
"crypto/sha256"
@@ -161,6 +163,15 @@ func RunBazeliskWithArgsFuncAndConfigAndOut(argsFunc ArgsFunc, repos *Repositori
161163
}
162164
}
163165

166+
// handle completion command
167+
if isCompletionCommand(args) {
168+
err := handleCompletionCommand(args, bazelInstallation, config)
169+
if err != nil {
170+
return -1, fmt.Errorf("could not handle completion command: %v", err)
171+
}
172+
return 0, nil
173+
}
174+
164175
exitCode, err := runBazel(bazelInstallation.Path, args, out, config)
165176
if err != nil {
166177
return -1, fmt.Errorf("could not run Bazel: %v", err)
@@ -1108,3 +1119,285 @@ func dirForURL(url string) string {
11081119
}
11091120
return dir
11101121
}
1122+
1123+
func isCompletionCommand(args []string) bool {
1124+
for _, arg := range args {
1125+
if arg == "completion" {
1126+
return true
1127+
} else if !strings.HasPrefix(arg, "--") {
1128+
return false // First non-flag arg is not "completion"
1129+
}
1130+
}
1131+
return false
1132+
}
1133+
1134+
func handleCompletionCommand(args []string, bazelInstallation *BazelInstallation, config config.Config) error {
1135+
// Look for the shell type after "completion"
1136+
var shell string
1137+
foundCompletion := false
1138+
for _, arg := range args {
1139+
if foundCompletion {
1140+
shell = arg
1141+
break
1142+
}
1143+
if arg == "completion" {
1144+
foundCompletion = true
1145+
}
1146+
}
1147+
1148+
if shell != "bash" && shell != "fish" {
1149+
return fmt.Errorf("only bash and fish completion are supported, got: %s", shell)
1150+
}
1151+
1152+
// Get bazelisk home directory
1153+
bazeliskHome, err := getBazeliskHome(config)
1154+
if err != nil {
1155+
return fmt.Errorf("could not determine bazelisk home: %v", err)
1156+
}
1157+
1158+
// Get the completion script for the current Bazel version
1159+
completionScript, err := getBazelCompletionScript(bazelInstallation.Version, bazeliskHome, shell, config)
1160+
if err != nil {
1161+
return fmt.Errorf("could not get completion script: %v", err)
1162+
}
1163+
1164+
fmt.Print(completionScript)
1165+
return nil
1166+
}
1167+
1168+
func getBazelCompletionScript(version string, bazeliskHome string, shell string, config config.Config) (string, error) {
1169+
var completionFilename string
1170+
switch shell {
1171+
case "bash":
1172+
completionFilename = "bazel-complete.bash"
1173+
case "fish":
1174+
completionFilename = "bazel.fish"
1175+
default:
1176+
return "", fmt.Errorf("unsupported shell: %s", shell)
1177+
}
1178+
1179+
// Construct installer URL using the same logic as bazel binary downloads
1180+
baseURL := config.Get(BaseURLEnv)
1181+
formatURL := config.Get(FormatURLEnv)
1182+
1183+
installerURL, err := constructInstallerURL(baseURL, formatURL, version, config)
1184+
if err != nil {
1185+
return "", fmt.Errorf("could not construct installer URL: %v", err)
1186+
}
1187+
1188+
// Download completion scripts if necessary (handles content-based caching internally)
1189+
installerHash, err := downloadCompletionScriptIfNecessary(installerURL, version, bazeliskHome, baseURL, config)
1190+
if err != nil {
1191+
return "", fmt.Errorf("could not download completion script: %v", err)
1192+
}
1193+
1194+
// Read the requested completion script using installer content hash
1195+
casDir := filepath.Join(bazeliskHome, "downloads", "sha256")
1196+
completionDir := filepath.Join(casDir, installerHash, "completion")
1197+
requestedPath := filepath.Join(completionDir, completionFilename)
1198+
cachedContent, err := os.ReadFile(requestedPath)
1199+
if err != nil {
1200+
if shell == "fish" {
1201+
return "", fmt.Errorf("fish completion script not available for Bazel version %s", version)
1202+
}
1203+
return "", fmt.Errorf("could not read cached completion script: %v", err)
1204+
}
1205+
1206+
return string(cachedContent), nil
1207+
}
1208+
1209+
func constructInstallerURL(baseURL, formatURL, version string, config config.Config) (string, error) {
1210+
if baseURL != "" && formatURL != "" {
1211+
return "", fmt.Errorf("cannot set %s and %s at once", BaseURLEnv, FormatURLEnv)
1212+
}
1213+
1214+
if formatURL != "" {
1215+
// Replace %v with version and construct installer-specific format
1216+
installerFormatURL := strings.Replace(formatURL, "bazel-%v", "bazel-%v-installer", 1)
1217+
installerFormatURL = strings.Replace(installerFormatURL, "%e", ".sh", 1)
1218+
return BuildURLFromFormat(config, installerFormatURL, version)
1219+
}
1220+
1221+
if baseURL != "" {
1222+
installerFile, err := platforms.DetermineBazelInstallerFilename(version, config)
1223+
if err != nil {
1224+
return "", err
1225+
}
1226+
return fmt.Sprintf("%s/%s/%s", baseURL, version, installerFile), nil
1227+
}
1228+
1229+
// Default to GitHub
1230+
installerFile, err := platforms.DetermineBazelInstallerFilename(version, config)
1231+
if err != nil {
1232+
return "", err
1233+
}
1234+
return fmt.Sprintf("https://github.com/bazelbuild/bazel/releases/download/%s/%s", version, installerFile), nil
1235+
}
1236+
1237+
func downloadCompletionScriptIfNecessary(installerURL, version, bazeliskHome, baseURL string, config config.Config) (string, error) {
1238+
// Create installer filename for metadata mapping (similar to bazel binary)
1239+
installerFile, err := platforms.DetermineBazelInstallerFilename(version, config)
1240+
if err != nil {
1241+
return "", fmt.Errorf("could not determine installer filename: %v", err)
1242+
}
1243+
1244+
installerForkOrURL := dirForURL(baseURL)
1245+
if len(installerForkOrURL) == 0 {
1246+
installerForkOrURL = "bazelbuild"
1247+
}
1248+
1249+
// Check metadata mapping for installer URL -> content hash
1250+
mappingPath := filepath.Join(bazeliskHome, "downloads", "metadata", installerForkOrURL, installerFile)
1251+
digestFromMappingFile, err := os.ReadFile(mappingPath)
1252+
if err == nil {
1253+
// Check if completion scripts exist for this content hash
1254+
casDir := filepath.Join(bazeliskHome, "downloads", "sha256")
1255+
installerHash := string(digestFromMappingFile)
1256+
completionDir := filepath.Join(casDir, installerHash, "completion")
1257+
bashPath := filepath.Join(completionDir, "bazel-complete.bash")
1258+
1259+
if _, errBash := os.Stat(bashPath); errBash == nil {
1260+
return installerHash, nil // Completion scripts already cached
1261+
}
1262+
}
1263+
1264+
// Download installer and extract completion scripts
1265+
installerHash, err := downloadInstallerToCAS(installerURL, bazeliskHome, config)
1266+
if err != nil {
1267+
return "", fmt.Errorf("failed to download installer: %w", err)
1268+
}
1269+
1270+
// Write metadata mapping
1271+
if err := atomicWriteFile(mappingPath, []byte(installerHash), 0644); err != nil {
1272+
return "", fmt.Errorf("failed to write mapping file: %w", err)
1273+
}
1274+
1275+
return installerHash, nil
1276+
}
1277+
1278+
func downloadInstallerToCAS(installerURL, bazeliskHome string, config config.Config) (string, error) {
1279+
downloadsDir := filepath.Join(bazeliskHome, "downloads")
1280+
temporaryDownloadDir := filepath.Join(downloadsDir, "_tmp")
1281+
casDir := filepath.Join(bazeliskHome, "downloads", "sha256")
1282+
1283+
// Generate temporary file name for installer download
1284+
tmpInstallerBytes := make([]byte, 16)
1285+
if _, err := rand.Read(tmpInstallerBytes); err != nil {
1286+
return "", fmt.Errorf("failed to generate temporary installer file name: %w", err)
1287+
}
1288+
tmpInstallerFile := fmt.Sprintf("%x-installer", tmpInstallerBytes)
1289+
1290+
// Download the installer
1291+
installerPath, err := httputil.DownloadBinary(installerURL, temporaryDownloadDir, tmpInstallerFile, config)
1292+
if err != nil {
1293+
return "", fmt.Errorf("failed to download installer: %w", err)
1294+
}
1295+
defer os.Remove(installerPath)
1296+
1297+
// Read installer content and compute hash
1298+
installerContent, err := os.ReadFile(installerPath)
1299+
if err != nil {
1300+
return "", fmt.Errorf("failed to read installer: %w", err)
1301+
}
1302+
1303+
h := sha256.New()
1304+
h.Write(installerContent)
1305+
installerHash := strings.ToLower(fmt.Sprintf("%x", h.Sum(nil)))
1306+
1307+
// Check if completion scripts already exist for this installer content hash
1308+
completionDir := filepath.Join(casDir, installerHash, "completion")
1309+
bashPath := filepath.Join(completionDir, "bazel-complete.bash")
1310+
1311+
if _, errBash := os.Stat(bashPath); errBash == nil {
1312+
return installerHash, nil // Completion scripts already cached
1313+
}
1314+
1315+
// Extract completion scripts from installer
1316+
completionScripts, err := extractCompletionScriptsFromInstaller(installerContent)
1317+
if err != nil {
1318+
return "", fmt.Errorf("failed to extract completion scripts: %w", err)
1319+
}
1320+
1321+
// Create completion directory in CAS
1322+
if err := os.MkdirAll(completionDir, 0755); err != nil {
1323+
return "", fmt.Errorf("failed to create completion directory: %w", err)
1324+
}
1325+
1326+
// Write completion scripts to CAS using installer content hash
1327+
for filename, content := range completionScripts {
1328+
scriptPath := filepath.Join(completionDir, filename)
1329+
if err := atomicWriteFile(scriptPath, []byte(content), 0644); err != nil {
1330+
return "", fmt.Errorf("failed to write %s: %w", filename, err)
1331+
}
1332+
}
1333+
1334+
return installerHash, nil
1335+
}
1336+
1337+
func extractCompletionScriptsFromInstaller(installerContent []byte) (map[string]string, error) {
1338+
// Extract the zip file from the installer script
1339+
zipData, err := extractZipFromInstaller(installerContent)
1340+
if err != nil {
1341+
return nil, fmt.Errorf("could not extract zip from installer: %w", err)
1342+
}
1343+
1344+
// Extract the completion scripts from the zip file
1345+
completionScripts, err := extractCompletionScriptsFromZip(zipData)
1346+
if err != nil {
1347+
return nil, fmt.Errorf("could not extract completion scripts from zip: %w", err)
1348+
}
1349+
1350+
return completionScripts, nil
1351+
}
1352+
1353+
func extractZipFromInstaller(installerContent []byte) ([]byte, error) {
1354+
// The installer script embeds a PK-formatted zip archive directly after the shell prologue.
1355+
const zipMagic = "PK\x03\x04" // local file header signature
1356+
1357+
idx := bytes.Index(installerContent, []byte(zipMagic))
1358+
if idx == -1 {
1359+
return nil, fmt.Errorf("could not find zip file in installer script")
1360+
}
1361+
1362+
return installerContent[idx:], nil
1363+
}
1364+
1365+
func extractCompletionScriptsFromZip(zipData []byte) (map[string]string, error) {
1366+
// Create a zip reader from the zip data
1367+
zipReader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
1368+
if err != nil {
1369+
return nil, fmt.Errorf("could not create zip reader: %v", err)
1370+
}
1371+
1372+
completionScripts := make(map[string]string)
1373+
targetFiles := map[string]string{
1374+
"bazel-complete.bash": "bazel-complete.bash",
1375+
"bazel.fish": "bazel.fish",
1376+
}
1377+
1378+
// Look for completion files in the zip
1379+
for _, file := range zipReader.File {
1380+
if targetFilename, found := targetFiles[file.Name]; found {
1381+
rc, err := file.Open()
1382+
if err != nil {
1383+
return nil, fmt.Errorf("could not open completion file %s: %v", file.Name, err)
1384+
}
1385+
1386+
completionContent, err := io.ReadAll(rc)
1387+
rc.Close()
1388+
if err != nil {
1389+
return nil, fmt.Errorf("could not read completion file %s: %v", file.Name, err)
1390+
}
1391+
1392+
completionScripts[targetFilename] = string(completionContent)
1393+
}
1394+
}
1395+
1396+
// Check that we found at least the bash completion script
1397+
if _, found := completionScripts["bazel-complete.bash"]; !found {
1398+
return nil, fmt.Errorf("bazel-complete.bash not found in zip file")
1399+
}
1400+
// Fish completion is optional (older Bazel versions might not have it)
1401+
1402+
return completionScripts, nil
1403+
}

0 commit comments

Comments
 (0)