blob: bb99ea8bd5d065fe1b65a1e8b20b7a8c75cfe65d [file] [log] [blame]
package analyze
import (
"bytes"
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"github.com/bazelbuild/buildtools/edit"
"github.com/bazelbuild/rules_typescript/ts_auto_deps/workspace"
"github.com/golang/protobuf/proto"
appb "github.com/bazelbuild/buildtools/build_proto"
)
// QueryBasedTargetLoader uses Bazel query to load targets from BUILD files.
type QueryBasedTargetLoader struct {
workdir string
bazelBinary string
}
// NewQueryBasedTargetLoader constructs a new QueryBasedTargetLoader rooted
// in workdir.
func NewQueryBasedTargetLoader(workdir, bazelBinary string) *QueryBasedTargetLoader {
return &QueryBasedTargetLoader{
workdir: workdir,
bazelBinary: bazelBinary,
}
}
// LoadLabels uses Bazel query to load targets associated with labels from BUILD
// files.
func (q *QueryBasedTargetLoader) LoadLabels(labels []string) (map[string]*appb.Rule, error) {
// Ensure the labels are unique to minimize the total number of targets that
// need to be loaded.
r, err := q.batchQuery(dedupeLabels(labels))
if err != nil {
return nil, err
}
labelToRule := make(map[string]*appb.Rule)
for _, target := range r.GetTarget() {
label, err := q.ruleLabel(target)
if err != nil {
return nil, err
}
labelToRule[label] = target.GetRule()
}
return labelToRule, nil
}
// LoadImportPaths uses Bazel Query to load targets associated with import
// paths from BUILD files.
func (q *QueryBasedTargetLoader) LoadImportPaths(ctx context.Context, workspaceRoot string, paths []string) (map[string]*appb.Rule, error) {
var remainingImportPaths []string
results := make(map[string]*appb.Rule)
for _, path := range paths {
if trim := strings.TrimPrefix(path, workspace.Name()+"/"); trim != path {
// TODO(jdhamlik): Optimize by grouping the queries into one larger query.
// TODO(jdhamlik): Handle .d.ts and .tsx files.
r, err := q.query(trim + ".ts")
if err != nil {
return nil, err
}
targets := r.GetTarget()
// Expecting to get one, and only one, target per query.
n := len(targets)
if n < 1 {
return nil, fmt.Errorf("failed to resolved a target for file %q", trim+".ts")
}
if n > 1 {
return nil, fmt.Errorf("got %d targets when only one was expected", n)
}
t, err := q.loadRuleIncludingFile(targets[0].GetSourceFile().GetName())
if err != nil {
return nil, err
}
results[path] = t
} else if trim := strings.TrimPrefix(path, "goog:"); trim != path {
// There are no current heuristics in this implementation for
// 'goog:' imports. That is handled by the ts_auto_deps binary proper.
results[path] = nil
} else {
// The import is not explicitly under google3 or a closure-style
// import.
remainingImportPaths = append(remainingImportPaths, path)
}
}
// Attempt to locate the file rooted in the workspace even though it isn't
// prefixed by 'google3/'.
for _, imp := range remainingImportPaths {
// If the path has a suffix of ".ngfactory" or ".ngsummary", it might
// be an Angular AOT generated file. We can infer the target as we
// infer its corresponding ngmodule target by simply stripping the
// ".ngfactory" / ".ngsummary" suffix
path := strings.TrimSuffix(strings.TrimSuffix(imp, ".ngsummary"), ".ngfactory")
res, err := q.batchQuery(pathWithExtensions(path))
if err != nil {
return nil, err
}
if len(res.GetTarget()) > 0 {
target := res.GetTarget()[0]
label, err := q.sourceFileLabel(target)
if err != nil {
return nil, err
}
rule, err := q.loadRuleIncludingFile(label)
if err != nil {
return nil, err
}
results[imp] = rule
continue
}
}
return results, nil
}
func (q *QueryBasedTargetLoader) ruleLabel(target *appb.Target) (string, error) {
if t := target.GetType(); t != appb.Target_RULE {
return "", fmt.Errorf("target contains object of type %q instead of type %q", t, appb.Target_RULE)
}
return target.GetRule().GetName(), nil
}
func (q *QueryBasedTargetLoader) sourceFileLabel(target *appb.Target) (string, error) {
if t := target.GetType(); t != appb.Target_SOURCE_FILE {
return "", fmt.Errorf("target contains object of type %q instead of type %q", t, appb.Target_SOURCE_FILE)
}
return target.GetSourceFile().GetName(), nil
}
// loadRuleIncludingFile loads the target associated with a file label.
func (q *QueryBasedTargetLoader) loadRuleIncludingFile(fileLabel string) (*appb.Rule, error) {
_, pkg, file := edit.ParseLabel(fileLabel)
// Filter the targets in the file label's package to only targets which
// include the file in their 'srcs' attribute.
r, err := q.query(fmt.Sprintf("attr('srcs', %s, //%s:*)", file, pkg))
if err != nil {
return nil, err
}
for _, target := range r.GetTarget() {
rule := target.GetRule()
for _, src := range listAttribute(rule, "srcs") {
_, _, path := edit.ParseLabel(src)
// Return the first rule which has a source file exactly matching
// the requested file path.
if path == file {
return rule, nil
}
}
}
return nil, fmt.Errorf("failed to resolved a target for file label %q", fileLabel)
}
// batchQuery runs a set of queries with a single call to Bazel query and the
// '--keep_going' flag.
func (q *QueryBasedTargetLoader) batchQuery(queries []string) (*appb.QueryResult, error) {
// Join all of the queries with a '+' character according to Bazel's
// syntax for running multiple queries.
return q.query("--keep_going", strings.Join(queries, "+"))
}
func (q *QueryBasedTargetLoader) query(args ...string) (*appb.QueryResult, error) {
n := len(args)
if n < 1 {
return nil, fmt.Errorf("expected at least one argument")
}
if query := args[n-1]; query == "" {
// An empty query was provided so return an empty result without
// making a call to Bazel.
return &appb.QueryResult{}, nil
}
var stdout, stderr bytes.Buffer
args = append([]string{"query", "--output=proto"}, args...)
cmd := exec.Command(q.bazelBinary, args...)
cmd.Dir = q.workdir
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Exit status 3 is a direct result of one or more queries in a set of
// queries not returning a result while running with the '--keep_going'
// flag. Since one query failing to return a result does not hinder the
// other queries from returning a result, ignore these errors.
if err.Error() != "exit status 3" {
// The error provided as a result is less useful than the contents of
// stderr for debugging.
return nil, fmt.Errorf(stderr.String())
}
}
var result appb.QueryResult
if err := proto.Unmarshal(stdout.Bytes(), &result); err != nil {
return nil, err
}
return &result, nil
}
// dedupeLabels returns a new set of labels with no duplicates.
func dedupeLabels(labels []string) []string {
addedLabels := make(map[string]bool)
var uniqueLabels []string
for _, label := range labels {
if _, added := addedLabels[label]; !added {
addedLabels[label] = true
uniqueLabels = append(uniqueLabels, label)
}
}
return uniqueLabels
}
// isTazeManagedRuleClass checks if a class is a ts_auto_deps-managed rule class.
func isTazeManagedRuleClass(class string) bool {
for _, c := range []string{
"ts_library",
"ts_declaration",
"ng_module",
"js_library",
} {
if c == class {
return true
}
}
return false
}
// typeScriptRules returns all TypeScript rules in rules.
func typeScriptRules(rules []*appb.Rule) []*appb.Rule {
var tsRules []*appb.Rule
for _, rule := range rules {
for _, supportedRuleClass := range []string{
"ts_library",
"ts_declaration",
"ng_module",
} {
if rule.GetRuleClass() == supportedRuleClass {
tsRules = append(tsRules, rule)
break
}
}
}
return tsRules
}
// resolveAgainstModuleRoot resolves imported against moduleRoot and moduleName.
func resolveAgainstModuleRoot(label, moduleRoot, moduleName, imported string) string {
if moduleRoot == "" && moduleName == "" {
return imported
}
trim := strings.TrimPrefix(imported, moduleName)
if trim == imported {
return imported
}
_, pkg, _ := edit.ParseLabel(label)
return filepath.Join(pkg, moduleRoot, trim)
}
// parsePackageName parses and returns the scope and package of imported. For
// example, "@foo/bar" would have a scope of "@foo" and a package of "bar".
func parsePackageName(imported string) (string, string) {
firstSlash := strings.Index(imported, "/")
if firstSlash == -1 {
return imported, ""
}
afterSlash := imported[firstSlash+1:]
if secondSlash := strings.Index(afterSlash, "/"); secondSlash > -1 {
return imported[:firstSlash], afterSlash[:secondSlash]
}
return imported[:firstSlash], afterSlash
}