| // Package analyze uses bazel query to determine and locate missing imports |
| // in TypeScript source files. |
| package analyze |
| |
| import ( |
| "context" |
| "fmt" |
| "os" |
| "path/filepath" |
| "regexp" |
| "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 { |
| // LoadTargets loads targets from BUILD files associated with labels. A target |
| // is a rule, source file, generated file, package group or environment group. |
| // 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. TODO(lucassloan): make this an error |
| // |
| // Only returns targets visible to currentPkg. If currentPkg is an empty |
| // string returns all targets regardless of visibility. |
| LoadTargets(currentPkg string, labels []string) (map[string]*appb.Target, error) |
| // LoadRules loads rules from BUILD files associated with labels. |
| // It returns a mapping from labels to rules or an error, if any |
| // occurred. |
| // |
| // A label must be the absolute label associated with a rule. For |
| // example, '//foo/bar:baz' is acceptable whereas 'bar:baz' or '//foo/bar' |
| // will result in undefined behavior. |
| // TODO(lucassloan): make this an error. |
| // |
| // Only returns rules visible to currentPkg. If currentPkg is an empty string |
| // returns all rules regardless of visibility. |
| LoadRules(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, targetToAnalyze *appb.Rule, 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 |
| } |
| rules, err := a.loader.LoadRules(currentPkg, labels) |
| if err != nil { |
| return nil, err |
| } |
| resolved, err := a.resolveImportsForTargets(ctx, currentPkg, root, rules) |
| 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 |
| // A map from the labels in the target's srcs to the Targets those |
| // labels refer. |
| sources map[string]*appb.Target |
| literalSourcePaths []string |
| generatedSourcePaths []string |
| } |
| |
| // setSources sets the sources on t. It returns an error if one of the srcs of |
| // t's rule isn't in loadedSrcs. It also sorts the sources into literal and |
| // generated sources, setting literalSourcePaths and generatedSourcePaths. |
| // Returns an error if all the sources are generated - ts_auto_deps can't read the |
| // import statements to determine deps. |
| func (t *resolvedTarget) setSources(loadedSrcs map[string]*appb.Target) error { |
| for _, label := range listAttribute(t.rule, "srcs") { |
| src := loadedSrcs[label] |
| if src == nil { |
| return fmt.Errorf("no source found for label %s", label) |
| } |
| t.sources[label] = src |
| if src.GetType() == appb.Target_SOURCE_FILE { |
| t.literalSourcePaths = append(t.literalSourcePaths, labelToPath(label)) |
| } else { |
| t.generatedSourcePaths = append(t.generatedSourcePaths, labelToPath(label)) |
| } |
| } |
| if len(t.literalSourcePaths) == 0 && len(t.generatedSourcePaths) > 0 { |
| return fmt.Errorf("rule has generated sources - cannot determine dependencies") |
| } |
| return nil |
| } |
| |
| // srcs returns the labels of the sources of t. |
| func (t *resolvedTarget) srcs() ([]string, error) { |
| srcs := listAttribute(t.rule, "srcs") |
| if srcs == nil { |
| return nil, fmt.Errorf("target %q missing \"srcs\" attribute", t.label) |
| } |
| |
| return srcs, nil |
| } |
| |
| // getAllLiteralSrcPaths returns the file paths of all the non-generated sources |
| // of the targets. |
| func getAllLiteralSrcPaths(targets map[string]*resolvedTarget) ([]string, error) { |
| var allLiteralSrcPaths []string |
| for _, t := range targets { |
| allLiteralSrcPaths = append(allLiteralSrcPaths, t.literalSourcePaths...) |
| } |
| |
| return allLiteralSrcPaths, 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 { |
| for _, label := range listAttribute(t.rule, "srcs") { |
| src := t.sources[label] |
| if src.GetType() == appb.Target_SOURCE_FILE { |
| // For literal sources, check the path of the source |
| if labelToPath(label) == path { |
| return true |
| } |
| } else if src.GetType() == appb.Target_RULE { |
| // For generated souces, check against the paths of rule's |
| // outputs |
| for _, genSrc := range src.GetRule().GetRuleOutput() { |
| if labelToPath(genSrc) == 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, |
| sources: make(map[string]*appb.Target), |
| } |
| } |
| |
| // 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.LoadRules(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] |
| } |
| } |
| // load all the sources in the targets, so that literal and generated |
| // targets can be distinguished |
| srcs, err := a.loader.LoadTargets(currentPkg, allSrcs) |
| if err != nil { |
| return nil, err |
| } |
| for _, t := range targets { |
| err := t.setSources(srcs) |
| if err != nil { |
| return nil, err |
| } |
| } |
| // only extract the imports out of the literal sources, since ts_auto_deps can't |
| // see the contents of generated files |
| allLiteralSrcPaths, err := getAllLiteralSrcPaths(targets) |
| if err != nil { |
| return nil, err |
| } |
| imports, errs := extractAllImports(root, allLiteralSrcPaths) |
| 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 := t.literalSourcePaths |
| 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 { |
| for _, target := range targets { |
| var paths []string |
| needingResolution := make(map[string][]*ts_auto_depsImport) |
| 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.findExistingDepProvidingImport(ctx, root, target, 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 { |
| continue |
| } |
| res, err := a.loader.LoadImportPaths(ctx, target.rule, 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 |
| } |
| |
| var ambientModuleDeclRE = regexp.MustCompile("(?m)^\\s*declare\\s+module\\s+['\"]([^'\"]+)['\"]\\s+\\{") |
| |
| // findExistingDepProvidingImport looks through a map of the existing deps to |
| // see if any of them provide the import in a way that can't be queried |
| // for. E.g. if the build rule has a "module_name" attribute or if one |
| // of the .d.ts sources has an ambient module declaration. |
| // |
| // If the import already has a knownTarget, findRuleProvidingImport will |
| // return the knownTarget. |
| func (a *Analyzer) findExistingDepProvidingImport(ctx context.Context, root string, rt *resolvedTarget, i *ts_auto_depsImport) (string, error) { |
| if i.knownTarget != "" { |
| return i.knownTarget, nil |
| } |
| |
| // check if any of the existing deps declare a module_name that matches the import |
| for _, r := range rt.dependencies { |
| resolvedImportPath := resolveAgainstModuleRoot(r, i.importPath) |
| if resolvedImportPath == i.importPath { |
| continue |
| } |
| |
| // enumerate all the possible filepaths for the resolved import path, and |
| // compare against all the srcs |
| possibleImportPaths := possibleFilepaths(resolvedImportPath) |
| for _, src := range listAttribute(r, "srcs") { |
| for _, mi := range possibleImportPaths { |
| if mi == labelToPath(src) { |
| return r.GetName(), nil |
| } |
| } |
| } |
| } |
| |
| // check if any of the other sources or the souces of any of the deps are .d.ts |
| // files which have ambient module declarations |
| var allRules []*appb.Rule |
| for _, r := range rt.dependencies { |
| allRules = append(allRules, r) |
| } |
| allRules = append(allRules, rt.rule) |
| for _, r := range allRules { |
| for _, src := range listAttribute(r, "srcs") { |
| fp := filepath.Join(root, labelToPath(src)) |
| if !strings.HasSuffix(fp, ".d.ts") { |
| continue |
| } |
| |
| contents, err := platform.ReadFile(ctx, fp) |
| if err != nil { |
| return "", fmt.Errorf("error reading file looking for ambient module decls: %s", err) |
| } |
| |
| matches := ambientModuleDeclRE.FindAllStringSubmatch(string(contents), -1) |
| |
| // put all the ambient modules into a set |
| declaredModules := make(map[string]bool) |
| for _, match := range matches { |
| declaredModules[match[1]] = true |
| } |
| |
| // remove all the modules that were imported (ie all the modules that |
| // were being augmented/re-opened) |
| for _, mi := range parseImports(fp, contents) { |
| delete(declaredModules, mi.importPath) |
| } |
| |
| if declaredModules[i.importPath] { |
| debugf("found import %s in ambient module declaration in %s", i.importPath, r.GetName()) |
| 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() |
| } |
| |
| func labelToPath(label string) string { |
| _, pkg, file := edit.ParseLabel(label) |
| return platform.Normalize(filepath.Clean(filepath.Join(pkg, file))) |
| } |
| |
| // 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, "") { |
| // fmt.Printf("%s provides %s\n", dep, imp.importPath) |
| 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.LoadRules("", unusedDeps) |
| if err != nil { |
| return nil, err |
| } |
| for label, rule := range labelToRule { |
| switch class := rule.GetRuleClass(); class { |
| case "ts_declaration": |
| // TypeScript declarations might declare arbitrary global symbols, so it |
| // is impossible to detect reliably if the import is being used (without |
| // compiling, at least). Report that the rule has no explicit import as a |
| // warning, so that ts_auto_deps can decide to import remove or not based on a |
| // flag. |
| warning := fmt.Sprintf("WARNING: %s: keeping possibly used %s '%s'", rule.GetLocation(), class, label) |
| report.Feedback = append(report.Feedback, warning) |
| case "css_library": |
| // Similar to ts_declaration, ts_auto_deps can't reliably detect if css_library |
| // imports are being used, since ts_auto_deps can't currently parse @requirecss |
| // annotations. Unlike ts_declaration, there's no flag to remove them, so |
| // there's no need to report a warning. |
| default: |
| // The contents of generated files aren't visible, so ts_auto_deps can't discover |
| // the import statements/deps that they contain. To be safe, don't remove |
| // any unused deps, since they might be used by the generated file(s). |
| if len(target.generatedSourcePaths) == 0 { |
| report.UnnecessaryDependency = append(report.UnnecessaryDependency, 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") != "" |
| } |