feat: support global excludes #13
@ -40,6 +40,8 @@ func (f *Format) Run() error {
|
|||||||
return fmt.Errorf("%w: failed to read config file", err)
|
return fmt.Errorf("%w: failed to read config file", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
globalExcludes, err := format.CompileGlobs(cfg.Global.Excludes)
|
||||||
|
|
||||||
// create optional formatter filter set
|
// create optional formatter filter set
|
||||||
formatterSet := make(map[string]bool)
|
formatterSet := make(map[string]bool)
|
||||||
for _, name := range Cli.Formatters {
|
for _, name := range Cli.Formatters {
|
||||||
@ -68,7 +70,7 @@ func (f *Format) Run() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = formatter.Init(name)
|
err = formatter.Init(name, globalExcludes)
|
||||||
if err == format.ErrFormatterNotFound && Cli.AllowMissingFormatter {
|
if err == format.ErrFormatterNotFound && Cli.AllowMissingFormatter {
|
||||||
l.Debugf("formatter not found: %v", name)
|
l.Debugf("formatter not found: %v", name)
|
||||||
// remove this formatter
|
// remove this formatter
|
||||||
|
@ -2,104 +2,21 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.numtide.com/numtide/treefmt/internal/test"
|
||||||
|
|
||||||
"git.numtide.com/numtide/treefmt/internal/format"
|
"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"
|
"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) {
|
func TestAllowMissingFormatter(t *testing.T) {
|
||||||
as := require.New(t)
|
as := require.New(t)
|
||||||
|
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
configPath := tempDir + "/treefmt.toml"
|
configPath := tempDir + "/treefmt.toml"
|
||||||
|
|
||||||
writeConfig(t, configPath, format.Config{
|
test.WriteConfig(t, configPath, format.Config{
|
||||||
Formatters: map[string]*format.Formatter{
|
Formatters: map[string]*format.Formatter{
|
||||||
"foo-fmt": {
|
"foo-fmt": {
|
||||||
Command: "foo-fmt",
|
Command: "foo-fmt",
|
||||||
@ -117,12 +34,10 @@ func TestAllowMissingFormatter(t *testing.T) {
|
|||||||
func TestSpecifyingFormatters(t *testing.T) {
|
func TestSpecifyingFormatters(t *testing.T) {
|
||||||
as := require.New(t)
|
as := require.New(t)
|
||||||
|
|
||||||
tempDir := t.TempDir()
|
tempDir := test.TempExamples(t)
|
||||||
configPath := tempDir + "/treefmt.toml"
|
configPath := tempDir + "/treefmt.toml"
|
||||||
|
|
||||||
as.NoError(cp.Copy("../../test/examples", tempDir), "failed to copy test data to temp dir")
|
test.WriteConfig(t, configPath, format.Config{
|
||||||
|
|
||||||
writeConfig(t, configPath, format.Config{
|
|
||||||
Formatters: map[string]*format.Formatter{
|
Formatters: map[string]*format.Formatter{
|
||||||
"elm": {
|
"elm": {
|
||||||
Command: "echo",
|
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")
|
out, err = cmd(t, "--clear-cache", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "bar,foo")
|
||||||
as.Errorf(err, "formatter not found in config: bar")
|
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))
|
||||||
|
}
|
||||||
|
70
internal/cli/helpers_test.go
Normal file
70
internal/cli/helpers_test.go
Normal file
@ -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
|
||||||
|
}
|
@ -4,6 +4,10 @@ import "github.com/BurntSushi/toml"
|
|||||||
|
|
||||||
// Config is used to represent the list of configured Formatters.
|
// Config is used to represent the list of configured Formatters.
|
||||||
type Config struct {
|
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"`
|
Formatters map[string]*Formatter `toml:"formatter"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +48,9 @@ func (f *Formatter) Executable() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Init is used to initialise internal state before this Formatter is ready to accept paths.
|
// 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
|
// capture the name from the config file
|
||||||
f.name = name
|
f.name = name
|
||||||
|
|
||||||
@ -68,26 +70,16 @@ func (f *Formatter) Init(name string) error {
|
|||||||
f.batch = make([]string, f.batchSize)
|
f.batch = make([]string, f.batchSize)
|
||||||
f.batch = f.batch[:0]
|
f.batch = f.batch[:0]
|
||||||
|
|
||||||
// todo refactor common code below
|
f.includes, err = CompileGlobs(f.Includes)
|
||||||
if len(f.Includes) > 0 {
|
|
||||||
for _, pattern := range f.Includes {
|
|
||||||
g, err := glob.Compile("**/" + pattern)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: failed to compile include pattern '%v' for formatter '%v'", err, pattern, f.name)
|
return fmt.Errorf("%w: formatter '%v' includes", err, f.name)
|
||||||
}
|
|
||||||
f.includes = append(f.includes, g)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(f.Excludes) > 0 {
|
f.excludes, err = CompileGlobs(f.Excludes)
|
||||||
for _, pattern := range f.Excludes {
|
|
||||||
g, err := glob.Compile("**/" + pattern)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: failed to compile exclude pattern '%v' for formatter '%v'", err, pattern, f.name)
|
return fmt.Errorf("%w: formatter '%v' excludes", err, f.name)
|
||||||
}
|
|
||||||
f.excludes = append(f.excludes, g)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
f.excludes = append(f.excludes, globalExcludes...)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,26 @@
|
|||||||
package format
|
package format
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gobwas/glob"
|
"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 {
|
func PathMatches(path string, globs []glob.Glob) bool {
|
||||||
for idx := range globs {
|
for idx := range globs {
|
||||||
if globs[idx].Match(path) {
|
if globs[idx].Match(path) {
|
||||||
|
38
internal/test/temp.go
Normal file
38
internal/test/temp.go
Normal file
@ -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
|
||||||
|
}
|
Reference in New Issue
Block a user