diff --git a/internal/cli/format.go b/internal/cli/format.go index ffaa03a..5842178 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -40,6 +40,8 @@ func (f *Format) Run() error { return fmt.Errorf("%w: failed to read config file", err) } + globalExcludes, err := format.CompileGlobs(cfg.Global.Excludes) + // create optional formatter filter set formatterSet := make(map[string]bool) for _, name := range Cli.Formatters { @@ -68,7 +70,7 @@ func (f *Format) Run() error { continue } - err = formatter.Init(name) + err = formatter.Init(name, globalExcludes) if err == format.ErrFormatterNotFound && Cli.AllowMissingFormatter { l.Debugf("formatter not found: %v", name) // remove this formatter diff --git a/internal/cli/format_test.go b/internal/cli/format_test.go index 088ece6..3aa319a 100644 --- a/internal/cli/format_test.go +++ b/internal/cli/format_test.go @@ -2,104 +2,21 @@ package cli import ( "fmt" - "io" - "os" - "path/filepath" "testing" + "git.numtide.com/numtide/treefmt/internal/test" + "git.numtide.com/numtide/treefmt/internal/format" - "github.com/BurntSushi/toml" - "github.com/alecthomas/kong" - cp "github.com/otiai10/copy" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func writeConfig(t *testing.T, path string, cfg format.Config) { - t.Helper() - f, err := os.Create(path) - if err != nil { - t.Fatalf("failed to create a new config file: %v", err) - } - encoder := toml.NewEncoder(f) - if err = encoder.Encode(cfg); err != nil { - t.Fatalf("failed to write to config file: %v", err) - } -} - -func newKong(t *testing.T, cli interface{}, options ...kong.Option) *kong.Kong { - t.Helper() - options = append([]kong.Option{ - kong.Name("test"), - kong.Exit(func(int) { - t.Helper() - t.Fatalf("unexpected exit()") - }), - }, options...) - parser, err := kong.New(cli, options...) - assert.NoError(t, err) - return parser -} - -func tempFile(t *testing.T, path string) *os.File { - t.Helper() - file, err := os.Create(path) - if err != nil { - t.Fatalf("failed to create temporary file: %v", err) - } - return file -} - -func cmd(t *testing.T, args ...string) ([]byte, error) { - t.Helper() - - // create a new kong context - p := newKong(t, &Cli) - ctx, err := p.Parse(args) - if err != nil { - return nil, err - } - - tempDir := t.TempDir() - tempOut := tempFile(t, filepath.Join(tempDir, "combined_output")) - - // capture standard outputs before swapping them - stdout := os.Stdout - stderr := os.Stderr - - // swap them temporarily - os.Stdout = tempOut - os.Stderr = tempOut - - // run the command - if err = ctx.Run(); err != nil { - return nil, err - } - - // reset and read the temporary output - if _, err = tempOut.Seek(0, 0); err != nil { - return nil, fmt.Errorf("%w: failed to reset temp output for reading", err) - } - - out, err := io.ReadAll(tempOut) - if err != nil { - return nil, fmt.Errorf("%w: failed to read temp output", err) - } - - // swap outputs back - os.Stdout = stdout - os.Stderr = stderr - - return out, nil -} - func TestAllowMissingFormatter(t *testing.T) { as := require.New(t) tempDir := t.TempDir() configPath := tempDir + "/treefmt.toml" - writeConfig(t, configPath, format.Config{ + test.WriteConfig(t, configPath, format.Config{ Formatters: map[string]*format.Formatter{ "foo-fmt": { Command: "foo-fmt", @@ -117,12 +34,10 @@ func TestAllowMissingFormatter(t *testing.T) { func TestSpecifyingFormatters(t *testing.T) { as := require.New(t) - tempDir := t.TempDir() + tempDir := test.TempExamples(t) configPath := tempDir + "/treefmt.toml" - as.NoError(cp.Copy("../../test/examples", tempDir), "failed to copy test data to temp dir") - - writeConfig(t, configPath, format.Config{ + test.WriteConfig(t, configPath, format.Config{ Formatters: map[string]*format.Formatter{ "elm": { Command: "echo", @@ -163,3 +78,75 @@ func TestSpecifyingFormatters(t *testing.T) { out, err = cmd(t, "--clear-cache", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "bar,foo") as.Errorf(err, "formatter not found in config: bar") } + +func TestIncludesAndExcludes(t *testing.T) { + as := require.New(t) + + tempDir := test.TempExamples(t) + configPath := tempDir + "/echo.toml" + + // test without any excludes + config := format.Config{ + Formatters: map[string]*format.Formatter{ + "echo": { + Command: "echo", + Includes: []string{"*"}, + }, + }, + } + + test.WriteConfig(t, configPath, config) + out, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + as.NoError(err) + as.Contains(string(out), fmt.Sprintf("%d files changed", 29)) + + // globally exclude nix files + config.Global = struct{ Excludes []string }{ + Excludes: []string{"*.nix"}, + } + + test.WriteConfig(t, configPath, config) + out, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + as.NoError(err) + as.Contains(string(out), fmt.Sprintf("%d files changed", 28)) + + // add haskell files to the global exclude + config.Global.Excludes = []string{"*.nix", "*.hs"} + + test.WriteConfig(t, configPath, config) + out, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + as.NoError(err) + as.Contains(string(out), fmt.Sprintf("%d files changed", 22)) + + // remove python files from the echo formatter + config.Formatters["echo"].Excludes = []string{"*.py"} + + test.WriteConfig(t, configPath, config) + out, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + as.NoError(err) + as.Contains(string(out), fmt.Sprintf("%d files changed", 20)) + + // remove go files from the echo formatter + config.Formatters["echo"].Excludes = []string{"*.py", "*.go"} + + test.WriteConfig(t, configPath, config) + out, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + as.NoError(err) + as.Contains(string(out), fmt.Sprintf("%d files changed", 19)) + + // adjust the includes for echo to only include elm files + config.Formatters["echo"].Includes = []string{"*.elm"} + + test.WriteConfig(t, configPath, config) + out, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + as.NoError(err) + as.Contains(string(out), fmt.Sprintf("%d files changed", 1)) + + // add js files to echo formatter + config.Formatters["echo"].Includes = []string{"*.elm", "*.js"} + + test.WriteConfig(t, configPath, config) + out, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + as.NoError(err) + as.Contains(string(out), fmt.Sprintf("%d files changed", 2)) +} diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go new file mode 100644 index 0000000..7268d23 --- /dev/null +++ b/internal/cli/helpers_test.go @@ -0,0 +1,70 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "git.numtide.com/numtide/treefmt/internal/test" + "github.com/alecthomas/kong" + "github.com/stretchr/testify/require" +) + +func newKong(t *testing.T, cli interface{}, options ...kong.Option) *kong.Kong { + t.Helper() + options = append([]kong.Option{ + kong.Name("test"), + kong.Exit(func(int) { + t.Helper() + t.Fatalf("unexpected exit()") + }), + }, options...) + parser, err := kong.New(cli, options...) + require.NoError(t, err) + return parser +} + +func cmd(t *testing.T, args ...string) ([]byte, error) { + t.Helper() + + // create a new kong context + p := newKong(t, &Cli) + ctx, err := p.Parse(args) + if err != nil { + return nil, err + } + + tempDir := t.TempDir() + tempOut := test.TempFile(t, filepath.Join(tempDir, "combined_output")) + + // capture standard outputs before swapping them + stdout := os.Stdout + stderr := os.Stderr + + // swap them temporarily + os.Stdout = tempOut + os.Stderr = tempOut + + // run the command + if err = ctx.Run(); err != nil { + return nil, err + } + + // reset and read the temporary output + if _, err = tempOut.Seek(0, 0); err != nil { + return nil, fmt.Errorf("%w: failed to reset temp output for reading", err) + } + + out, err := io.ReadAll(tempOut) + if err != nil { + return nil, fmt.Errorf("%w: failed to read temp output", err) + } + + // swap outputs back + os.Stdout = stdout + os.Stderr = stderr + + return out, nil +} diff --git a/internal/format/config.go b/internal/format/config.go index 6287da0..29b127d 100644 --- a/internal/format/config.go +++ b/internal/format/config.go @@ -4,6 +4,10 @@ import "github.com/BurntSushi/toml" // Config is used to represent the list of configured Formatters. type Config struct { + Global struct { + // Excludes is an optional list of glob patterns used to exclude certain files from all formatters. + Excludes []string + } Formatters map[string]*Formatter `toml:"formatter"` } diff --git a/internal/format/format.go b/internal/format/format.go index 4049a18..c1ab066 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -48,7 +48,9 @@ func (f *Formatter) Executable() string { } // Init is used to initialise internal state before this Formatter is ready to accept paths. -func (f *Formatter) Init(name string) error { +func (f *Formatter) Init(name string, globalExcludes []glob.Glob) error { + var err error + // capture the name from the config file f.name = name @@ -68,26 +70,16 @@ func (f *Formatter) Init(name string) error { f.batch = make([]string, f.batchSize) f.batch = f.batch[:0] - // todo refactor common code below - if len(f.Includes) > 0 { - for _, pattern := range f.Includes { - g, err := glob.Compile("**/" + pattern) - if err != nil { - return fmt.Errorf("%w: failed to compile include pattern '%v' for formatter '%v'", err, pattern, f.name) - } - f.includes = append(f.includes, g) - } + f.includes, err = CompileGlobs(f.Includes) + if err != nil { + return fmt.Errorf("%w: formatter '%v' includes", err, f.name) } - if len(f.Excludes) > 0 { - for _, pattern := range f.Excludes { - g, err := glob.Compile("**/" + pattern) - if err != nil { - return fmt.Errorf("%w: failed to compile exclude pattern '%v' for formatter '%v'", err, pattern, f.name) - } - f.excludes = append(f.excludes, g) - } + f.excludes, err = CompileGlobs(f.Excludes) + if err != nil { + return fmt.Errorf("%w: formatter '%v' excludes", err, f.name) } + f.excludes = append(f.excludes, globalExcludes...) return nil } diff --git a/internal/format/glob.go b/internal/format/glob.go index e883fb6..947ec9f 100644 --- a/internal/format/glob.go +++ b/internal/format/glob.go @@ -1,9 +1,26 @@ package format import ( + "fmt" + "github.com/gobwas/glob" ) +// CompileGlobs prepares the globs, where the patterns are all right-matching. +func CompileGlobs(patterns []string) ([]glob.Glob, error) { + globs := make([]glob.Glob, len(patterns)) + + for i, pattern := range patterns { + g, err := glob.Compile("**/" + pattern) + if err != nil { + return nil, fmt.Errorf("%w: failed to compile include pattern '%v'", err, pattern) + } + globs[i] = g + } + + return globs, nil +} + func PathMatches(path string, globs []glob.Glob) bool { for idx := range globs { if globs[idx].Match(path) { diff --git a/internal/test/temp.go b/internal/test/temp.go new file mode 100644 index 0000000..c997d80 --- /dev/null +++ b/internal/test/temp.go @@ -0,0 +1,38 @@ +package test + +import ( + "os" + "testing" + + "git.numtide.com/numtide/treefmt/internal/format" + "github.com/BurntSushi/toml" + cp "github.com/otiai10/copy" + "github.com/stretchr/testify/require" +) + +func WriteConfig(t *testing.T, path string, cfg format.Config) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("failed to create a new config file: %v", err) + } + encoder := toml.NewEncoder(f) + if err = encoder.Encode(cfg); err != nil { + t.Fatalf("failed to write to config file: %v", err) + } +} + +func TempExamples(t *testing.T) string { + tempDir := t.TempDir() + require.NoError(t, cp.Copy("../../test/examples", tempDir), "failed to copy test data to temp dir") + return tempDir +} + +func TempFile(t *testing.T, path string) *os.File { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatalf("failed to create temporary file: %v", err) + } + return file +}