github.com/please-build/go-rules/tools/please_go@v0.0.0-20240319165128-ea27d6f5caba/generate/generate.go (about)

     1  package generate
     2  
     3  import (
     4  	"bufio"
     5  	"fmt"
     6  	"go/build"
     7  	"io/fs"
     8  	"log"
     9  	"os"
    10  	"path"
    11  	"path/filepath"
    12  	"strings"
    13  
    14  	bazelbuild "github.com/bazelbuild/buildtools/build"
    15  	bazeledit "github.com/bazelbuild/buildtools/edit"
    16  
    17  	"github.com/please-build/go-rules/tools/please_go/generate/gomoddeps"
    18  )
    19  
    20  type Generate struct {
    21  	moduleName         string
    22  	moduleArg          string
    23  	srcRoot            string
    24  	subrepo            string
    25  	buildContext       build.Context
    26  	hostModFile        string
    27  	buildFileNames     []string
    28  	moduleDeps         []string
    29  	replace            map[string]string
    30  	knownImportTargets map[string]string // cache these so we don't end up looping over all the modules for every import
    31  	thirdPartyFolder   string
    32  	install            []string
    33  }
    34  
    35  func New(srcRoot, thirdPartyFolder, hostModFile, module, version, subrepo string, buildFileNames, moduleDeps, install []string, buildTags []string) *Generate {
    36  	moduleArg := module
    37  	if version != "" {
    38  		moduleArg += "@" + version
    39  	}
    40  
    41  	ctxt := build.Default
    42  	ctxt.BuildTags = buildTags
    43  
    44  	return &Generate{
    45  		srcRoot:            srcRoot,
    46  		buildContext:       ctxt,
    47  		buildFileNames:     buildFileNames,
    48  		moduleDeps:         moduleDeps,
    49  		hostModFile:        hostModFile,
    50  		knownImportTargets: map[string]string{},
    51  		thirdPartyFolder:   thirdPartyFolder,
    52  		install:            install,
    53  		moduleName:         module,
    54  		moduleArg:          moduleArg,
    55  		subrepo:            subrepo,
    56  	}
    57  }
    58  
    59  // Generate generates a new Please project at the src root. It will walk through the directory tree generating new BUILD
    60  // files. This is primarily intended to generate a please subrepo for third party code.
    61  func (g *Generate) Generate() error {
    62  	deps, replacements, err := gomoddeps.GetCombinedDepsAndReplacements(g.hostModFile, path.Join(g.srcRoot, "go.mod"))
    63  	if err != nil {
    64  		return err
    65  	}
    66  	// It's important to not override g.moduleDeps as it can already contains dependencies configured
    67  	// when `Generate` was constructed.
    68  	g.moduleDeps = append(g.moduleDeps, deps...)
    69  	g.moduleDeps = append(g.moduleDeps, g.moduleName)
    70  	g.replace = replacements
    71  
    72  	if err := g.writeConfig(); err != nil {
    73  		return fmt.Errorf("failed to write config: %w", err)
    74  	}
    75  	if err := g.parseImportConfigs(); err != nil {
    76  		return fmt.Errorf("failed to parse import configs: %w", err)
    77  	}
    78  
    79  	if err := g.generateAll(g.srcRoot); err != nil {
    80  		return fmt.Errorf("failed to generate BUILD files: %w", err)
    81  	}
    82  	return g.writeInstallFilegroup()
    83  }
    84  
    85  // parseImportConfigs walks through the build dir looking for .importconfig files, parsing the # please:target //foo:bar
    86  // comments to generate the known imports. These are the deps that are passed to the go_repo e.g. for legacy go_module
    87  // rules.
    88  func (g *Generate) parseImportConfigs() error {
    89  	return filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
    90  		if filepath.Ext(path) == ".importconfig" {
    91  			target, pkgs, err := parseImportConfig(path)
    92  			if err != nil {
    93  				return err
    94  			}
    95  			if target == "" {
    96  				return nil
    97  			}
    98  			for _, p := range pkgs {
    99  				g.knownImportTargets[p] = target
   100  			}
   101  		}
   102  		return nil
   103  	})
   104  }
   105  
   106  func parseImportConfig(path string) (string, []string, error) {
   107  	f, err := os.Open(path)
   108  	if err != nil {
   109  		return "", nil, err
   110  	}
   111  	defer f.Close()
   112  
   113  	target := ""
   114  	var imports []string
   115  
   116  	importCfg := bufio.NewScanner(f)
   117  	for importCfg.Scan() {
   118  		line := importCfg.Text()
   119  		if strings.HasPrefix(line, "#") {
   120  			if strings.HasPrefix(line, "# please:target ") {
   121  				target = strings.TrimSpace(strings.TrimPrefix(line, "# please:target "))
   122  				if !strings.HasPrefix(target, "///") {
   123  					target = "@" + target
   124  				}
   125  			}
   126  			continue
   127  		}
   128  		parts := strings.Split(strings.TrimPrefix(line, "packagefile "), "=")
   129  		imports = append(imports, parts[0])
   130  	}
   131  	return target, imports, nil
   132  }
   133  
   134  func (g *Generate) installTargets() ([]string, error) {
   135  	var targets []string
   136  
   137  	for _, i := range g.install {
   138  		dir := filepath.Join(g.srcRoot, i)
   139  		if strings.HasSuffix(dir, "/...") {
   140  			ts, err := g.targetsInDir(strings.TrimSuffix(dir, "/..."))
   141  			if err != nil {
   142  				return nil, err
   143  			}
   144  			targets = append(targets, ts...)
   145  		} else {
   146  			t, err := g.libTargetForBuildPackage(i)
   147  			if err != nil {
   148  				return nil, err
   149  			}
   150  			if t == "" {
   151  				return nil, fmt.Errorf("couldn't find install package %v", i)
   152  			}
   153  			targets = append(targets, t)
   154  		}
   155  	}
   156  	return targets, nil
   157  }
   158  
   159  func (g *Generate) targetsInDir(dir string) ([]string, error) {
   160  	var ret []string
   161  	err := filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error {
   162  		if g.isBuildFile(path) {
   163  			t, err := g.libTargetForBuildFile(trimPath(path, g.srcRoot))
   164  			if err != nil {
   165  				return err
   166  			}
   167  			if t != "" {
   168  				ret = append(ret, t)
   169  			}
   170  		}
   171  		return nil
   172  	})
   173  	return ret, err
   174  }
   175  
   176  func (g *Generate) isBuildFile(file string) bool {
   177  	base := filepath.Base(file)
   178  	for _, file := range g.buildFileNames {
   179  		if base == file {
   180  			return true
   181  		}
   182  	}
   183  	return false
   184  }
   185  
   186  func (g *Generate) writeInstallFilegroup() error {
   187  	buildFile, err := parseOrCreateBuildFile(g.srcRoot, g.buildFileNames)
   188  	if err != nil {
   189  		return err
   190  	}
   191  
   192  	rule := NewRule("filegroup", "installs")
   193  	installTargets, err := g.installTargets()
   194  	if err != nil {
   195  		return fmt.Errorf("failed to generate install targets: %v", err)
   196  	}
   197  	rule.SetAttr("exported_deps", NewStringList(installTargets))
   198  	rule.SetAttr("visibility", NewStringList([]string{"PUBLIC"}))
   199  
   200  	buildFile.Stmt = append(buildFile.Stmt, rule.Call)
   201  
   202  	return saveBuildFile(buildFile)
   203  }
   204  
   205  func (g *Generate) writeConfig() error {
   206  	file, err := os.Create(filepath.Join(g.srcRoot, ".plzconfig"))
   207  	if err != nil {
   208  		return err
   209  	}
   210  	defer file.Close()
   211  
   212  	fmt.Fprintln(file, "[Plugin \"go\"]")
   213  	fmt.Fprintln(file, "Target=@//plugins:go")
   214  	fmt.Fprintf(file, "ImportPath=%s\n", g.moduleName)
   215  	for _, t := range g.buildContext.BuildTags {
   216  		fmt.Fprintf(file, "BuildTags=%s\n", t)
   217  	}
   218  	return nil
   219  }
   220  
   221  func (g *Generate) generateAll(dir string) error {
   222  	return filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error {
   223  		if err != nil {
   224  			return err
   225  		}
   226  		if info.IsDir() {
   227  			if info.Name() == "testdata" {
   228  				return filepath.SkipDir
   229  			}
   230  			if path != dir && strings.HasPrefix(info.Name(), "_") {
   231  				return filepath.SkipDir
   232  			}
   233  
   234  			if err := g.generate(trimPath(path, g.srcRoot)); err != nil {
   235  				switch err.(type) {
   236  				case *build.NoGoError:
   237  					// We might walk into a dir that has no .go files for the current arch. This shouldn't
   238  					// be an error so we just eat this
   239  					return nil
   240  				default:
   241  					return err
   242  				}
   243  			}
   244  		}
   245  		return nil
   246  	})
   247  }
   248  
   249  func (g *Generate) pkgDir(target string) string {
   250  	p := strings.TrimPrefix(target, g.moduleName)
   251  	return filepath.Join(g.srcRoot, p)
   252  }
   253  
   254  func (g *Generate) importDir(target string) (*build.Package, error) {
   255  	dir := filepath.Join(os.Getenv("TMP_DIR"), g.pkgDir(target))
   256  	pkg, err := g.buildContext.ImportDir(dir, 0)
   257  	if err != nil {
   258  		return nil, err
   259  	}
   260  	// We also need to discover & attach any .a files in the directory; some libraries use these
   261  	entries, err := os.ReadDir(dir)
   262  	if err != nil {
   263  		return nil, err
   264  	}
   265  	pkg.IgnoredOtherFiles = nil
   266  	for _, entry := range entries {
   267  		if name := entry.Name(); strings.HasSuffix(name, ".a") {
   268  			pkg.IgnoredOtherFiles = append(pkg.IgnoredOtherFiles, name)
   269  		}
   270  	}
   271  	return pkg, nil
   272  }
   273  
   274  func (g *Generate) generate(dir string) error {
   275  	pkg, err := g.importDir(dir)
   276  	if err != nil {
   277  		return err
   278  	}
   279  
   280  	// filter out pkg.GoFiles based on build tags
   281  	var goFiles []string
   282  	for _, f := range pkg.GoFiles {
   283  		match, err := g.buildContext.MatchFile(pkg.Dir, f)
   284  		if err != nil {
   285  			return err
   286  		}
   287  		if match {
   288  			goFiles = append(goFiles, f)
   289  		}
   290  	}
   291  
   292  	pkg.GoFiles = goFiles
   293  	lib := g.ruleForPackage(pkg, dir)
   294  	if lib == nil {
   295  		return nil
   296  	}
   297  
   298  	return g.createBuildFile(dir, lib, pkg.IgnoredOtherFiles)
   299  }
   300  
   301  func (g *Generate) matchesInstall(dir string) bool {
   302  	for _, i := range g.install {
   303  		i := filepath.Join(g.srcRoot, i)
   304  		pkgDir := g.pkgDir(dir)
   305  
   306  		if strings.HasSuffix(i, "/...") {
   307  			i = strings.TrimSuffix(i, "/...")
   308  			return strings.HasPrefix(pkgDir, i)
   309  		}
   310  		return i == pkgDir
   311  	}
   312  	return false
   313  }
   314  
   315  func (g *Generate) rule(rule *Rule) *bazelbuild.Rule {
   316  	r := NewRule(rule.kind, rule.name)
   317  	populateRule(r, rule)
   318  	r.SetAttr("visibility", NewStringList([]string{"PUBLIC"}))
   319  	if rule.kind == "go_library" {
   320  		r.SetAttr("cover", &bazelbuild.Ident{Name: "False"})
   321  	}
   322  
   323  	return r
   324  }
   325  
   326  // parseOrCreateBuildFile loops through the available build file names to create a new build file or open the existing
   327  // one.
   328  func parseOrCreateBuildFile(path string, fileNames []string) (*bazelbuild.File, error) {
   329  	for _, name := range fileNames {
   330  		filePath := filepath.Join(path, name)
   331  		if f, err := os.Lstat(filePath); os.IsNotExist(err) {
   332  			return bazelbuild.ParseBuild(filePath, nil)
   333  		} else if !f.IsDir() {
   334  			bs, err := os.ReadFile(filePath)
   335  			if err != nil {
   336  				return nil, err
   337  			}
   338  			return bazelbuild.ParseBuild(filePath, bs)
   339  		}
   340  	}
   341  	return nil, fmt.Errorf("folders exist with the build file names in directory %v %v", path, fileNames)
   342  }
   343  
   344  func saveBuildFile(buildFile *bazelbuild.File) error {
   345  	f, err := os.Create(buildFile.Path)
   346  	if err != nil {
   347  		return err
   348  	}
   349  	defer f.Close()
   350  
   351  	_, err = f.Write(bazelbuild.Format(buildFile))
   352  	return err
   353  }
   354  
   355  func (g *Generate) createBuildFile(pkg string, rule *Rule, aFiles []string) error {
   356  	buildFile, err := parseOrCreateBuildFile(g.pkgDir(pkg), g.buildFileNames)
   357  	if err != nil {
   358  		return err
   359  	}
   360  
   361  	var subincludes []bazelbuild.Expr
   362  	if strings.HasPrefix(rule.kind, "cgo") {
   363  		subincludes = []bazelbuild.Expr{NewStringExpr("///go//build_defs:cgo")}
   364  	} else {
   365  		subincludes = []bazelbuild.Expr{NewStringExpr("///go//build_defs:go")}
   366  	}
   367  
   368  	buildFile.Stmt = []bazelbuild.Expr{
   369  		&bazelbuild.CallExpr{
   370  			X:    &bazelbuild.Ident{Name: "subinclude"},
   371  			List: subincludes,
   372  		},
   373  	}
   374  
   375  	buildFile.Stmt = append(buildFile.Stmt, g.rule(rule).Call)
   376  
   377  	if len(aFiles) != 0 {
   378  		filegroup := NewRule("filegroup", "a_files")
   379  		filegroup.SetAttr("srcs", NewStringList(aFiles))
   380  		buildFile.Stmt = append(buildFile.Stmt, filegroup.Call)
   381  	}
   382  
   383  	return saveBuildFile(buildFile)
   384  }
   385  
   386  func NewRule(kind, name string) *bazelbuild.Rule {
   387  	rule, _ := bazeledit.ExprToRule(&bazelbuild.CallExpr{
   388  		X:    &bazelbuild.Ident{Name: kind},
   389  		List: []bazelbuild.Expr{},
   390  	}, kind)
   391  
   392  	rule.SetAttr("name", NewStringExpr(name))
   393  
   394  	return rule
   395  }
   396  
   397  func NewStringExpr(s string) *bazelbuild.StringExpr {
   398  	return &bazelbuild.StringExpr{Value: s}
   399  }
   400  
   401  func NewStringList(ss []string) *bazelbuild.ListExpr {
   402  	l := new(bazelbuild.ListExpr)
   403  	for _, s := range ss {
   404  		l.List = append(l.List, NewStringExpr(s))
   405  	}
   406  	return l
   407  }
   408  
   409  func packageKind(pkg *build.Package) string {
   410  	cgo := len(pkg.CgoFiles) > 0
   411  	if pkg.IsCommand() && cgo {
   412  		return "cgo_binary"
   413  	}
   414  	if pkg.IsCommand() {
   415  		return "go_binary"
   416  	}
   417  	if cgo {
   418  		return "cgo_library"
   419  	}
   420  	return "go_library"
   421  }
   422  
   423  func (g *Generate) depTargets(imports []string) []string {
   424  	deps := make([]string, 0)
   425  	for _, path := range imports {
   426  		target := g.depTarget(path)
   427  		if target == "" {
   428  			continue
   429  		}
   430  		deps = append(deps, target)
   431  	}
   432  	return deps
   433  }
   434  
   435  func (g *Generate) ruleForPackage(pkg *build.Package, dir string) *Rule {
   436  	if len(pkg.GoFiles) == 0 && len(pkg.CgoFiles) == 0 {
   437  		return nil
   438  	}
   439  
   440  	name := nameForLibInPkg(g.moduleName, trimPath(dir, g.srcRoot))
   441  	deps := g.depTargets(pkg.Imports)
   442  	if len(pkg.IgnoredOtherFiles) != 0 {
   443  		deps = append(deps, ":a_files")
   444  	}
   445  
   446  	return &Rule{
   447  		name:          name,
   448  		kind:          packageKind(pkg),
   449  		srcs:          pkg.GoFiles,
   450  		module:        g.moduleArg,
   451  		subrepo:       g.subrepo,
   452  		cgoSrcs:       pkg.CgoFiles,
   453  		cSrcs:         pkg.CFiles,
   454  		compilerFlags: pkg.CgoCFLAGS,
   455  		linkerFlags:   orderLinkerFlags(pkg.CgoLDFLAGS),
   456  		pkgConfigs:    pkg.CgoPkgConfig,
   457  		asmFiles:      pkg.SFiles,
   458  		hdrs:          pkg.HFiles,
   459  		deps:          deps,
   460  		embedPatterns: pkg.EmbedPatterns,
   461  		isCMD:         pkg.IsCommand(),
   462  	}
   463  }
   464  
   465  // orderLinkerFlags collapses linker flags into one to enforce a consistent ordering
   466  func orderLinkerFlags(in []string) []string {
   467  	if len(in) > 0 {
   468  		return []string{strings.Join(in, " ")}
   469  	}
   470  	return nil
   471  }
   472  
   473  func (g *Generate) depTarget(importPath string) string {
   474  	if target, ok := g.knownImportTargets[importPath]; ok {
   475  		return target
   476  	}
   477  
   478  	if replacement, ok := g.replace[importPath]; ok && replacement != importPath {
   479  		target := g.depTarget(replacement)
   480  		g.knownImportTargets[importPath] = target
   481  		return target
   482  	}
   483  
   484  	module := ""
   485  	for _, mod := range append(g.moduleDeps, g.moduleName) {
   486  		if strings.HasPrefix(importPath, mod) {
   487  			if len(module) < len(mod) {
   488  				module = mod
   489  			}
   490  		}
   491  	}
   492  
   493  	if module == "" {
   494  		// If we can't find this import, we can return nothing and the build rule will fail at build time reporting a
   495  		// sensible error. It may also be an import from the go SDK which is fine.
   496  		return ""
   497  	}
   498  
   499  	subrepoName := g.subrepoName(module)
   500  	packageName := trimPath(importPath, module)
   501  	name := nameForLibInPkg(module, packageName)
   502  
   503  	target := buildTarget(name, packageName, subrepoName)
   504  	g.knownImportTargets[importPath] = target
   505  	return target
   506  }
   507  
   508  // nameForLibInPkg returns the lib target name for a target in pkg. The pkg should be the relative pkg part excluding
   509  // the module, e.g. pkg would be asset, and module would be github.com/stretchr/testify for
   510  // github.com/stretchr/testify/assert,
   511  func nameForLibInPkg(module, pkg string) string {
   512  	name := filepath.Base(pkg)
   513  	if pkg == "" || pkg == "." {
   514  		name = filepath.Base(module)
   515  	}
   516  
   517  	if name == "all" {
   518  		return "lib"
   519  	}
   520  
   521  	return name
   522  }
   523  
   524  // trimPath is like strings.TrimPrefix but is path aware. It removes base from target if target starts with base,
   525  // otherwise returns target unmodified.
   526  func trimPath(target, base string) string {
   527  	baseParts := strings.Split(filepath.Clean(base), "/")
   528  	targetParts := strings.Split(filepath.Clean(target), "/")
   529  
   530  	if len(targetParts) < len(baseParts) {
   531  		return target
   532  	}
   533  
   534  	for i := range baseParts {
   535  		if baseParts[i] != targetParts[i] {
   536  			return target
   537  		}
   538  	}
   539  	return strings.Join(targetParts[len(baseParts):], "/")
   540  }
   541  
   542  // libTargetForBuildFile finds the go_library or cgo_library target in the package
   543  func (g *Generate) libTargetForBuildFile(path string) (string, error) {
   544  	bs, err := os.ReadFile(filepath.Join(g.srcRoot, path))
   545  	if err != nil {
   546  		return "", err
   547  	}
   548  	file, err := bazelbuild.ParseBuild(path, bs)
   549  	if err != nil {
   550  		return "", err
   551  	}
   552  
   553  	libs := append(file.Rules("go_library"), file.Rules("cgo_library")...)
   554  	if len(libs) >= 1 {
   555  		if len(libs) != 1 {
   556  			log.Fatalf("more than one go library in installed package %v", path)
   557  		}
   558  		return buildTarget(libs[0].Name(), filepath.Dir(path), ""), nil
   559  	}
   560  	return "", nil
   561  }
   562  
   563  func (g *Generate) subrepoName(module string) string {
   564  	if g.moduleName == module {
   565  		return ""
   566  	}
   567  	return filepath.Join(g.thirdPartyFolder, strings.ReplaceAll(module, "/", "_"))
   568  }
   569  
   570  func (g *Generate) libTargetForBuildPackage(i string) (string, error) {
   571  	entries, err := os.ReadDir(filepath.Join(g.srcRoot, i))
   572  	if err != nil {
   573  		return "", err
   574  	}
   575  
   576  	for _, e := range entries {
   577  		if g.isBuildFile(e.Name()) {
   578  			t, err := g.libTargetForBuildFile(filepath.Join(i, e.Name()))
   579  			if err != nil {
   580  				return "", err
   581  			}
   582  			return t, nil
   583  		}
   584  	}
   585  	return "", nil
   586  }
   587  
   588  func buildTarget(name, pkgDir, subrepo string) string {
   589  	bs := new(strings.Builder)
   590  	if subrepo != "" {
   591  		bs.WriteString("///")
   592  		bs.WriteString(subrepo)
   593  	}
   594  
   595  	// Bit of a special case here where we assume all build targets are absolute which is fine for our use case.
   596  	bs.WriteString("//")
   597  
   598  	if pkgDir == "." {
   599  		pkgDir = ""
   600  	}
   601  
   602  	if pkgDir != "" {
   603  		bs.WriteString(pkgDir)
   604  		if filepath.Base(pkgDir) != name {
   605  			bs.WriteString(":")
   606  			bs.WriteString(name)
   607  		}
   608  	} else {
   609  		bs.WriteString(":")
   610  		bs.WriteString(name)
   611  	}
   612  	return bs.String()
   613  }