| // Copyright 2026 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // Package gentools provides shared helper utilities for Go code generator tools | |
| // in archsimd. | |
| // | |
| // Basic usage: | |
| // | |
| // func main() { | |
| // gentools.RegisterFlags(nil) | |
| // flag.Parse() | |
| // | |
| // var files gentools.Files | |
| // defer files.FlushOrExit() | |
| // | |
| // buf := files.NewGoFile("src/simd/archsimd/ops_amd64.go") | |
| // fmt.Fprintln(buf, "package archsimd") | |
| // // ... write generated code to buf ... | |
| // } | |
| // | |
| // By default (when -w is not specified), gentools outputs all generated files | |
| // as a txtar archive to standard output. Pass -w to write files directly into | |
| // the Go source tree. | |
| package gentools | |
| import ( | |
| "bytes" | |
| "flag" | |
| "fmt" | |
| "go/format" | |
| "go/scanner" | |
| "go/token" | |
| "io" | |
| "io/fs" | |
| "os" | |
| "path/filepath" | |
| "strings" | |
| "sync" | |
| ) | |
| // Options contains standard options and CLI flags for code generators. | |
| type Options struct { | |
| GOROOT string // -goroot: root of the input Go source tree | |
| outDir string // -outdir: root of the output tree (defaults to GOROOT) | |
| Write bool // -w: write generated files to disk under GOROOT | |
| Diff bool // -diff: check if generated files match disk, print diffs if not | |
| Txtar bool // -txtar: write generated files to output as a txtar archive (default output mode) | |
| Output io.Writer // output writer for txtar and diff mode; defaults to os.Stdout if nil | |
| ErrOutput io.Writer // error writer for formatting errors; defaults to os.Stderr if nil | |
| } | |
| var globalOptions *Options | |
| // RegisterFlags registers standard generator flags with the provided FlagSet | |
| // (or [flag.CommandLine] if fs is nil) and returns a pointer to the Options | |
| // struct. | |
| // | |
| // If fs is nil, the returned options are remembered globally as defaults for | |
| // zero-value Files instances. This should only be used in the main module. | |
| func RegisterFlags(fs *flag.FlagSet) *Options { | |
| o := new(Options) | |
| if fs == nil { | |
| fs = flag.CommandLine | |
| globalOptions = o | |
| } | |
| defaultGOROOT := DefaultGOROOT() | |
| fs.StringVar(&o.GOROOT, "goroot", defaultGOROOT, "source Go dev tree") | |
| fs.StringVar(&o.outDir, "outdir", "", "output directory (default: set to -goroot)") | |
| fs.BoolVar(&o.Write, "w", false, "write generated files directly to disk under -outdir") | |
| fs.BoolVar(&o.Diff, "diff", false, "compare generated files against disk and print unified diffs") | |
| fs.BoolVar(&o.Txtar, "txtar", false, "output generated files as a txtar archive to stdout (default mode)") | |
| return o | |
| } | |
| // InputPath resolves relPath relative to either o.OutDir/src, if that file | |
| // exists, or o.GOROOT/src. In effect, o.OutDir is treated as an overlay on | |
| // o.GOROOT. | |
| func (o *Options) InputPath(relPath string) string { | |
| if o.outDir != o.GOROOT { | |
| path := o.OutputPath(relPath) | |
| if _, err := os.Stat(path); err == nil { | |
| return path | |
| } | |
| } | |
| return filepath.Join(o.GOROOT, "src", relPath) | |
| } | |
| // ReadFile reads relPath from either o.OutDir/src or o.GOROOT/src. | |
| func (o *Options) ReadFile(relPath string) ([]byte, error) { | |
| return os.ReadFile(o.InputPath(relPath)) | |
| } | |
| // OutputPath returns relPath relative to o.OutDir/src. | |
| func (o *Options) OutputPath(relPath string) string { | |
| outDir := o.outDir | |
| if outDir == "" { | |
| outDir = o.GOROOT | |
| } | |
| return filepath.Join(outDir, "src", relPath) | |
| } | |
| // WritingToInput returns true if Flush will write to the input tree. | |
| func (o *Options) WritingToInput() bool { | |
| return o.Write && (o.outDir == "" || o.outDir == o.GOROOT) | |
| } | |
| // OverlayDir returns the root directory containing overlay files if an overlay | |
| // is active (i.e. -outdir is set and different from -goroot), or "" otherwise. | |
| func (o *Options) OverlayDir() string { | |
| if o != nil && o.outDir != "" && o.outDir != o.GOROOT { | |
| return o.outDir | |
| } | |
| return "" | |
| } | |
| type fileInfo struct { | |
| relPath string | |
| isGo bool | |
| buf bytes.Buffer | |
| } | |
| // Files manages a collection of generated files for a single generator run. | |
| // The zero value of Files is ready for immediate use and automatically honors | |
| // the command-line flags registered via RegisterFlags. | |
| type Files struct { | |
| // Options optionally overrides the generator options for this Files instance. | |
| // If nil, the globally registered options from RegisterFlags are used automatically. | |
| Options *Options | |
| files []*fileInfo | |
| postProcessors []PostProcessor | |
| fileSet map[string]*fileInfo // By fileInfo.relPath | |
| // tmpDir is a temporary directory used for communicating with subprocess | |
| // gentools. | |
| tmpDirOnce sync.Once | |
| tmpDir string | |
| } | |
| func (f *Files) getOptions() Options { | |
| var opts Options | |
| if f != nil && f.Options != nil { | |
| opts = *f.Options | |
| } else if globalOptions != nil { | |
| opts = *globalOptions | |
| } | |
| if opts.GOROOT == "" { | |
| opts.GOROOT = DefaultGOROOT() | |
| } | |
| if opts.Output == nil { | |
| opts.Output = os.Stdout | |
| } | |
| if opts.ErrOutput == nil { | |
| opts.ErrOutput = os.Stderr | |
| } | |
| if !(opts.Write || opts.Diff || opts.Txtar) { | |
| opts.Txtar = true | |
| } | |
| return opts | |
| } | |
| // NewGoFile registers a Go source file at relPath (relative to GOROOT/src). It | |
| // returns a *bytes.Buffer for the generator to populate. During Flush(), Go | |
| // files are formatted with go/format. | |
| func (f *Files) NewGoFile(relPath string) *bytes.Buffer { | |
| return f.newFile(&fileInfo{ | |
| relPath: relPath, | |
| isGo: true, | |
| }) | |
| } | |
| // NewRawFile registers a non-Go file (e.g. .rules, YAML, txtar) at relPath | |
| // (relative to GOROOT/src). It returns a *bytes.Buffer for the generator to | |
| // populate. During Flush(), content is written directly without go/format. | |
| func (f *Files) NewRawFile(relPath string) *bytes.Buffer { | |
| return f.newFile(&fileInfo{ | |
| relPath: relPath, | |
| isGo: false, | |
| }) | |
| } | |
| func (f *Files) newFile(info *fileInfo) *bytes.Buffer { | |
| if f.fileSet == nil { | |
| f.fileSet = make(map[string]*fileInfo) | |
| } | |
| if f.fileSet[info.relPath] != nil { | |
| panic(fmt.Sprintf("generator already created file %s", info.relPath)) | |
| } | |
| f.fileSet[info.relPath] = info | |
| if opts := f.getOptions(); opts.Write && !opts.WritingToInput() { | |
| path := opts.OutputPath(info.relPath) | |
| if _, err := os.Stat(path); err == nil { | |
| panic(fmt.Sprintf("file already exists in output directory (created by multiple generators?): %s", path)) | |
| } | |
| } | |
| f.files = append(f.files, info) | |
| return &info.buf | |
| } | |
| // PostProcessor is a function that inspects or transforms file content before | |
| // it is written, diffed, or packed into a txtar archive. | |
| // | |
| // relPath is the file path relative to GOROOT/src. isGo indicates whether the | |
| // file was registered as a Go file (via NewGoFile). | |
| type PostProcessor func(relPath string, isGo bool, content []byte) ([]byte, error) | |
| // AddPostProcessor registers a post-processing hook to be run on generated | |
| // files during Flush before writing or diffing. Post-processors are called in | |
| // registration order. | |
| func (f *Files) AddPostProcessor(fn PostProcessor) { | |
| f.postProcessors = append(f.postProcessors, fn) | |
| } | |
| // ExecFlags returns a sequence of flags that can be passed to a gentools | |
| // subprocess. This allows several gentools to be tied together by a larger | |
| // gentool, including if later gentools read the outputs of earlier gentools. | |
| // | |
| // Regardless of the output mode of f, this directs subprocesses to write to a | |
| // temporary directory. Flush then reads the contents of this temporary | |
| // directory back as if this process had written all of those files using f and | |
| // applies the configured output mode. | |
| func (f *Files) ExecFlags() []string { | |
| f.tmpDirOnce.Do(func() { | |
| tmpDir, err := os.MkdirTemp("", "") | |
| if err != nil { | |
| panic("failed to create tmpdir: " + err.Error()) | |
| } | |
| f.tmpDir = tmpDir | |
| }) | |
| return []string{"-goroot", f.getOptions().GOROOT, "-w", "-outdir", f.tmpDir} | |
| } | |
| // Flush outputs all registered files according to the mode in options. | |
| // | |
| // In default / -txtar mode, it outputs files as a txtar archive to Output. In | |
| // write mode (-w), it writes all files to disk under GOROOT. In diff mode | |
| // (-diff), it compares generated content against disk, prints diffs to Output, | |
| // and returns an error if out of date. | |
| func (f *Files) Flush() error { | |
| opts := f.getOptions() | |
| if (opts.Write || opts.Diff) && opts.GOROOT == "" { | |
| return fmt.Errorf("GOROOT not found; pass -goroot flag") | |
| } | |
| type preparedFile struct { | |
| relPath string | |
| content []byte | |
| } | |
| prepared := make([]preparedFile, len(f.files)) | |
| // If we invoked subprocesses, read their output files. | |
| if f.tmpDir != "" { | |
| root := filepath.Join(f.tmpDir, "src") | |
| err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { | |
| if d.IsDir() { | |
| return nil | |
| } | |
| relPath, ok := strings.CutPrefix(path, root) | |
| if !ok { | |
| return fmt.Errorf("expected path %q to start with root %q", path, root) | |
| } | |
| content, err := os.ReadFile(path) | |
| if err != nil { | |
| return err | |
| } | |
| prepared = append(prepared, preparedFile{relPath, content}) | |
| return nil | |
| }) | |
| if err != nil { | |
| return err | |
| } | |
| os.RemoveAll(f.tmpDir) | |
| } | |
| for i, fi := range f.files { | |
| content := fi.buf.Bytes() | |
| for _, pp := range f.postProcessors { | |
| var err error | |
| content, err = pp(fi.relPath, fi.isGo, content) | |
| if err != nil { | |
| return err | |
| } | |
| } | |
| if fi.isGo { | |
| formatted, err := format.Source(content) | |
| if err != nil { | |
| printFormattingError(opts.ErrOutput, fi.relPath, content, err) | |
| return fmt.Errorf("error formatting %s: %w", fi.relPath, err) | |
| } | |
| content = formatted | |
| } | |
| prepared[i] = preparedFile{ | |
| relPath: fi.relPath, | |
| content: content, | |
| } | |
| } | |
| f.files = nil | |
| if opts.Diff { | |
| hasDiffs := false | |
| for _, pf := range prepared { | |
| onDisk, err := opts.ReadFile(pf.relPath) | |
| if err != nil && !os.IsNotExist(err) { | |
| return fmt.Errorf("reading %s for diff: %w", pf.relPath, err) | |
| } | |
| srcPath := filepath.Join("src", pf.relPath) | |
| d := Diff(srcPath, onDisk, srcPath, pf.content) | |
| if len(d) > 0 { | |
| hasDiffs = true | |
| opts.Output.Write(d) | |
| } | |
| } | |
| if hasDiffs { | |
| return fmt.Errorf("generated files differ from disk") | |
| } | |
| } | |
| if opts.Txtar { | |
| for i, pf := range prepared { | |
| if i > 0 { | |
| fmt.Fprintln(opts.Output) | |
| } | |
| srcPath := filepath.Join("src", pf.relPath) | |
| fmt.Fprintf(opts.Output, "-- %s --\n", srcPath) | |
| opts.Output.Write(pf.content) | |
| // Ensure trailing \n | |
| if len(pf.content) > 0 && !bytes.HasSuffix(pf.content, []byte("\n")) { | |
| fmt.Fprintln(opts.Output) | |
| } | |
| } | |
| } | |
| if opts.Write { | |
| for _, pf := range prepared { | |
| path := opts.OutputPath(pf.relPath) | |
| dir := filepath.Dir(path) | |
| if err := os.MkdirAll(dir, 0755); err != nil { | |
| return fmt.Errorf("creating directory %s: %w", dir, err) | |
| } | |
| if err := os.WriteFile(path, pf.content, 0644); err != nil { | |
| return fmt.Errorf("writing %s: %w", path, err) | |
| } | |
| } | |
| } | |
| return nil | |
| } | |
| // FlushOrExit calls Flush(), prints any error to stderr, and exits with code 1 if Flush fails. | |
| // | |
| // It is intended to be deferred at the beginning of main (e.g., `defer files.FlushOrExit()`). | |
| // Hence, if invoked as part of a panic, it skips flushing and instead allows the panic to propagate. | |
| func (f *Files) FlushOrExit() { | |
| if r := recover(); r != nil { | |
| panic(r) | |
| } | |
| if err := f.Flush(); err != nil { | |
| fmt.Fprintf(os.Stderr, "%v\n", err) | |
| os.Exit(1) | |
| } | |
| } | |
| // printFormattingError prints err, with 10 lines of context around the error | |
| // line and a caret mark ("^") to indicate the column offset of the error. | |
| func printFormattingError(out io.Writer, relPath string, raw []byte, err error) { | |
| var pos token.Position | |
| if el, ok := err.(scanner.ErrorList); ok && len(el) > 0 { | |
| el.Sort() | |
| pos = el[0].Pos | |
| } else if e, ok := err.(*scanner.Error); ok { | |
| pos = e.Pos | |
| } else if e, ok := err.(scanner.Error); ok { | |
| pos = e.Pos | |
| } | |
| lines := strings.Split(string(raw), "\n") | |
| if len(lines) > 0 && lines[len(lines)-1] == "" { | |
| lines = lines[:len(lines)-1] | |
| } | |
| if pos.Line <= 0 || pos.Line > len(lines) { | |
| fmt.Fprintf(out, "error formatting %s: %v\n", relPath, err) | |
| fmt.Fprintf(out, "%s\n", raw) | |
| return | |
| } | |
| startLine := max(pos.Line-5, 1) | |
| endLine := min(pos.Line+5, len(lines)) | |
| for i := startLine; i <= endLine; i++ { | |
| line := lines[i-1] | |
| fmt.Fprintf(out, "%s\n", line) | |
| if i == pos.Line { | |
| var indent strings.Builder | |
| for _, ch := range line { | |
| pos.Column-- | |
| if pos.Column == 0 { | |
| break | |
| } | |
| if ch == '\t' { | |
| indent.WriteByte('\t') | |
| } else { | |
| indent.WriteByte(' ') | |
| } | |
| } | |
| fmt.Fprintf(out, "%s^\n", indent.String()) | |
| fmt.Fprintf(out, "%s\n", strings.TrimRight(err.Error(), "\n")) | |
| } | |
| } | |
| } | |
| func DefaultGOROOT() string { | |
| cwd, err := os.Getwd() | |
| if err != nil { | |
| return "" | |
| } | |
| dir := cwd | |
| for { | |
| parent := filepath.Dir(dir) | |
| if parent == dir { | |
| return "" | |
| } | |
| if filepath.Base(dir) == "src" { | |
| if b, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil { | |
| for line := range strings.SplitSeq(string(b), "\n") { | |
| fields := strings.Fields(line) | |
| if len(fields) >= 2 && fields[0] == "module" && fields[1] == "std" { | |
| return parent | |
| } | |
| } | |
| } | |
| } | |
| dir = parent | |
| } | |
| } | |
| func resolvePath(goroot, relPath string) string { | |
| clean := cleanRelPath(relPath) | |
| if goroot == "" { | |
| return clean | |
| } | |
| return filepath.Join(goroot, clean) | |
| } | |
| func cleanRelPath(p string) string { | |
| p = strings.ReplaceAll(p, "\\", "/") | |
| return filepath.Join("src", p) | |
| } |