@@ -4,7 +4,9 @@ package core
44// TODO: split this file into multiple smaller ones in dedicated packages (e.g. execution, incompatible, ...).
55
66import (
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