| /* |
| Copyright 2017 Google LLC |
| |
| 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 |
| |
| https://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. |
| */ |
| |
| // The unused_deps binary prints out buildozer commands for removing |
| // unused Java dependencies from java_library Bazel rules. |
| package main |
| |
| import ( |
| "bufio" |
| "bytes" |
| "errors" |
| "flag" |
| "fmt" |
| "log" |
| "os" |
| "os/exec" |
| "path" |
| "strings" |
| |
| "github.com/bazelbuild/buildtools/build" |
| "github.com/bazelbuild/buildtools/config" |
| depspb "github.com/bazelbuild/buildtools/deps_proto" |
| "github.com/bazelbuild/buildtools/edit" |
| eapb "github.com/bazelbuild/buildtools/extra_actions_base_proto" |
| "github.com/bazelbuild/buildtools/labels" |
| "github.com/golang/protobuf/proto" |
| ) |
| |
| var ( |
| buildVersion = "redacted" |
| buildScmRevision = "redacted" |
| |
| version = flag.Bool("version", false, "Print the version of unused_deps") |
| cQuery = flag.Bool("cquery", false, "Use 'cquery' command instead of 'query'") |
| buildTool = flag.String("build_tool", config.DefaultBuildTool, config.BuildToolHelp) |
| extraActionFileName = flag.String("extra_action_file", "", config.ExtraActionFileNameHelp) |
| outputFileName = flag.String("output_file", "", "used only with extra_action_file") |
| buildOptions = stringList("extra_build_flags", "Extra build flags to use when building the targets.") |
| |
| blazeFlags = []string{"--tool_tag=unused_deps", "--keep_going", "--color=yes", "--curses=yes"} |
| |
| aspect = ` |
| # Explicitly creates a params file for a Javac action. |
| def _javac_params(target, ctx): |
| params = [] |
| for action in target.actions: |
| if not action.mnemonic == "Javac" and not action.mnemonic == "KotlinCompile": |
| continue |
| output = ctx.actions.declare_file("%s.javac_params" % target.label.name) |
| args = ctx.actions.args() |
| args.add_all(action.argv) |
| ctx.actions.write( |
| output = output, |
| content = args, |
| ) |
| params.append(output) |
| break |
| return [OutputGroupInfo(unused_deps_outputs = depset(params))] |
| |
| javac_params = aspect( |
| implementation = _javac_params, |
| ) |
| ` |
| ) |
| |
| func stringList(name, help string) func() []string { |
| f := flag.String(name, "", help) |
| return func() []string { |
| if *f == "" { |
| return nil |
| } |
| res := strings.Split(*f, ",") |
| for i := range res { |
| res[i] = strings.TrimSpace(res[i]) |
| } |
| return res |
| } |
| } |
| |
| // getJarPath prints the path to the output jar file specified in the extra_action file at path. |
| func getJarPath(path string) (string, error) { |
| data, err := os.ReadFile(path) |
| if err != nil { |
| return "", err |
| } |
| i := &eapb.ExtraActionInfo{} |
| if err := proto.Unmarshal(data, i); err != nil { |
| return "", err |
| } |
| ext, err := proto.GetExtension(i, eapb.E_JavaCompileInfo_JavaCompileInfo) |
| if err != nil { |
| return "", err |
| } |
| jci, ok := ext.(*eapb.JavaCompileInfo) |
| if !ok { |
| return "", errors.New("no JavaCompileInfo in " + path) |
| } |
| return jci.GetOutputjar(), nil |
| } |
| |
| // writeUnusedDeps writes the labels of unused direct deps, one per line, to outputFileName. |
| func writeUnusedDeps(jarPath, outputFileName string) { |
| depsPath := strings.Replace(jarPath, ".jar", ".jdeps", 1) |
| paramsPath := jarPath + "-2.params" |
| file, _ := os.Create(outputFileName) |
| for dep := range unusedDeps(depsPath, directDepParams(paramsPath)) { |
| file.WriteString(dep + "\n") |
| } |
| } |
| |
| func cmdWithStderr(name string, arg ...string) *exec.Cmd { |
| cmd := exec.Command(name, arg...) |
| cmd.Stderr = os.Stderr |
| return cmd |
| } |
| |
| // blazeInfo retrieves the blaze info value for a given key. |
| func blazeInfo(key string) (value string) { |
| out, err := cmdWithStderr(*buildTool, "info", key).Output() |
| if err != nil { |
| log.Printf("'%s info %s' failed: %s", *buildTool, key, err) |
| } |
| return strings.TrimSpace(bytes.NewBuffer(out).String()) |
| } |
| |
| // inputFileName returns a blaze output file name from which to read input. |
| func inputFileName(blazeBin, pkg, ruleName, extension string) string { |
| name := fmt.Sprintf("%s/%s/lib%s.%s", blazeBin, pkg, ruleName, extension) // *_library |
| if _, err := os.Stat(name); err == nil { |
| return name |
| } |
| // lazily let the caller handle it if this doesn't exist |
| return fmt.Sprintf("%s/%s/%s.%s", blazeBin, pkg, ruleName, extension) // *_{binary,test} |
| } |
| |
| // directDepParams reads the jar-2.params files, looking for a |
| // "--direct_dependencies" argument. When found, the direct dependencies are |
| // returned as a map from jar file names to labels. |
| func directDepParams(blazeOutputPath string, paramsFileNames ...string) (depsByJar map[string]string) { |
| depsByJar = make(map[string]string) |
| errs := make([]error, 0) |
| for _, paramsFileName := range paramsFileNames { |
| data, err := os.ReadFile(paramsFileName) |
| if err != nil { |
| errs = append(errs, err) |
| continue |
| } |
| // The classpath param exceeds MaxScanTokenSize, so we scan just the |
| // dependencies section. |
| directDepsFlag := []byte("--direct_dependencies") |
| arg := bytes.Index(data, directDepsFlag) |
| if arg < 0 { |
| continue |
| } |
| first := arg + len(directDepsFlag) + 1 |
| |
| scanner := bufio.NewScanner(bytes.NewReader(data[first:])) |
| for scanner.Scan() { |
| jar := scanner.Text() |
| if strings.HasPrefix(jar, "--") { |
| break |
| } |
| label, err := jarManifestValue(blazeOutputPath+strings.TrimPrefix(jar, "bazel-out"), "Target-Label") |
| if err != nil { |
| continue |
| } |
| if strings.HasPrefix(label, "@@") || strings.HasPrefix(label, "@/") { |
| label = label[1:] |
| } |
| depsByJar[jar] = label |
| } |
| if err := scanner.Err(); err != nil { |
| log.Printf("reading %s: %s", paramsFileName, err) |
| } |
| } |
| if len(errs) == len(paramsFileNames) { |
| for _, err := range errs { |
| log.Println(err) |
| } |
| } |
| return depsByJar |
| } |
| |
| // unusedDeps returns a set of labels that are unused deps. |
| // It reads Dependencies proto messages from depsFileName (a jdeps file), which indicate deps used |
| // at compile time, and returns those values in the depsByJar map that aren't used at compile time. |
| func unusedDeps(depsFileName string, depsByJar map[string]string) (unusedDeps map[string]bool) { |
| unusedDeps = make(map[string]bool) |
| data, err := os.ReadFile(depsFileName) |
| if err != nil { |
| log.Println(err) |
| return unusedDeps |
| } |
| dependencies := &depspb.Dependencies{} |
| if err := proto.Unmarshal(data, dependencies); err != nil { |
| log.Println(err) |
| return unusedDeps |
| } |
| for _, label := range depsByJar { |
| unusedDeps[label] = true |
| } |
| for _, dependency := range dependencies.Dependency { |
| if *dependency.Kind == depspb.Dependency_EXPLICIT { |
| delete(unusedDeps, depsByJar[*dependency.Path]) |
| } |
| } |
| return unusedDeps |
| } |
| |
| // parseBuildFile tries to read and parse the contents of buildFileName. |
| func parseBuildFile(buildFileName string) (buildFile *build.File, err error) { |
| data, err := os.ReadFile(buildFileName) |
| if err != nil { |
| return nil, err |
| } |
| return build.Parse(buildFileName, data) |
| } |
| |
| // getDepsExpr tries to parse the content of buildFileName and return the deps Expr for ruleName. |
| func getDepsExpr(buildFileName string, ruleName string) build.Expr { |
| buildFile, err := parseBuildFile(buildFileName) |
| if buildFile == nil { |
| log.Printf("%s when parsing %s", err, buildFileName) |
| return nil |
| } |
| rule := edit.FindRuleByName(buildFile, ruleName) |
| if rule == nil { |
| log.Printf("%s not found in %s", ruleName, buildFileName) |
| return nil |
| } |
| depsExpr := rule.Attr("deps") |
| if depsExpr == nil { |
| log.Printf("no deps attribute for %s in %s", ruleName, buildFileName) |
| } |
| return depsExpr |
| } |
| |
| // hasRuntimeComment returns true if expr has an EOL comment containing the word "runtime". |
| // TODO(bazel-team): delete when this comment convention is extinct |
| func hasRuntimeComment(expr build.Expr) bool { |
| for _, comment := range expr.Comment().Suffix { |
| if strings.Contains(strings.ToLower(comment.Token), "runtime") { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // printCommands prints, for each key in the deps map, a buildozer command |
| // to remove that entry from the deps attribute of the rule identified by label. |
| // Returns true if at least one command was printed, or false otherwise. |
| func printCommands(label string, deps map[string]bool) (anyCommandPrinted bool) { |
| buildFileName, repo, pkg, ruleName := edit.InterpretLabelWithRepo(label) |
| if repo != "" { |
| outputBase := blazeInfo(config.DefaultOutputBase) |
| buildFileName = fmt.Sprintf("%s/external/%s/%s", outputBase, repo, buildFileName) |
| } |
| |
| depsExpr := getDepsExpr(buildFileName, ruleName) |
| for _, li := range edit.AllLists(depsExpr) { |
| for _, elem := range li.List { |
| for dep := range deps { |
| str, ok := elem.(*build.StringExpr) |
| if !ok { |
| continue |
| } |
| buildLabel := str.Value |
| if repo != "" && buildLabel[:2] == "//" { |
| buildLabel = fmt.Sprintf("@%s%s", repo, str.Value) |
| } |
| if !labels.Equal(buildLabel, dep, pkg) { |
| continue |
| } |
| if hasRuntimeComment(str) { |
| fmt.Printf("buildozer 'move deps runtime_deps %s' %s\n", str.Value, label) |
| } else { |
| // add dep's exported dependencies to label before removing dep |
| fmt.Printf("buildozer \"add deps $(%s query 'labels(exports, %s)' | tr '\\n' ' ')\" %s\n", *buildTool, str.Value, label) |
| fmt.Printf("buildozer 'remove deps %s' %s\n", str.Value, label) |
| } |
| anyCommandPrinted = true |
| } |
| } |
| } |
| return anyCommandPrinted |
| } |
| |
| // setupAspect creates a workspace in a tmpdir and populates it with an aspect, |
| // which is used with --override_repository below. |
| func setupAspect() (string, error) { |
| tmp, err := os.MkdirTemp(os.TempDir(), "unused_deps") |
| if err != nil { |
| return "", err |
| } |
| for _, f := range []string{"WORKSPACE", "BUILD"} { |
| if err := os.WriteFile(path.Join(tmp, f), []byte{}, 0666); err != nil { |
| return "", err |
| } |
| } |
| if err := os.WriteFile(path.Join(tmp, "unused_deps.bzl"), []byte(aspect), 0666); err != nil { |
| return "", err |
| } |
| return tmp, nil |
| } |
| |
| func usage() { |
| fmt.Fprintf(os.Stderr, `usage: unused_deps TARGET... |
| |
| For Java rules in TARGETs, prints commands to delete deps unused at compile time. |
| Note these may be used at run time; see documentation for more information. |
| `) |
| os.Exit(2) |
| } |
| |
| func main() { |
| flag.Usage = usage |
| flag.Parse() |
| if *version { |
| fmt.Printf("unused_deps version: %s \n", buildVersion) |
| fmt.Printf("unused_deps scm revision: %s \n", buildScmRevision) |
| os.Exit(0) |
| } |
| |
| if *extraActionFileName != "" { |
| jarPath, err := getJarPath(*extraActionFileName) |
| if err != nil { |
| log.Fatal(err) |
| } |
| writeUnusedDeps(jarPath, *outputFileName) |
| return |
| } |
| targetPatterns := flag.Args() |
| if len(targetPatterns) == 0 { |
| targetPatterns = []string{"//..."} |
| } |
| queryCmd := []string{} |
| if *cQuery { |
| queryCmd = append(queryCmd, "cquery") |
| } else { |
| queryCmd = append(queryCmd, "query") |
| } |
| queryCmd = append(queryCmd, blazeFlags...) |
| queryCmd = append( |
| queryCmd, fmt.Sprintf("kind('(kt|java|android)_*', %s)", strings.Join(targetPatterns, " + "))) |
| |
| log.Printf("running: %s %s", *buildTool, strings.Join(queryCmd, " ")) |
| queryOut, err := cmdWithStderr(*buildTool, queryCmd...).Output() |
| if err != nil { |
| log.Print(err) |
| } |
| if len(queryOut) == 0 { |
| fmt.Fprintln(os.Stderr, "found no targets of kind (kt|java|android)_*") |
| usage() |
| } |
| |
| aspectDir, err := setupAspect() |
| if err != nil { |
| log.Print(err) |
| os.Exit(1) |
| } |
| defer func() { |
| os.RemoveAll(aspectDir) |
| }() |
| |
| buildCmd := []string{"build"} |
| buildCmd = append(buildCmd, blazeFlags...) |
| buildCmd = append(buildCmd, config.DefaultExtraBuildFlags...) |
| buildCmd = append(buildCmd, "--output_groups=+unused_deps_outputs") |
| buildCmd = append(buildCmd, "--override_repository=unused_deps="+aspectDir) |
| buildCmd = append(buildCmd, "--aspects=@@unused_deps//:unused_deps.bzl%javac_params") |
| buildCmd = append(buildCmd, buildOptions()...) |
| |
| blazeArgs := append(buildCmd, targetPatterns...) |
| |
| log.Printf("running: %s %s", *buildTool, strings.Join(blazeArgs, " ")) |
| cmdWithStderr(*buildTool, blazeArgs...).Run() |
| binDir := blazeInfo(config.DefaultBinDir) |
| blazeOutputPath := blazeInfo(config.DefaultOutputPath) |
| fmt.Fprintf(os.Stderr, "\n") // vertical space between build output and unused_deps output |
| |
| anyCommandPrinted := false |
| for _, label := range strings.Fields(string(queryOut)) { |
| if *cQuery && strings.HasPrefix(label, "(") { |
| // cquery output includes the target's configuration ID. Skip it. |
| // https://docs.bazel.build/versions/main/cquery.html#configurations |
| continue |
| } |
| _, repo, pkg, ruleName := edit.InterpretLabelWithRepo(label) |
| blazeBin := binDir |
| if repo != "" { |
| blazeBin = fmt.Sprintf("%s/external/%s", binDir, repo) |
| } |
| depsByJar := directDepParams(blazeOutputPath, inputFileName(blazeBin, pkg, ruleName, "javac_params")) |
| depsToRemove := unusedDeps(inputFileName(blazeBin, pkg, ruleName, "jdeps"), depsByJar) |
| // TODO(bazel-team): instead of printing, have buildifier-like modes? |
| anyCommandPrinted = printCommands(label, depsToRemove) || anyCommandPrinted |
| } |
| if !anyCommandPrinted { |
| fmt.Fprintln(os.Stderr, "No unused deps found.") |
| } |
| } |