You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
tailscale/tool/listpkgs/listpkgs.go

283 lines
7.5 KiB

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// listpkgs prints the import paths that match the Go package patterns
// given on the command line and conditionally filters them in various ways.
package main
import (
"bufio"
"flag"
"fmt"
"go/build/constraint"
"log"
"os"
"slices"
"strings"
"sync"
"golang.org/x/tools/go/packages"
)
var (
ignore3p = flag.Bool("ignore-3p", false, "ignore third-party packages forked/vendored into Tailscale")
goos = flag.String("goos", "", "GOOS to use for loading packages (default: current OS)")
goarch = flag.String("goarch", "", "GOARCH to use for loading packages (default: current architecture)")
withTagsAllStr = flag.String("with-tags-all", "", "if non-empty, a comma-separated list of builds tags to require (a package will only be listed if it contains all of these build tags)")
withoutTagsAnyStr = flag.String("without-tags-any", "", "if non-empty, a comma-separated list of build constraints to exclude (a package will be omitted if it contains any of these build tags)")
shard = flag.String("shard", "", "if non-empty, a string of the form 'N/M' to only print packages in shard N of M (e.g. '1/3', '2/3', '3/3/' for different thirds of the list)")
affectedByTag = flag.String("affected-by-tag", "", "if non-empty, only list packages whose test binary would be affected by the presence or absence of this build tag")
)
func main() {
flag.Parse()
patterns := flag.Args()
if len(patterns) == 0 {
flag.Usage()
os.Exit(1)
}
cfg := &packages.Config{
Mode: packages.LoadFiles,
Env: os.Environ(),
}
if *affectedByTag != "" {
cfg.Mode |= packages.NeedImports
cfg.Tests = true
}
if *goos != "" {
cfg.Env = append(cfg.Env, "GOOS="+*goos)
}
if *goarch != "" {
cfg.Env = append(cfg.Env, "GOARCH="+*goarch)
}
pkgs, err := packages.Load(cfg, patterns...)
if err != nil {
log.Fatalf("loading packages: %v", err)
}
var withoutAny []string
if *withoutTagsAnyStr != "" {
withoutAny = strings.Split(*withoutTagsAnyStr, ",")
}
var withAll []string
if *withTagsAllStr != "" {
withAll = strings.Split(*withTagsAllStr, ",")
}
var affected map[string]bool // PkgPath → true
if *affectedByTag != "" {
affected = computeAffected(pkgs, *affectedByTag)
}
seen := map[string]bool{}
matches := 0
Pkg:
for _, pkg := range pkgs {
if pkg.PkgPath == "" { // malformed (shouldn’t happen)
continue
}
if affected != nil {
// Skip synthetic packages created by Tests: true:
// - for-test variants like "foo [foo.test]" (ID != PkgPath)
// - test binary packages like "foo.test" (PkgPath ends in ".test")
if pkg.ID != pkg.PkgPath || strings.HasSuffix(pkg.PkgPath, ".test") {
continue
}
if !affected[pkg.PkgPath] {
continue
}
}
if seen[pkg.PkgPath] {
continue // suppress duplicates when patterns overlap
}
seen[pkg.PkgPath] = true
pkgPath := pkg.PkgPath
if *ignore3p && isThirdParty(pkgPath) {
continue
}
if withAll != nil {
for _, t := range withAll {
if !hasBuildTag(pkg, t) {
continue Pkg
}
}
}
for _, t := range withoutAny {
if hasBuildTag(pkg, t) {
continue Pkg
}
}
matches++
if *shard != "" {
var n, m int
if _, err := fmt.Sscanf(*shard, "%d/%d", &n, &m); err != nil || n < 1 || m < 1 {
log.Fatalf("invalid shard format %q; expected ‘N/M’", *shard)
}
if m > 0 && (matches-1)%m != n-1 {
continue // not in this shard
}
}
fmt.Println(pkgPath)
}
// If any package had errors (e.g. missing deps) report them via packages.PrintErrors.
// This mirrors `go list` behaviour when -e is *not* supplied.
if packages.PrintErrors(pkgs) > 0 {
os.Exit(1)
}
}
// computeAffected returns the set of package paths whose test binaries would
// differ with vs without the given build tag. It finds packages that directly
// mention the tag, then propagates transitively via reverse dependencies.
func computeAffected(pkgs []*packages.Package, tag string) map[string]bool {
// Build a map from package ID to package for quick lookup.
byID := make(map[string]*packages.Package, len(pkgs))
for _, pkg := range pkgs {
byID[pkg.ID] = pkg
}
// First pass: find directly affected package IDs.
directlyAffected := make(map[string]bool)
for _, pkg := range pkgs {
if hasBuildTag(pkg, tag) {
directlyAffected[pkg.ID] = true
}
}
// Build reverse dependency graph: importedID → []importingID.
reverseDeps := make(map[string][]string)
for _, pkg := range pkgs {
for _, imp := range pkg.Imports {
reverseDeps[imp.ID] = append(reverseDeps[imp.ID], pkg.ID)
}
}
// BFS from directly affected packages through reverse deps.
affectedIDs := make(map[string]bool)
queue := make([]string, 0, len(directlyAffected))
for id := range directlyAffected {
affectedIDs[id] = true
queue = append(queue, id)
}
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
for _, rdep := range reverseDeps[id] {
if !affectedIDs[rdep] {
affectedIDs[rdep] = true
queue = append(queue, rdep)
}
}
}
// Map affected IDs back to PkgPaths. For-test variants like
// "foo [foo.test]" share the same PkgPath as "foo", so the
// result naturally deduplicates.
affected := make(map[string]bool)
for id := range affectedIDs {
if pkg, ok := byID[id]; ok {
affected[pkg.PkgPath] = true
}
}
return affected
}
func isThirdParty(pkg string) bool {
return strings.HasPrefix(pkg, "tailscale.com/tempfork/")
}
// hasBuildTag reports whether any source file in pkg mentions `tag`
// in a //go:build constraint.
func hasBuildTag(pkg *packages.Package, tag string) bool {
all := slices.Concat(pkg.CompiledGoFiles, pkg.OtherFiles, pkg.IgnoredFiles)
suffix := "_" + tag + ".go"
for _, name := range all {
if strings.HasSuffix(name, suffix) {
return true
}
ok, err := fileMentionsTag(name, tag)
if err != nil {
log.Printf("reading %s: %v", name, err)
continue
}
if ok {
return true
}
}
return false
}
// tagSet is a set of build tags.
// The values are always true. We avoid non-std set types
// to make this faster to "go run" on empty caches.
type tagSet map[string]bool
var (
mu sync.Mutex
fileTags = map[string]tagSet{} // abs path -> set of build tags mentioned in file
)
func getFileTags(filename string) (tagSet, error) {
mu.Lock()
tags, ok := fileTags[filename]
mu.Unlock()
if ok {
return tags, nil
}
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
ts := make(tagSet)
s := bufio.NewScanner(f)
for s.Scan() {
line := s.Text()
if strings.TrimSpace(line) == "" {
continue // still in leading blank lines
}
if !strings.HasPrefix(line, "//") {
// hit real code – done with header comments
// TODO(bradfitz): care about /* */ comments?
break
}
if !strings.HasPrefix(line, "//go:build") {
continue // some other comment
}
expr, err := constraint.Parse(line)
if err != nil {
return nil, fmt.Errorf("parsing %q: %w", line, err)
}
// Call Eval to populate ts with the tags mentioned in the expression.
// We don't care about the result, just the side effect of populating ts.
expr.Eval(func(tag string) bool {
ts[tag] = true
return true // arbitrary
})
}
if err := s.Err(); err != nil {
return nil, fmt.Errorf("reading %s: %w", filename, err)
}
mu.Lock()
defer mu.Unlock()
fileTags[filename] = ts
return ts, nil
}
func fileMentionsTag(filename, tag string) (bool, error) {
tags, err := getFileTags(filename)
if err != nil {
return false, err
}
return tags[tag], nil
}