package cli import ( "bufio" "os" "os/exec" "path" "path/filepath" "regexp" "testing" config2 "git.numtide.com/numtide/treefmt/config" "git.numtide.com/numtide/treefmt/format" "git.numtide.com/numtide/treefmt/test" "github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/cache" "github.com/go-git/go-git/v5/storage/filesystem" "github.com/stretchr/testify/require" ) func TestAllowMissingFormatter(t *testing.T) { as := require.New(t) tempDir := test.TempExamples(t) configPath := tempDir + "/treefmt.toml" test.WriteConfig(t, configPath, config2.Config{ Formatters: map[string]*config2.Formatter{ "foo-fmt": { Command: "foo-fmt", }, }, }) _, err := cmd(t, "--config-file", configPath, "--tree-root", tempDir) as.ErrorIs(err, format.ErrCommandNotFound) _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--allow-missing-formatter") as.NoError(err) } func TestSpecifyingFormatters(t *testing.T) { as := require.New(t) tempDir := test.TempExamples(t) configPath := tempDir + "/treefmt.toml" test.WriteConfig(t, configPath, config2.Config{ Formatters: map[string]*config2.Formatter{ "elm": { Command: "touch", Includes: []string{"*.elm"}, }, "nix": { Command: "touch", Includes: []string{"*.nix"}, }, "ruby": { Command: "touch", Includes: []string{"*.rb"}, }, }, }) _, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 3, 3) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "elm,nix") as.NoError(err) assertStats(t, as, 31, 31, 2, 2) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "ruby,nix") as.NoError(err) assertStats(t, as, 31, 31, 2, 2) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "nix") as.NoError(err) assertStats(t, as, 31, 31, 1, 1) // test bad names _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "foo") as.Errorf(err, "formatter not found in config: foo") _, err = cmd(t, "-c", "--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 + "/touch.toml" // test without any excludes cfg := config2.Config{ Formatters: map[string]*config2.Formatter{ "echo": { Command: "echo", Includes: []string{"*"}, }, }, } test.WriteConfig(t, configPath, cfg) _, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 31, 0) // globally exclude nix files cfg.Global.Excludes = []string{"*.nix"} test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 30, 0) // add haskell files to the global exclude cfg.Global.Excludes = []string{"*.nix", "*.hs"} test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 24, 0) echo := cfg.Formatters["echo"] // remove python files from the echo formatter echo.Excludes = []string{"*.py"} test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 22, 0) // remove go files from the echo formatter echo.Excludes = []string{"*.py", "*.go"} test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 21, 0) // adjust the includes for echo to only include elm files echo.Includes = []string{"*.elm"} test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 1, 0) // add js files to echo formatter echo.Includes = []string{"*.elm", "*.js"} test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 2, 0) } func TestCache(t *testing.T) { as := require.New(t) tempDir := test.TempExamples(t) configPath := tempDir + "/touch.toml" // test without any excludes cfg := config2.Config{ Formatters: map[string]*config2.Formatter{ "echo": { Command: "echo", Includes: []string{"*"}, }, }, } test.WriteConfig(t, configPath, cfg) out, err := cmd(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 31, 0) out, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertFormatted(t, as, out, 0) // clear cache out, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-c") as.NoError(err) assertStats(t, as, 31, 31, 31, 0) out, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertFormatted(t, as, out, 0) // clear cache out, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-c") as.NoError(err) assertStats(t, as, 31, 31, 31, 0) out, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertFormatted(t, as, out, 0) // no cache out, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") as.NoError(err) assertStats(t, as, 31, 31, 31, 0) } func TestChangeWorkingDirectory(t *testing.T) { as := require.New(t) // capture current cwd, so we can replace it after the test is finished cwd, err := os.Getwd() as.NoError(err) t.Cleanup(func() { // return to the previous working directory as.NoError(os.Chdir(cwd)) }) tempDir := test.TempExamples(t) configPath := tempDir + "/treefmt.toml" // test without any excludes cfg := config2.Config{ Formatters: map[string]*config2.Formatter{ "echo": { Command: "echo", Includes: []string{"*"}, }, }, } test.WriteConfig(t, configPath, cfg) // by default, we look for ./treefmt.toml and use the cwd for the tree root // this should fail if the working directory hasn't been changed first _, err = cmd(t, "-C", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 31, 0) } func TestFailOnChange(t *testing.T) { as := require.New(t) tempDir := test.TempExamples(t) configPath := tempDir + "/touch.toml" // test without any excludes cfg := config2.Config{ Formatters: map[string]*config2.Formatter{ "touch": { Command: "touch", Includes: []string{"*"}, }, }, } test.WriteConfig(t, configPath, cfg) _, err := cmd(t, "--fail-on-change", "--config-file", configPath, "--tree-root", tempDir) as.ErrorIs(err, ErrFailOnChange) } func TestBustCacheOnFormatterChange(t *testing.T) { as := require.New(t) tempDir := test.TempExamples(t) configPath := tempDir + "/touch.toml" // symlink some formatters into temp dir, so we can mess with their mod times binPath := tempDir + "/bin" as.NoError(os.Mkdir(binPath, 0o755)) binaries := []string{"black", "elm-format", "gofmt"} for _, name := range binaries { src, err := exec.LookPath(name) as.NoError(err) as.NoError(os.Symlink(src, binPath+"/"+name)) } // prepend our test bin directory to PATH as.NoError(os.Setenv("PATH", binPath+":"+os.Getenv("PATH"))) // start with 2 formatters cfg := config2.Config{ Formatters: map[string]*config2.Formatter{ "python": { Command: "black", Includes: []string{"*.py"}, }, "elm": { Command: "elm-format", Options: []string{"--yes"}, Includes: []string{"*.elm"}, }, }, } test.WriteConfig(t, configPath, cfg) args := []string{"--config-file", configPath, "--tree-root", tempDir} _, err := cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 31, 3, 0) // tweak mod time of elm formatter as.NoError(test.RecreateSymlink(t, binPath+"/"+"elm-format")) _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 31, 3, 0) // check cache is working _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 0, 0, 0) // tweak mod time of python formatter as.NoError(test.RecreateSymlink(t, binPath+"/"+"black")) _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 31, 3, 0) // check cache is working _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 0, 0, 0) // add go formatter cfg.Formatters["go"] = &config2.Formatter{ Command: "gofmt", Options: []string{"-w"}, Includes: []string{"*.go"}, } test.WriteConfig(t, configPath, cfg) _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 31, 4, 0) // check cache is working _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 0, 0, 0) // remove python formatter delete(cfg.Formatters, "python") test.WriteConfig(t, configPath, cfg) _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 31, 2, 0) // check cache is working _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 0, 0, 0) // remove elm formatter delete(cfg.Formatters, "elm") test.WriteConfig(t, configPath, cfg) _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 31, 1, 0) // check cache is working _, err = cmd(t, args...) as.NoError(err) assertStats(t, as, 31, 0, 0, 0) } func TestGitWorktree(t *testing.T) { as := require.New(t) tempDir := test.TempExamples(t) configPath := filepath.Join(tempDir, "/treefmt.toml") // basic config cfg := config2.Config{ Formatters: map[string]*config2.Formatter{ "echo": { Command: "echo", Includes: []string{"*"}, }, }, } test.WriteConfig(t, configPath, cfg) // init a git repo repo, err := git.Init( filesystem.NewStorage( osfs.New(path.Join(tempDir, ".git")), cache.NewObjectLRUDefault(), ), osfs.New(tempDir), ) as.NoError(err, "failed to init git repository") // get worktree wt, err := repo.Worktree() as.NoError(err, "failed to get git worktree") run := func(traversed int, emitted int, matched int, formatted int) { out, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertFormatted(t, as, out, formatted) } // run before adding anything to the worktree run(0, 0, 0, 0) // add everything to the worktree as.NoError(wt.AddGlob(".")) as.NoError(err) run(31, 31, 31, 0) // remove python directory as.NoError(wt.RemoveGlob("python/*")) run(28, 28, 28, 0) // walk with filesystem instead of git _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--walk", "filesystem") as.NoError(err) assertStats(t, as, 59, 59, 59, 0) } func TestPathsArg(t *testing.T) { as := require.New(t) // capture current cwd, so we can replace it after the test is finished cwd, err := os.Getwd() as.NoError(err) t.Cleanup(func() { // return to the previous working directory as.NoError(os.Chdir(cwd)) }) tempDir := test.TempExamples(t) configPath := filepath.Join(tempDir, "/treefmt.toml") // change working directory to temp root as.NoError(os.Chdir(tempDir)) // basic config cfg := config2.Config{ Formatters: map[string]*config2.Formatter{ "echo": { Command: "echo", Includes: []string{"*"}, }, }, } test.WriteConfig(t, configPath, cfg) // without any path args _, err = cmd(t, "-C", tempDir) as.NoError(err) assertStats(t, as, 31, 31, 31, 0) // specify some explicit paths _, err = cmd(t, "-C", tempDir, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") as.NoError(err) assertStats(t, as, 4, 4, 4, 0) // specify a bad path _, err = cmd(t, "-C", tempDir, "-c", "elm/elm.json", "haskell/Nested/Bar.hs") as.ErrorContains(err, "no such file or directory") } func TestStdIn(t *testing.T) { as := require.New(t) // capture current cwd, so we can replace it after the test is finished cwd, err := os.Getwd() as.NoError(err) t.Cleanup(func() { // return to the previous working directory as.NoError(os.Chdir(cwd)) }) tempDir := test.TempExamples(t) configPath := filepath.Join(tempDir, "/treefmt.toml") // change working directory to temp root as.NoError(os.Chdir(tempDir)) // basic config cfg := config2.Config{ Formatters: map[string]*config2.Formatter{ "echo": { Command: "echo", Includes: []string{"*"}, }, }, } test.WriteConfig(t, configPath, cfg) // swap out stdin prevStdIn := os.Stdin stdin, err := os.CreateTemp("", "stdin") as.NoError(err) os.Stdin = stdin t.Cleanup(func() { os.Stdin = prevStdIn _ = os.Remove(stdin.Name()) }) go func() { _, err := stdin.WriteString(`treefmt.toml elm/elm.json go/main.go `) as.NoError(err, "failed to write to stdin") as.NoError(stdin.Sync()) _, _ = stdin.Seek(0, 0) }() _, err = cmd(t, "-C", tempDir, "--stdin") as.NoError(err) assertStats(t, as, 6, 6, 6, 0) } func TestDeterministicOrderingInPipeline(t *testing.T) { as := require.New(t) tempDir := test.TempExamples(t) configPath := tempDir + "/treefmt.toml" test.WriteConfig(t, configPath, config2.Config{ Formatters: map[string]*config2.Formatter{ // a and b should execute in lexicographical order as they have default priority 0, with c last since it has // priority 1 "fmt-a": { Command: "test-fmt", Options: []string{"fmt-a"}, Includes: []string{"*.py"}, Pipeline: "foo", }, "fmt-b": { Command: "test-fmt", Options: []string{"fmt-b"}, Includes: []string{"*.py"}, Pipeline: "foo", }, "fmt-c": { Command: "test-fmt", Options: []string{"fmt-c"}, Includes: []string{"*.py"}, Pipeline: "foo", Priority: 1, }, }, }) _, err := cmd(t, "-C", tempDir) as.NoError(err) matcher := regexp.MustCompile("^fmt-(.*)") // check each affected file for the sequence of test statements which should be prepended to the end sequence := []string{"fmt-a", "fmt-b", "fmt-c"} paths := []string{"python/main.py", "python/virtualenv_proxy.py"} for _, p := range paths { file, err := os.Open(filepath.Join(tempDir, p)) as.NoError(err) scanner := bufio.NewScanner(file) idx := 0 for scanner.Scan() { line := scanner.Text() matches := matcher.FindAllString(line, -1) if len(matches) != 1 { continue } as.Equal(sequence[idx], matches[0]) idx += 1 } } }