From 2e8ae85f30de8e460a7507d75c74b65e32646616 Mon Sep 17 00:00:00 2001 From: Danny Hinshaw Date: Fri, 22 Dec 2023 17:51:56 -0500 Subject: [PATCH] release: added exclude regex, include packages, and better logging. --- Makefile | 13 +- cmd/converge.go | 18 -- cmd/converge_test.go | 2 +- cmd/options.go | 20 ++ gonverge/worker.go | 135 ----------- internal/cli/cli.go | 130 ++++++++++ {gonverge => internal/gonverge}/gofile.go | 3 +- {gonverge => internal/gonverge}/gonverge.go | 29 ++- .../gonverge}/gonverge_test.go | 3 +- {gonverge => internal/gonverge}/options.go | 22 +- {gonverge => internal/gonverge}/processor.go | 16 +- internal/gonverge/worker.go | 225 ++++++++++++++++++ internal/logger/logger.go | 115 +++++++++ internal/logger/noop.go | 26 ++ internal/logger/options.go | 22 ++ internal/version/version.go | 77 ------ main.go | 171 +++++-------- 17 files changed, 663 insertions(+), 364 deletions(-) create mode 100644 cmd/options.go delete mode 100644 gonverge/worker.go create mode 100644 internal/cli/cli.go rename {gonverge => internal/gonverge}/gofile.go (97%) rename {gonverge => internal/gonverge}/gonverge.go (76%) rename {gonverge => internal/gonverge}/gonverge_test.go (95%) rename {gonverge => internal/gonverge}/options.go (53%) rename {gonverge => internal/gonverge}/processor.go (90%) create mode 100644 internal/gonverge/worker.go create mode 100644 internal/logger/logger.go create mode 100644 internal/logger/noop.go create mode 100644 internal/logger/options.go delete mode 100644 internal/version/version.go diff --git a/Makefile b/Makefile index 2d5f223..bb4e632 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,11 @@ COMPOSE = docker compose # at play with developer machines to ensure consistency across all engineering. TOOLS ?= $(COMPOSE) run --rm --service-ports tools +# CGO contains the base Docker Compose command for +# running various Go tools in the tools Compose service +# as ephemeral containers with CGO_ENABLED=1. +CGO ?= $(COMPOSE) run --rm --service-ports -e CGO_ENABLED=1 tools go + # GOFUMPT contains the base Go command for running gofumpt # defaulting to running it in the tools container. GOFUMPT ?= $(TOOLS) gofumpt @@ -38,10 +43,6 @@ PKGSITE := $(TOOLS) pkgsite # environment variable to an empty string. GO ?= $(TOOLS) go -# CGO contains the base Go command for running go with CGO_ENABLED=1 -# and running it in the tools container by default. -CGO ?= $(TOOLS) CGO_ENABLED=1 go - # GOTEST contains the base Go command for running tests. # It can be overridden by setting the GOTEST environment variable. GOTEST ?= $(GO) test @@ -91,7 +92,9 @@ build: build/cli build/compose ## builds the converge CLI binary build/cli: @mkdir -p bin - go build -v -trimpath -o bin/converge + @VERSION=$$(git describe --tags --always || echo "(dev)") && \ + echo "building converge $$VERSION" && \ + go build -v -trimpath -ldflags "-X main.version=$$VERSION" -o bin/converge .PHONY: build/compose ## builds resources diff --git a/cmd/converge.go b/cmd/converge.go index ceec5f4..6401393 100644 --- a/cmd/converge.go +++ b/cmd/converge.go @@ -93,7 +93,6 @@ func (c *Converge) build() error { if c.dst, err = filepath.Abs(c.dst); err != nil { return fmt.Errorf("failed to get absolute path to destination file %s: %w", c.dst, err) } - if c.writer, err = os.Create(c.dst); err != nil { return fmt.Errorf("failed to create destination file %s: %w", c.dst, err) } @@ -153,20 +152,3 @@ func validateDstFile(dst string) error { return nil } } - -// Option is a function that configures a Converge. -type Option func(*Converge) - -// WithWriter sets the writer to use for the output. -func WithWriter(w io.Writer) Option { - return func(c *Converge) { - c.writer = w - } -} - -// WithDstFile sets the destination file to use for the output. -func WithDstFile(dst string) Option { - return func(c *Converge) { - c.dst = dst - } -} diff --git a/cmd/converge_test.go b/cmd/converge_test.go index 2bdd116..21e6e9e 100644 --- a/cmd/converge_test.go +++ b/cmd/converge_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/dannyhinshaw/converge/cmd" - "github.com/dannyhinshaw/converge/gonverge" + "github.com/dannyhinshaw/converge/internal/gonverge" ) func TestConverge_Run(t *testing.T) { diff --git a/cmd/options.go b/cmd/options.go new file mode 100644 index 0000000..d0e05a7 --- /dev/null +++ b/cmd/options.go @@ -0,0 +1,20 @@ +package cmd + +import "io" + +// Option is a function that configures a Converge. +type Option func(*Converge) + +// WithWriter sets the writer to use for the output. +func WithWriter(w io.Writer) Option { + return func(c *Converge) { + c.writer = w + } +} + +// WithDstFile sets the destination file to use for the output. +func WithDstFile(dst string) Option { + return func(c *Converge) { + c.dst = dst + } +} diff --git a/gonverge/worker.go b/gonverge/worker.go deleted file mode 100644 index 22596a7..0000000 --- a/gonverge/worker.go +++ /dev/null @@ -1,135 +0,0 @@ -package gonverge - -import ( - "context" - "io/fs" - "os" - "path/filepath" - "strings" -) - -// fileProducer walks a directory and sends all file paths -// to the given channel for the consumer to process. -type fileProducer struct { - // excludes is a list of files that should - // be excluded from processing in the merge. - excludes []string - - // fpCh is the channel to send file paths to. - fpCh chan<- string - - // errCh is the channel to send errors to. - errCh chan<- error -} - -// newFileProducer returns a new fileProducer. -func newFileProducer(excludes []string, fpCh chan<- string, errCh chan<- error) *fileProducer { - return &fileProducer{ - fpCh: fpCh, - errCh: errCh, - excludes: excludes, - } -} - -// produce walks the given directory and sends all file paths -// to the fpCh channel for the consumer to process. -func (fp *fileProducer) produce(dir string) { - defer close(fp.fpCh) - err := fs.WalkDir(os.DirFS(dir), ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - - info, err := d.Info() - if err != nil { - return err - } - if !validFile(info, fp.excludes) { - return nil - } - - fullPath := filepath.Join(dir, path) - fp.fpCh <- fullPath - - return nil - }) - if err != nil { - fp.errCh <- err - } -} - -// fileConsumer reads file paths from the given channel, -// processes them, and then sends back either the processed -// result or an error (if one occurred). -type fileConsumer struct { - // fpCh is the channel to read file paths from. - fpCh <-chan string - - // resCh is the channel to send processed files to. - resCh chan<- *goFile - - // errCh is the channel to send errors to. - errCh chan error -} - -// newFileConsumer returns a new fileConsumer. -func newFileConsumer(fpCh <-chan string, resCh chan<- *goFile, errCh chan error) *fileConsumer { - return &fileConsumer{ - fpCh: fpCh, - resCh: resCh, - errCh: errCh, - } -} - -// consume consumes file paths from the given channel, -// processes them, and then sends back either the processed -// result or an error (if one occurred). -// -// It will stop processing if an error occurs or if the -// context is cancelled, since this is an all or nothing -// command (can't *half* converge files). -func (fc *fileConsumer) consume(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-fc.errCh: - return - case fp, ok := <-fc.fpCh: - if !ok { - return - } - proc := newFileProcessor(fp) - res, err := proc.process() - if err != nil { - fc.errCh <- err - return - } - fc.resCh <- res - } - } -} - -// validFile checks that the file is a valid *non-test* Go file. -func validFile(fileInfo os.FileInfo, excludes []string) bool { - name := fileInfo.Name() - if !strings.HasSuffix(name, ".go") { - return false - } - if strings.HasSuffix(name, "_test.go") { - return false - } - - // Iterate over excludes and check if the file - // should be excluded from processing. - for _, exclude := range excludes { - if name == exclude { - return false - } - } - - return true -} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..db5222f --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,130 @@ +package cli + +import ( + "flag" + "fmt" +) + +// App is the CLI application struct. +type App struct { + // SrcDir is the path to the source directory + // containing Go source files to be converged. + SrcDir string + + // OutFile is the path to the output file where the + // converged content will be written; defaults to + // stdout if not specified. + OutFile string + + // Packages is a list of specific Packages to include + // in the converged file. + Packages string + + // Exclude is a comma-separated list of regex patterns + // that will be used to Exclude files from converging. + Exclude string + + // Workers is the maximum number of concurrent Workers + // in the worker pool. + Workers int + + // Timeout is the maximum time (in seconds) before + // cancelling the converge operation. + Timeout int + + // VerboseLog enables verbose logging + // for debugging purposes. + VerboseLog bool + + // ShowVersion shows version information and exits. + ShowVersion bool +} + +// NewApp creates a new CLI application struct with the arguments parsed. +func NewApp() App { + var a App + + // OutFile flag (short and long version) + flag.StringVar(&a.OutFile, "f", "", "Output file for merged content; defaults to stdout if not specified") + flag.StringVar(&a.OutFile, "file", "", "") + + // Packages flag (short and long version) + flag.StringVar(&a.Packages, "p", "", "Comma-separated list of packages to include") + flag.StringVar(&a.Packages, "pkg", "", "") + + // Exclude flag (short and long version) + flag.StringVar(&a.Exclude, "e", "", "Comma-separated list of regex patterns to exclude") + flag.StringVar(&a.Exclude, "exclude", "", "") + + // Workers flag (short and long version) + flag.IntVar(&a.Workers, "w", 0, "Maximum number of workers to use for file processing") + flag.IntVar(&a.Workers, "workers", 0, "") + + // Timeout flag (short and long version) + flag.IntVar(&a.Timeout, "t", 0, "Maximum time in seconds before cancelling the operation") + flag.IntVar(&a.Timeout, "timeout", 0, "") + + // Verbose flag (short version only) + flag.BoolVar(&a.VerboseLog, "v", false, "Enable verbose logging") + + // Version flag (long version only) + flag.BoolVar(&a.ShowVersion, "version", false, "Show version information and exit") + + // Custom usage message + flag.Usage = func() { + fmt.Println(a.Usage()) //nolint:forbidigo // not debugging + flag.PrintDefaults() + } + flag.Parse() + + return a +} + +// ParseSrcDir parses the source directory from the positional arguments. +func (a App) ParseSrcDir() (string, error) { + if flag.NArg() < 1 { + return "", fmt.Errorf("source directory is required") + } + + return flag.Arg(0), nil +} + +// Usage returns the usage help message. +func (a App) Usage() string { + return ` + +┏┏┓┏┓┓┏┏┓┏┓┏┓┏┓ +┗┗┛┛┗┗┛┗ ┛ ┗┫┗ + ┛ + +Usage: converge [options] + +The converge tool provides ways to 'converge' multiple Go source files into a single file. +By default it does not converge files in subdirectories and ignores test files (_test.go). + +Arguments: + Path to the directory containing Go source files to be converged. + +Options: + -f, --file Path to the output file where the converged content will be written; + defaults to stdout if not specified. + -p, --pkg List of specific packages to include in the converged file. + Note that if you converge multiple packages the converged file will + not be compilable. + -t, --timeout Maximum time (in seconds) before cancelling the converge operation; + if not specified, the command runs until completion. + -w, --workers Maximum number of concurrent workers in the worker pool. + -e, --exclude Comma-separated list of regex patterns to exclude from converging. + -v Enable verbose logging for debugging purposes. + -h, --help Show this help message and exit. + --version Show version information. + +Examples: + converge . -o converged.go All Go files in current dir into 'converged.go' + converge . -p included_test,included All Go files with package name included_test or included. + converge . -v Run with verbose logging enabled. + converge . -t 60 Run with a timeout of 60 seconds. + converge . -w 4 Run using a maximum of 4 workers. + converge . -e "file1.go,pattern(.*).go" Run while excluding 'file1.go' and 'file2.go'. +` +} diff --git a/gonverge/gofile.go b/internal/gonverge/gofile.go similarity index 97% rename from gonverge/gofile.go rename to internal/gonverge/gofile.go index b0c30f2..b6f6f28 100644 --- a/gonverge/gofile.go +++ b/internal/gonverge/gofile.go @@ -84,7 +84,8 @@ func (f *goFile) FormatCode() ([]byte, error) { builder.WriteString(f.pkgName) builder.WriteString("\n\n") if len(f.imports) > 0 { - builder.WriteString(f.buildImports()) + imports := f.buildImports() + builder.WriteString(imports) } builder.WriteString(f.code.String()) diff --git a/gonverge/gonverge.go b/internal/gonverge/gonverge.go similarity index 76% rename from gonverge/gonverge.go rename to internal/gonverge/gonverge.go index 45d2e7f..8e469d6 100644 --- a/gonverge/gonverge.go +++ b/internal/gonverge/gonverge.go @@ -6,6 +6,8 @@ import ( "io" "runtime" "sync" + + "github.com/dannyhinshaw/converge/internal/logger" ) // GoFileConverger is a struct that converges multiple Go files into one. @@ -15,14 +17,23 @@ type GoFileConverger struct { // process all files in the given directory. MaxWorkers int + // Packages is a list of packages to include in the output. + // If empty, converger will default to top-level NON-TEST + // package in the given directory. + Packages []string + // Excludes is a list of files to exclude from merging. Excludes []string + + // Logger is the logger to use for logging. + Logger logger.LevelLogger } // NewGoFileConverger creates a new GoFileConverger with sensible defaults. func NewGoFileConverger(opts ...Option) *GoFileConverger { gfc := GoFileConverger{ MaxWorkers: runtime.NumCPU(), + Logger: &logger.NoopLogger{}, } for _, opt := range opts { @@ -35,17 +46,15 @@ func NewGoFileConverger(opts ...Option) *GoFileConverger { // ConvergeFiles converges all Go files in the given directory and // package into one and writes the result to the given output. func (gfc *GoFileConverger) ConvergeFiles(ctx context.Context, src string, w io.Writer) error { - // Create channels, filePathsCh is buffered so consumers - // can finish processing their files after the producer + // fpCh is buffered so consumers can finish + // processing their files after the producer // has closed the channel. - // Instead, consumers listen to the errorsCh, so they can - // stop processing if an error occurs. fpCh := make(chan string, gfc.MaxWorkers) resCh := make(chan *goFile) errCh := make(chan error) var wg sync.WaitGroup - // Start consumer goroutines + // Start consumer worker pool for i := 0; i < gfc.MaxWorkers; i++ { wg.Add(1) go func() { @@ -55,8 +64,8 @@ func (gfc *GoFileConverger) ConvergeFiles(ctx context.Context, src string, w io. }() } - // Start producer goroutine - producer := newFileProducer(gfc.Excludes, fpCh, errCh) + // Setup and start producer + producer := newFileProducer(gfc.Logger, gfc.Excludes, gfc.Packages, fpCh, errCh) go producer.produce(src) // Wait for all consumers to finish @@ -89,11 +98,7 @@ func (gfc *GoFileConverger) ConvergeFiles(ctx context.Context, src string, w io. } // buildFile handles running the converger and returning the result or an error. -func (gfc *GoFileConverger) buildFile( - ctx context.Context, - errCh <-chan error, - resCh <-chan *goFile, -) (*goFile, error) { +func (gfc *GoFileConverger) buildFile(ctx context.Context, errCh <-chan error, resCh <-chan *goFile) (*goFile, error) { gf := newGoFile() for { select { diff --git a/gonverge/gonverge_test.go b/internal/gonverge/gonverge_test.go similarity index 95% rename from gonverge/gonverge_test.go rename to internal/gonverge/gonverge_test.go index b52dc4a..88fab4d 100644 --- a/gonverge/gonverge_test.go +++ b/internal/gonverge/gonverge_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/dannyhinshaw/converge/gonverge" + "github.com/dannyhinshaw/converge/internal/gonverge" ) func TestGoFileConverger_ConvergeFiles(t *testing.T) { @@ -104,6 +104,7 @@ func TestGoFileConverger_ConvergeFiles(t *testing.T) { } } +// createTempDirWithFiles creates a temporary directory with the given files for testing. func createTempDirWithFiles(t *testing.T, files map[string]string) string { t.Helper() dir, err := os.MkdirTemp("", "gonverge_test") diff --git a/gonverge/options.go b/internal/gonverge/options.go similarity index 53% rename from gonverge/options.go rename to internal/gonverge/options.go index 588d762..50ad216 100644 --- a/gonverge/options.go +++ b/internal/gonverge/options.go @@ -1,12 +1,21 @@ package gonverge +import "github.com/dannyhinshaw/converge/internal/logger" + // Option is a functional option for the GoFileConverger. type Option func(*GoFileConverger) -// WithMaxWorkers sets the maximum amount of workers to use. -func WithMaxWorkers(maxWorkers int) Option { +// WithLogger sets the logger for the GoFileConverger. +func WithLogger(logger logger.LevelLogger) Option { return func(gfc *GoFileConverger) { - gfc.MaxWorkers = maxWorkers + gfc.Logger = logger + } +} + +// WithPackages sets the list of packages to include in the output. +func WithPackages(packages []string) Option { + return func(gfc *GoFileConverger) { + gfc.Packages = packages } } @@ -16,3 +25,10 @@ func WithExcludes(excludes []string) Option { gfc.Excludes = excludes } } + +// WithMaxWorkers sets the maximum amount of workers to use. +func WithMaxWorkers(maxWorkers int) Option { + return func(gfc *GoFileConverger) { + gfc.MaxWorkers = maxWorkers + } +} diff --git a/gonverge/processor.go b/internal/gonverge/processor.go similarity index 90% rename from gonverge/processor.go rename to internal/gonverge/processor.go index d40566e..828aa8b 100644 --- a/gonverge/processor.go +++ b/internal/gonverge/processor.go @@ -24,14 +24,17 @@ const ( // tokenPkgDecl is the token for a package declaration line. tokenPkgDecl string = `package ` + // tokenImport is the token for import declarations. + tokenImport = `import` + + // tokenImportMono is the token for a single import line. + tokenImportMono = tokenImport + ` "` + // tokenImportMulti is the token that starts an import block. - tokenImportMultiStart = `import (` + tokenImportMultiStart = tokenImport + ` (` // tokenImportMultiEnd is the token that ends an import block. tokenImportMultiFinish = `)` - - // tokenImportMono is the token for a single import line. - tokenImportMono = `import "` ) // fileProcessor holds the *os.File representations @@ -70,10 +73,13 @@ func (p *fileProcessor) process() (*goFile, error) { case strings.HasPrefix(line, tokenImportMultiStart): p.state = procStateImporting case strings.HasPrefix(line, tokenImportMono): - res.addImport(strings.TrimPrefix(line, tokenImportMono)) + res.addImport(strings.TrimPrefix(line, tokenImport)) case p.importing() && strings.HasSuffix(line, tokenImportMultiFinish): p.state = procStateCoding case p.importing(): + if line == "" { + continue + } res.addImport(line) case p.coding(): res.appendCode(line) diff --git a/internal/gonverge/worker.go b/internal/gonverge/worker.go new file mode 100644 index 0000000..94b575c --- /dev/null +++ b/internal/gonverge/worker.go @@ -0,0 +1,225 @@ +package gonverge + +import ( + "context" + "fmt" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/dannyhinshaw/converge/internal/logger" +) + +// fileProducer walks a directory and sends all file paths +// to the given channel for the consumer to process. +type fileProducer struct { + // excludes is a map of files to exclude from processing. + + excludes map[string]regexp.Regexp + + // packages is a set of packages to include + // in the output. If empty, converger will + // default to top-level NON-TEST package in + // the given directory. + packages map[string]struct{} + + // log is the logger to use for logging. + log logger.LevelLogger + + // fpCh is the channel to send file paths to. + fpCh chan<- string + + // errCh is the channel to send errors to. + errCh chan<- error +} + +// newFileProducer returns a new fileProducer. +func newFileProducer(ll logger.LevelLogger, excludes []string, + packages []string, fpCh chan<- string, errCh chan<- error) *fileProducer { + ll = ll.WithGroup("file_producer") + + packageSet := make(map[string]struct{}, len(packages)) + for _, pkg := range packages { + packageSet[pkg] = struct{}{} + } + + excludeSet := make(map[string]regexp.Regexp) + for _, exclude := range excludes { + if exclude == "" { + continue + } + if _, ok := excludeSet[exclude]; ok { + continue + } + + re, err := regexp.Compile(exclude) + if err != nil { + errCh <- fmt.Errorf("error compiling exclude regex: %w", err) + continue + } + if re == nil { + errCh <- fmt.Errorf("error compiling exclude regex: regex is nil") + continue + } + excludeSet[exclude] = *re + } + + return &fileProducer{ + log: ll, + fpCh: fpCh, + errCh: errCh, + excludes: excludeSet, + packages: packageSet, + } +} + +// produce walks the given directory and sends all file paths +// to the fpCh channel for the consumer to process. +func (fp *fileProducer) produce(dir string) { + defer close(fp.fpCh) + + err := fs.WalkDir(os.DirFS(dir), ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("error walking directory: %w", err) + } + if d.IsDir() { + return nil + } + + info, err := d.Info() + if err != nil { + return fmt.Errorf("error getting file info: %w", err) + } + + fullPath := filepath.Join(dir, path) + if !fp.validFile(info.Name(), fullPath) { + fp.log.Info("file is not valid", "fullPath", fullPath) + return nil + } + + fp.log.Info("file is valid", "fullPath", fullPath) + fp.fpCh <- fullPath + + return nil + }) + if err != nil { + fp.errCh <- err + } +} + +// validFile checks that the file is a valid *non-test* Go file. +func (fp *fileProducer) validFile(name, fullPath string) bool { + checkPkgs := len(fp.packages) > 0 + + // Check if the file should be excluded from processing. + for _, re := range fp.excludes { + if re.MatchString(name) { + fp.log.Info("file %s excluded from processing", name) + return false + } + } + + // Packages specified overrides all other checks. + if checkPkgs { + if fp.validPackage(fullPath) { + fp.log.Info("file is valid package", "name", name) + return true + } + return false + } + + // Only process Go files + if !strings.HasSuffix(name, ".go") { + return false + } + + // Ignore test files by default. + // NOTE: If you need to converge test files, you can + // do so by specifying the package name in the packages + // option. This will override the default behavior. + if strings.HasSuffix(name, "_test.go") { + return false + } + + return true +} + +// validPackage checks that the file is a valid Go file and that +// the package name is in the set of packages to include. +func (fp *fileProducer) validPackage(fullPath string) bool { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, fullPath, nil, parser.PackageClauseOnly) + if err != nil { + fp.log.Info("error parsing file %s: %v", fullPath, err) + return false + } + + if node == nil || node.Name == nil { + fp.log.Info("error parsing file %s: %v", fullPath, err) + return false + } + + name := node.Name.Name + if _, ok := fp.packages[name]; !ok { + fp.log.Info("package not in packages set", "name", name, "set", fp.packages) + return false + } + + return true +} + +// fileConsumer reads file paths from the given channel, +// processes them, and then sends back either the processed +// result or an error (if one occurred). +type fileConsumer struct { + // fpCh is the channel to read file paths from. + fpCh <-chan string + + // resCh is the channel to send processed files to. + resCh chan<- *goFile + + // errCh is the channel to send errors to. + errCh chan error +} + +// newFileConsumer returns a new fileConsumer. +func newFileConsumer(fpCh <-chan string, resCh chan<- *goFile, errCh chan error) *fileConsumer { + return &fileConsumer{ + fpCh: fpCh, + resCh: resCh, + errCh: errCh, + } +} + +// consume consumes file paths from the given channel, +// processes them, and then sends back either the processed +// result or an error (if one occurred). +// +// It will stop processing if an error occurs or if the +// context is cancelled, since this is an all or nothing +// command (can't *half* converge files). +func (fc *fileConsumer) consume(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-fc.errCh: + return + case fp, ok := <-fc.fpCh: + if !ok { + return + } + proc := newFileProcessor(fp) + res, err := proc.process() + if err != nil { + fc.errCh <- fmt.Errorf("error processing file: %w", err) + return + } + fc.resCh <- res + } + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..499cd93 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,115 @@ +package logger + +import ( + "log/slog" + "os" +) + +// LevelLogger is a logger that supports different levels of logging. +type LevelLogger interface { + // Debug handles logging the given message at the debug level. + Debug(msg string, args ...any) + + // Info handles logging the given message at the info level. + Info(msg string, args ...any) + + // Warn handles logging the given message at the warn level. + Warn(msg string, args ...any) + + // Error handles logging the given message at the error level. + Error(msg string, args ...any) + + // WithGroup creates a new child/group Logger from the current logger. + WithGroup(group string) LevelLogger + + // Verbose returns true if the logger is in verbose mode. + Verbose() bool +} + +// Logger is a wrapper around slog.Logger. +type Logger struct { + // Level is the log Level to use for + // filtering log verbosity. + Level slog.Level + + // Slogger is the underlying slog.Logger + // that is used for logging. + Slogger *slog.Logger + + // handler is the handler to use for + // the loggers structured output. + handler slog.Handler + + // parent is the parent logger to use + // for creating the Slogger as a child + // in the New function. + parent *slog.Logger +} + +// New returns a new Logger with the given name and level. +func New(name string, opts ...Option) *Logger { + // Base logger defaults to error level. + logger := Logger{ + Level: slog.LevelError, + } + + // Apply optional configurations. + for _, opt := range opts { + opt(&logger) + } + + // If no handler was provided, default to + // a text handler that writes to stderr. + if logger.handler == nil { + logger.handler = slog.NewTextHandler( + os.Stderr, &slog.HandlerOptions{ + Level: logger.Level, + }) + } + + // If a parent logger was provided, use it + // to create the Slogger as a child. + var root *slog.Logger + if logger.parent != nil { + root = logger.parent + } else { + root = slog.New(logger.handler) + } + + // Create a new logger with the given name. + logger.Slogger = root.WithGroup(name) + + return &logger +} + +// Debug handles proxying the given message to the underlying slog.Logger at the debug level. +func (l *Logger) Debug(msg string, args ...any) { + l.Slogger.Debug(msg, args...) +} + +// Info handles proxying the given message to the underlying slog.Logger at the info level. +func (l *Logger) Info(msg string, args ...any) { + l.Slogger.Info(msg, args...) +} + +// Warn handles proxying the given message to the underlying slog.Logger at the warn level. +func (l *Logger) Warn(msg string, args ...any) { + l.Slogger.Warn(msg, args...) +} + +// Error handles proxying the given message to the underlying slog.Logger at the error level. +func (l *Logger) Error(msg string, args ...any) { + l.Slogger.Error(msg, args...) +} + +// WithGroup creates a new child/group Logger from the current logger. +func (l *Logger) WithGroup(group string) LevelLogger { + return &Logger{ + Slogger: l.Slogger.WithGroup(group), + } +} + +// Verbose returns true if the logger is in verbose mode. +func (l *Logger) Verbose() bool { + return l.Level == slog.LevelDebug +} diff --git a/internal/logger/noop.go b/internal/logger/noop.go new file mode 100644 index 0000000..6727a05 --- /dev/null +++ b/internal/logger/noop.go @@ -0,0 +1,26 @@ +package logger + +// NoopLogger is a logger that does nothing. +type NoopLogger struct{} + +// Debug handles logging the given message at the debug level. +func (l *NoopLogger) Debug(msg string, args ...any) {} //nolint:revive //unused + +// Info handles logging the given message at the info level. +func (l *NoopLogger) Info(msg string, args ...any) {} //nolint:revive //unused + +// Warn handles logging the given message at the warn level. +func (l *NoopLogger) Warn(msg string, args ...any) {} //nolint:revive //unused + +// Error handles logging the given message at the error level. +func (l *NoopLogger) Error(msg string, args ...any) {} //nolint:revive //unused + +// WithGroup creates a new child/group Logger from the current logger. +func (l *NoopLogger) WithGroup(group string) LevelLogger { //nolint:revive //unused + return l +} + +// Verbose returns true if the logger is in verbose mode. +func (l *NoopLogger) Verbose() bool { + return false +} diff --git a/internal/logger/options.go b/internal/logger/options.go new file mode 100644 index 0000000..47e11e0 --- /dev/null +++ b/internal/logger/options.go @@ -0,0 +1,22 @@ +package logger + +import "log/slog" + +type Option func(*Logger) + +// WithHandler sets the handler for the logger. +func WithHandler(h slog.Handler) Option { + return func(l *Logger) { + l.handler = h + } +} + +// WithVerbose sets the logger to verbose mode by way of +// setting the log level to debug. +func WithVerbose(v bool) Option { + return func(l *Logger) { + if v { + l.Level = slog.LevelDebug + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go deleted file mode 100644 index 82c877d..0000000 --- a/internal/version/version.go +++ /dev/null @@ -1,77 +0,0 @@ -package version - -import ( - "fmt" - "os/exec" - "runtime/debug" - "strings" - "time" -) - -const ( - gitRevShortLen = 12 - fallbackVersion = "(devel)" -) - -// GetVersion retrieves the version of the module dynamically at runtime. -func GetVersion() string { - info, ok := debug.ReadBuildInfo() - if !ok { - return getGitVersion() - } - - for _, setting := range info.Settings { - if setting.Key == "vcs" && setting.Value == "git" { - return getGitVersion() - } - } - - return constructPseudoVersion(info.Settings) -} - -func getGitVersion() string { - // Try to get the latest tag - tag, err := exec.Command("git", "describe", "--tags", "--abbrev=0").Output() - if err == nil && len(tag) > 0 { - return strings.TrimSpace(string(tag)) - } - - // Fallback to the latest commit hash - commit, err := exec.Command("git", "rev-parse", "HEAD").Output() - if err == nil && len(commit) > 0 { - commitHash := strings.TrimSpace(string(commit)) - if len(commitHash) > gitRevShortLen { - commitHash = commitHash[:gitRevShortLen] - } - return fmt.Sprintf("commit-%s", commitHash) - } - - return fallbackVersion -} - -func constructPseudoVersion(bs []debug.BuildSetting) string { - var vcsTime time.Time - var vcsRev string - for _, s := range bs { - switch s.Key { - case "vcs.time": - vcsTime, _ = time.Parse(time.RFC3339Nano, s.Value) - case "vcs.revision": - vcsRev = s.Value - } - } - - // Format the timestamp in the specific format used by Go pseudo-versions - timestamp := vcsTime.UTC().Format("20060102150405") - - // Truncate the commit hash to the first 12 characters - if len(vcsRev) > gitRevShortLen { - vcsRev = vcsRev[:gitRevShortLen] - } - - if vcsRev != "" { - return fmt.Sprintf("v0.0.0-%s-%s", timestamp, vcsRev) - } - - return fallbackVersion -} diff --git a/main.go b/main.go index 5ee24fd..173d952 100644 --- a/main.go +++ b/main.go @@ -10,136 +10,94 @@ import ( "time" "github.com/dannyhinshaw/converge/cmd" - "github.com/dannyhinshaw/converge/gonverge" - "github.com/dannyhinshaw/converge/internal/version" + "github.com/dannyhinshaw/converge/internal/cli" + "github.com/dannyhinshaw/converge/internal/gonverge" + "github.com/dannyhinshaw/converge/internal/logger" ) -// getUsage returns the usage message for the command. -func getUsage() string { - return ` - - ┏┏┓┏┓┓┏┏┓┏┓┏┓┏┓ - ┗┗┛┛┗┗┛┗ ┛ ┗┫┗ - ┛ - -Usage: converge [options] - -Converge multiple files in a Go package into one. - -Arguments: - Path to the directory containing Go source files to be merged. - -Options: - -f, --file Path to the output file where the merged content will be written; - defaults to stdout if not specified. - -v Enable verbose logging for debugging purposes. - -h, --help Show this help message and exit. - --version Show version information. - -t, --timeout Maximum time (in seconds) before cancelling the merge operation; - if not specified, the command runs until completion. - -w, --workers Maximum number of concurrent workers in the worker pool. - -e, --exclude Comma-separated list of filenames to exclude from merging. - -Examples: - converge ./src ./merged.go Merge all Go files in the 'src' directory into 'merged.go' - converge -v ./src ./merged.go Merge with verbose logging enabled. - converge -t 60 ./src ./merged.go Merge with a timeout of 60 seconds. - converge -w 4 ./src ./merged.go Merge using a maximum of 4 workers. - converge -e "file1.go,file2.go" ./src ./merged.go Merge while excluding 'file1.go' and 'file2.go'. - -Note: - The tool does not merge files in subdirectories and ignores test files (_test.go).` -} +// versions is set by the linker at build time. +// Defaults to "(dev)" if not set. +var version = "(dev)" func main() { - var ( - outFile string - exclude string - workers int - timeout int - verboseLog bool - showVersion bool - ) - - // outFile flag (short and long version) - flag.StringVar(&outFile, "f", "", "Output file for merged content; defaults to stdout if not specified") - flag.StringVar(&outFile, "file", "", "") - - // exclude flag (short and long version) - flag.StringVar(&exclude, "e", "", "Comma-separated list of files to exclude from merging") - flag.StringVar(&exclude, "exclude", "", "") - - // workers flag (short and long version) - flag.IntVar(&workers, "w", 0, "Maximum number of workers to use for file processing") - flag.IntVar(&workers, "workers", 0, "") - - // timeout flag (short and long version) - flag.IntVar(&timeout, "t", 0, "Maximum time in seconds before cancelling the operation") - flag.IntVar(&timeout, "timeout", 0, "") - - // Verbose flag (short version only) - flag.BoolVar(&verboseLog, "v", false, "Enable verbose logging") - - // Version flag (long version only) - flag.BoolVar(&showVersion, "version", false, "Show version information and exit") - - // Custom usage message - flag.Usage = func() { - fmt.Fprintln(os.Stderr, getUsage()) - flag.PrintDefaults() - } - - flag.Parse() + app := cli.NewApp() // Handle version flag - if showVersion { - fmt.Println("converge version:", version.GetVersion()) //nolint:forbidigo // only want to print version + if app.ShowVersion { + fmt.Println("converge version:", version) //nolint:forbidigo // not debugging return } - // Verbose logging - if verboseLog { - log.SetFlags(0) - log.Println("Verbose logging enabled") + // Create logger and set verbose logging. + clog := logger.New("converge", logger.WithVerbose(app.VerboseLog)) + if app.VerboseLog { + clog.Debug("Verbose logging enabled") } - // Check for at least one positional argument (source directory) - if flag.NArg() < 1 { - log.Println("Error: Source directory is required.") + srcDir, err := app.ParseSrcDir() + if err != nil { + clog.Error(err.Error()) flag.Usage() os.Exit(1) } - source := flag.Arg(0) // First positional argument - // Create context to handle timeouts - t := time.Duration(timeout) * time.Second - ctx, cancel := newCancelContext(context.Background(), t) + var ( + converger = createConverger(clog, app.Workers, app.Packages, app.Exclude) + command = createCommand(converger, srcDir, app.OutFile) + ctx, cancel = newCancelContext(context.Background(), app.Timeout) + ) defer cancel() - // Build options for the converger - var gonvOpts []gonverge.Option - if workers > 0 { - gonvOpts = append(gonvOpts, gonverge.WithMaxWorkers(workers)) + if err = command.Run(ctx); err != nil { + log.Printf("Error: %v\n", err) + return } - if exclude != "" { - excludeFiles := strings.Split(exclude, ",") - gonvOpts = append(gonvOpts, gonverge.WithExcludes(excludeFiles)) + + if app.OutFile != "" { + log.Printf("Files from '%s' have been successfully merged into '%s'.\n", srcDir, app.OutFile) } - converger := gonverge.NewGoFileConverger(gonvOpts...) +} - // Build options for the command +// createCommand creates a new Converge command with the given options. +func createCommand(converger cmd.FileConverger, src, outFile string) *cmd.Converge { var cmdOpts []cmd.Option if outFile != "" { cmdOpts = append(cmdOpts, cmd.WithDstFile(outFile)) } - command := cmd.NewCommand(converger, source, cmdOpts...) - if err := command.Run(ctx); err != nil { - log.Printf("Error: %v\n", err) - return + return cmd.NewCommand(converger, src, cmdOpts...) +} + +// createConverger creates a new GoFileConverger with the given options. +func createConverger(ll logger.LevelLogger, workers int, packages, exclude string) *gonverge.GoFileConverger { + var gonvOpts []gonverge.Option + if ll != nil { + gonvOpts = append(gonvOpts, gonverge.WithLogger(ll)) + } + + if workers > 0 { + gonvOpts = append(gonvOpts, gonverge.WithMaxWorkers(workers)) + } + + if packages != "" { + var pkgs []string + for _, pkg := range strings.Split(packages, ",") { + pkgs = append(pkgs, strings.TrimSpace(pkg)) + } + + gonvOpts = append(gonvOpts, gonverge.WithPackages(pkgs)) + } + + if exclude != "" { + var excludeFiles []string + for _, excludeFile := range strings.Split(exclude, ",") { + excludeFiles = append(excludeFiles, strings.TrimSpace(excludeFile)) + } + + gonvOpts = append(gonvOpts, gonverge.WithExcludes(excludeFiles)) } - log.Printf("Files from '%s' have been successfully merged into '%s'.\n", source, outFile) + return gonverge.NewGoFileConverger(gonvOpts...) } // newCancelContext returns a new cancellable context @@ -147,9 +105,10 @@ func main() { // // If no timeout is specified, the context will not have a // timeout, but a cancel function will still be returned. -func newCancelContext(ctx context.Context, t time.Duration) (context.Context, context.CancelFunc) { - if t > 0 { - return context.WithTimeout(ctx, t) +func newCancelContext(ctx context.Context, timeout int) (context.Context, context.CancelFunc) { + if timeout > 0 { + return context.WithTimeout(ctx, time.Duration(timeout)*time.Second) } + return context.WithCancel(ctx) }