| // Package analyze uses bazel query to determine and locate missing imports |
| // in TypeScript source files. |
| package analyze |
| |
| import ( |
| "context" |
| "fmt" |
| "os" |
| "path/filepath" |
| "strings" |
| |
| "github.com/bazelbuild/buildtools/edit" |
| "github.com/bazelbuild/rules_typescript/ts_auto_deps/platform" |
| "github.com/bazelbuild/rules_typescript/ts_auto_deps/workspace" |
| "github.com/golang/protobuf/proto" |
| |
| appb "github.com/bazelbuild/buildtools/build_proto" |
| arpb "github.com/bazelbuild/rules_typescript/ts_auto_deps/proto" |
| ) |
| |
| var ( |
| extensions = []string{ |
| // '.d.ts' must come before '.ts' to completely remove the '.d.ts' |
| // extension. |
| ".d.ts", |
| ".ts", |
| ".tsx", |
| } |
| ) |
| |
| const ( |
| // debug enables/disables debug logging. Set to true to have debug statements |
| // print to stdout, set to false to disable debug statements. |
| debug = false |
| ) |
| |
| // debugf prints a formatted message prefixed with "DEBUG:" if the debug |
| // flag is enabled. |
| func debugf(format string, v ...interface{}) { |
| if debug { |
| fmt.Printf(fmt.Sprintf("DEBUG: %s\n", format), v...) |
| } |
| } |
| |
| // TargetLoader provides methods for loading targets from BUILD files. |
| type TargetLoader interface { |
| // LoadLabels loads targets from BUILD files associated with labels. |
| // It returns a mapping from labels to targets or an error, if any |
| // occurred. |
| // |
| // A label must be the absolute label associated with a target. For |
| // example, '//foo/bar:baz' is acceptable whereas 'bar:baz' or '//foo/bar' |
| // will result in undefined behavior. If no target is found associated |
| // with a provided label, the label should be excluded from the returned |
| // mapping but an error should not be returned. |
| // |
| // Only returns rules visible to currentPkg. If currentPkg is an empty string |
| // returns all targets regardless of visibility. |
| LoadLabels(currentPkg string, labels []string) (map[string]*appb.Rule, error) |
| // LoadImportPaths loads targets from BUILD files associated with import |
| // paths relative to a root directory. It returns a mapping from import |
| // paths to targets or an error, if any occurred. |
| // |
| // An import path is the path present in a TypeScript import statement |
| // resolved relative to the workspace root. For example, an import |
| // statement 'import baz from "../baz.ts"' declared in the TypeScript |
| // source file '//foo/bar.ts' would have the import path of 'baz.ts'. If |
| // no target is found associated with a provided import path, the import |
| // path should be excluded from the returned mapping but an error should |
| // not be returned. |
| // |
| // Only returns rules visible to currentPkg. If currentPkg is an empty string |
| // returns all targets regardless of visibility. |
| LoadImportPaths(ctx context.Context, currentPkg, root string, paths []string) (map[string]*appb.Rule, error) |
| } |
| |
| // Analyzer uses a BuildLoader to generate dependency reports. |
| type Analyzer struct { |
| loader TargetLoader |
| } |
| |
| // New returns a new Analyzer which can be used to generate dependency reports. |
| func New(loader TargetLoader) *Analyzer { |
| return &Analyzer{loader: loader} |
| } |
| |
| // Analyze generates a dependency report for each target label in labels. |
| // |
| // dir is the directory that ts_auto_deps should execute in. Must be a sub-directory |
| // of the workspace root. |
| func (a *Analyzer) Analyze(ctx context.Context, dir string, labels []string) ([]*arpb.DependencyReport, error) { |
| if len(labels) == 0 { |
| return nil, nil |
| } |
| _, currentPkg, _ := edit.ParseLabel(labels[0]) |
| for _, label := range labels { |
| if _, pkg, _ := edit.ParseLabel(label); pkg != currentPkg { |
| return nil, fmt.Errorf("can't analyze targets in different packages") |
| } |
| } |
| root, err := workspace.Root(dir) |
| if err != nil { |
| return nil, err |
| } |
| targets, err := a.loader.LoadLabels(currentPkg, labels) |
| if err != nil { |
| return nil, err |
| } |
| resolved, err := a.resolveImportsForTargets(ctx, currentPkg, root, targets) |
| if err != nil { |
| return nil, err |
| } |
| return a.generateReports(labels, resolved) |
| } |
| |
| // resolvedTarget represents a Bazel target and all resolved information. |
| type resolvedTarget struct { |
| label string |
| // A map of all existing dependencies on a target at the time of analysis. |
| // The keys are labels and the values are thes loaded target. |
| dependencies map[string]*appb.Rule |
| // A map of source file paths to their imports. |
| imports map[string][]*ts_auto_depsImport |
| // rule is the original rule the target was constructed from. |
| rule *appb.Rule |
| // missingSources are source files which could not be opened on disk. |
| // These are added to the dependency reports and MissingSources. |
| missingSources []string |
| } |
| |
| func (t *resolvedTarget) srcs() ([]string, error) { |
| srcs, err := sources(t.rule) |
| if err != nil { |
| // Targets without sources are considered errors. |
| return nil, err |
| } |
| return srcs, nil |
| } |
| |
| func (t *resolvedTarget) deps() []string { |
| return listAttribute(t.rule, "deps") |
| } |
| |
| // provides returns whether the resolved target can provide the path provided. |
| func (t *resolvedTarget) provides(path string) bool { |
| srcs, err := t.srcs() |
| if err != nil { |
| return false |
| } |
| for _, src := range srcs { |
| if src == path { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // newTarget constructs a new target instance from a loaded rule. |
| func newResolvedTarget(r *appb.Rule) *resolvedTarget { |
| return &resolvedTarget{ |
| label: r.GetName(), |
| dependencies: make(map[string]*appb.Rule), |
| imports: make(map[string][]*ts_auto_depsImport), |
| rule: r, |
| } |
| } |
| |
| // resolveImportsForTargets attempts to resolve the imports in the sources of |
| // each target in targets. |
| func (a *Analyzer) resolveImportsForTargets(ctx context.Context, currentPkg, root string, allTargets map[string]*appb.Rule) (map[string]*resolvedTarget, error) { |
| targets := make(map[string]*resolvedTarget) |
| var allDeps, allSrcs []string |
| for _, t := range allTargets { |
| target := newResolvedTarget(t) |
| targets[target.label] = target |
| srcs, err := target.srcs() |
| if err != nil { |
| return nil, err |
| } |
| allDeps = append(allDeps, target.deps()...) |
| allSrcs = append(allSrcs, srcs...) |
| } |
| deps, err := a.loader.LoadLabels(currentPkg, allDeps) |
| if err != nil { |
| return nil, err |
| } |
| // Associate the loaded existing deps with the target or targets which |
| // contained them. |
| for _, t := range targets { |
| for _, dep := range t.deps() { |
| t.dependencies[dep] = deps[dep] |
| } |
| } |
| imports, errs := extractAllImports(root, allSrcs) |
| for _, err := range errs { |
| // NotExist errors are caught and added to the generated dependency |
| // reports as missing source files. Only errors which are not NotExist |
| // errors should be reported. |
| if !os.IsNotExist(err) { |
| return nil, err |
| } |
| } |
| for _, t := range targets { |
| srcs, err := t.srcs() |
| if err != nil { |
| return nil, err |
| } |
| for _, src := range srcs { |
| v, ok := imports[src] |
| if ok { |
| t.imports[src] = v |
| } else { |
| // The source was not found on disk during import extraction. |
| t.missingSources = append(t.missingSources, relativePathLabel(t.label, src)) |
| } |
| } |
| } |
| if err := a.resolveImports(ctx, currentPkg, root, targets); err != nil { |
| return nil, err |
| } |
| return targets, nil |
| } |
| |
| // resolveImports finds targets which provide the imported file or library |
| // for imports without known targets. |
| func (a *Analyzer) resolveImports(ctx context.Context, currentPkg, root string, targets map[string]*resolvedTarget) error { |
| var paths []string |
| needingResolution := make(map[string][]*ts_auto_depsImport) |
| for _, target := range targets { |
| for _, imports := range target.imports { |
| handlingImports: |
| for _, imp := range imports { |
| resolvedPath := imp.resolvedPath() |
| for _, path := range pathWithExtensions(resolvedPath) { |
| if target.provides(path) { |
| imp.knownTarget = target.label |
| continue handlingImports |
| } |
| } |
| d, err := a.findRuleProvidingImport(target.dependencies, imp) |
| if err != nil { |
| return err |
| } |
| if d == "" { |
| // A target providing the import was not found on the |
| // existing dependencies or in a comment. Use other |
| // heuristics. |
| paths = append(paths, resolvedPath) |
| needingResolution[resolvedPath] = append(needingResolution[resolvedPath], imp) |
| continue |
| } |
| imp.knownTarget = d |
| } |
| } |
| } |
| if len(needingResolution) == 0 { |
| return nil |
| } |
| res, err := a.loader.LoadImportPaths(ctx, currentPkg, root, paths) |
| if err != nil { |
| return err |
| } |
| for path, imports := range needingResolution { |
| if target, ok := res[path]; ok { |
| for _, imp := range imports { |
| imp.knownTarget = redirectedLabel(target) |
| } |
| } |
| } |
| return nil |
| } |
| |
| func pathWithExtensions(basename string) []string { |
| var paths []string |
| for _, ext := range extensions { |
| paths = append(paths, basename+ext) |
| } |
| return paths |
| } |
| |
| // findRuleProvidingImport looks through a map of loaded rules for a rule |
| // which can provide the passed import. |
| // |
| // If the import already has a knownTarget, findRuleProvidingImport will |
| // return the knownTarget. |
| func (a *Analyzer) findRuleProvidingImport(rules map[string]*appb.Rule, i *ts_auto_depsImport) (string, error) { |
| if i.knownTarget != "" { |
| return i.knownTarget, nil |
| } |
| for _, r := range rules { |
| moduleName := stringAttribute(r, "module_name") |
| if moduleName == "" { |
| continue |
| } |
| srcs := listAttribute(r, "srcs") |
| for _, src := range srcs { |
| _, _, file := edit.ParseLabel(src) |
| moduleImportPath := moduleName + "/" + stripTSExtension(file) |
| if i.importPath == moduleImportPath || i.importPath == strings.TrimSuffix(moduleImportPath, "/index") { |
| return r.GetName(), nil |
| } |
| } |
| } |
| return "", nil |
| } |
| |
| // stripTSExtension removes TypeScript extensions from a file path. If no |
| // TypeScript extensions are present, the filepath is returned unaltered. |
| func stripTSExtension(path string) string { |
| for _, ext := range extensions { |
| if strings.HasSuffix(path, ext) { |
| return strings.TrimSuffix(path, ext) |
| } |
| } |
| return path |
| } |
| |
| // redirectedLabel looks in the target's tags for a tag starting with |
| // 'alt_dep=' followed by a label. If such a tag is found, the label is |
| // returned. Otherwise, the target's own label is returned. |
| func redirectedLabel(target *appb.Rule) string { |
| for _, tag := range listAttribute(target, "tags") { |
| if trimmedTag := strings.TrimPrefix(tag, "alt_dep="); trimmedTag != tag { |
| return trimmedTag |
| } |
| } |
| // No 'alt_dep=' tag was present on the target so no redirects need to occur. |
| return target.GetName() |
| } |
| |
| // sources creates an array of all sources listed in the 'srcs' attribute |
| // on each target in targets. |
| func sources(target *appb.Rule) ([]string, error) { |
| srcs := listAttribute(target, "srcs") |
| if srcs == nil { |
| return nil, fmt.Errorf("target %q missing \"srcs\" attribute", target.GetName()) |
| } |
| for i, src := range srcs { |
| _, pkg, file := edit.ParseLabel(src) |
| // TODO(jdhamlik): Handle generated files. |
| srcs[i] = filepath.Clean(filepath.Join(pkg, file)) |
| } |
| return srcs, nil |
| } |
| |
| // generateReports generates reports for each label in labels. |
| func (a *Analyzer) generateReports(labels []string, labelToTarget map[string]*resolvedTarget) ([]*arpb.DependencyReport, error) { |
| reports := make([]*arpb.DependencyReport, 0, len(labels)) |
| for _, label := range labels { |
| target, ok := labelToTarget[label] |
| if !ok { |
| // This case should never happen. |
| platform.Fatalf("target %s no longer loaded", label) |
| } |
| report, err := a.generateReport(target) |
| if err != nil { |
| return nil, err |
| } |
| reports = append(reports, report) |
| } |
| return reports, nil |
| } |
| |
| // generateReport generates a dependency report for a target. |
| // |
| // It adds imports for which no target could be found to unresolved imports. |
| // Imports which had locatable targets are added to the necessary dependency |
| // or missing dependency properties if the import was already present on target |
| // or the import was not already present respectively. |
| // |
| // Missing source files detected during import resolution are added to the |
| // reports. Dependencies which were present on the initial target but are not |
| // required are added to the unnecessary dependency array. |
| func (a *Analyzer) generateReport(target *resolvedTarget) (*arpb.DependencyReport, error) { |
| usedDeps := make(map[string]bool) |
| report := &arpb.DependencyReport{ |
| Rule: proto.String(target.label), |
| MissingSourceFile: target.missingSources, |
| } |
| for _, imports := range target.imports { |
| handlingImports: |
| for _, imp := range imports { |
| if imp.knownTarget == target.label { |
| continue |
| } |
| if imp.knownTarget == "" { |
| if strings.HasPrefix(imp.importPath, "goog:") { |
| // This feedback needs to be phrased this way since the |
| // updater.go relies on parsing the feedback strings to |
| // determine which 'goog:' imports to add. |
| report.Feedback = append(report.Feedback, |
| fmt.Sprintf( |
| "ERROR: %s:%d:%d: missing comment for 'goog:' import, "+ |
| "please add a trailing comment to the import. E.g.\n "+ |
| "import Bar from '%s'; // from //foo:bar", |
| imp.location.sourcePath, imp.location.line, imp.location.offset, imp.importPath)) |
| } |
| report.UnresolvedImport = append(report.UnresolvedImport, imp.resolvedPath()) |
| continue |
| } |
| |
| for _, dep := range target.deps() { |
| if edit.LabelsEqual(dep, imp.knownTarget, "") { |
| usedDeps[dep] = true |
| report.NecessaryDependency = append(report.NecessaryDependency, imp.knownTarget) |
| continue handlingImports |
| } |
| } |
| report.MissingDependencyGroup = append(report.MissingDependencyGroup, &arpb.DependencyGroup{ |
| Dependency: []string{edit.ShortenLabel(imp.knownTarget, "")}, |
| ImportPath: []string{imp.importPath}, |
| }) |
| } |
| } |
| |
| var unusedDeps []string |
| for _, dep := range target.deps() { |
| if _, ok := usedDeps[dep]; !ok { |
| unusedDeps = append(unusedDeps, dep) |
| } |
| } |
| labelToRule, err := a.loader.LoadLabels("", unusedDeps) |
| if err != nil { |
| return nil, err |
| } |
| for label, rule := range labelToRule { |
| if isTazeManagedRuleClass(rule.GetRuleClass()) || isGenerated(rule) { |
| report.UnnecessaryDependency = append(report.UnnecessaryDependency, label) |
| } else if c := rule.GetRuleClass(); c == "ts_declaration" { |
| // ts_declarations may be used even if there is no explicit import |
| // since ambient types can't be detected. |
| report.AmbiguousDependency = append(report.AmbiguousDependency, label) |
| } |
| } |
| return report, nil |
| } |
| |
| // relativePathLabel converts src to a label for a path relative to the |
| // provided target. For example, a target '//foo/bar' and a src 'foo/bar/baz.ts' |
| // would result in a relative path label of '//foo/bar:baz.ts'. |
| func relativePathLabel(label, src string) string { |
| _, pkg, _ := edit.ParseLabel(label) |
| return fmt.Sprintf("//%s:%s", pkg, strings.TrimPrefix(src, pkg+"/")) |
| } |
| |
| // listAttribute retrieves the attribute from target with name. |
| func listAttribute(target *appb.Rule, name string) []string { |
| if a := attribute(target, name); a != nil { |
| return a.GetStringListValue() |
| } |
| return nil |
| } |
| |
| func stringAttribute(target *appb.Rule, name string) string { |
| if a := attribute(target, name); a != nil { |
| return a.GetStringValue() |
| } |
| return "" |
| } |
| |
| func attribute(target *appb.Rule, name string) *appb.Attribute { |
| for _, a := range target.GetAttribute() { |
| if a.GetName() == name { |
| return a |
| } |
| } |
| return nil |
| } |
| |
| func isGenerated(rule *appb.Rule) bool { |
| return stringAttribute(rule, "generator_name") != "" |
| } |