From 2d3398c3d3920b2d8bead9d8f3b6a3a1089a90f4 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Fri, 4 Oct 2024 14:32:16 +0100 Subject: [PATCH 01/15] fix: git module domain It was still pointing to git.numtide.com Signed-off-by: Brian McGee --- .goreleaser.yaml | 2 +- cache/cache.go | 6 +++--- cli/cli.go | 4 ++-- cli/format.go | 10 +++++----- cli/format_test.go | 6 +++--- cli/helpers_test.go | 4 ++-- format/formatter.go | 4 ++-- format/glob_test.go | 2 +- format/task.go | 2 +- go.mod | 2 +- init.toml | 2 +- main.go | 4 ++-- nix/packages/treefmt/default.nix | 6 +++--- test/temp.go | 2 +- walk/filesystem_test.go | 2 +- 15 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 15817401..30dbf335 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,7 +18,7 @@ builds: - env: - CGO_ENABLED=0 ldflags: - - -s -w -X git.numtide.com/numtide/treefmt/build.Version=v{{.Version}} + - -s -w -X github.com/numtide/treefmt/build.Version=v{{.Version}} goos: - linux - darwin diff --git a/cache/cache.go b/cache/cache.go index 46496007..7effc933 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -9,10 +9,10 @@ import ( "runtime" "time" - "git.numtide.com/numtide/treefmt/stats" + "github.com/numtide/treefmt/stats" - "git.numtide.com/numtide/treefmt/format" - "git.numtide.com/numtide/treefmt/walk" + "github.com/numtide/treefmt/format" + "github.com/numtide/treefmt/walk" "github.com/charmbracelet/log" diff --git a/cli/cli.go b/cli/cli.go index 8210ac47..be69c65a 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -5,10 +5,10 @@ import ( "github.com/gobwas/glob" - "git.numtide.com/numtide/treefmt/format" - "git.numtide.com/numtide/treefmt/walk" "github.com/alecthomas/kong" "github.com/charmbracelet/log" + "github.com/numtide/treefmt/format" + "github.com/numtide/treefmt/walk" ) func New() *Format { diff --git a/cli/format.go b/cli/format.go index d1f5e8d3..5cb473d8 100644 --- a/cli/format.go +++ b/cli/format.go @@ -14,13 +14,13 @@ import ( "syscall" "time" - "git.numtide.com/numtide/treefmt/format" - "git.numtide.com/numtide/treefmt/stats" + "github.com/numtide/treefmt/format" + "github.com/numtide/treefmt/stats" "mvdan.cc/sh/v3/expand" - "git.numtide.com/numtide/treefmt/cache" - "git.numtide.com/numtide/treefmt/config" - "git.numtide.com/numtide/treefmt/walk" + "github.com/numtide/treefmt/cache" + "github.com/numtide/treefmt/config" + "github.com/numtide/treefmt/walk" "github.com/charmbracelet/log" "golang.org/x/sync/errgroup" diff --git a/cli/format_test.go b/cli/format_test.go index 53ac9e42..6ce199b6 100644 --- a/cli/format_test.go +++ b/cli/format_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "git.numtide.com/numtide/treefmt/config" - "git.numtide.com/numtide/treefmt/format" - "git.numtide.com/numtide/treefmt/test" + "github.com/numtide/treefmt/config" + "github.com/numtide/treefmt/format" + "github.com/numtide/treefmt/test" "github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-git/v5" diff --git a/cli/helpers_test.go b/cli/helpers_test.go index 7c4fe02d..64666cbb 100644 --- a/cli/helpers_test.go +++ b/cli/helpers_test.go @@ -8,9 +8,9 @@ import ( "github.com/charmbracelet/log" - "git.numtide.com/numtide/treefmt/stats" + "github.com/numtide/treefmt/stats" - "git.numtide.com/numtide/treefmt/test" + "github.com/numtide/treefmt/test" "github.com/alecthomas/kong" "github.com/stretchr/testify/require" diff --git a/format/formatter.go b/format/formatter.go index 4bf2effb..a77d00d7 100644 --- a/format/formatter.go +++ b/format/formatter.go @@ -8,8 +8,8 @@ import ( "os/exec" "time" - "git.numtide.com/numtide/treefmt/config" - "git.numtide.com/numtide/treefmt/walk" + "github.com/numtide/treefmt/config" + "github.com/numtide/treefmt/walk" "github.com/charmbracelet/log" "github.com/gobwas/glob" diff --git a/format/glob_test.go b/format/glob_test.go index fad77c8b..137cdb25 100644 --- a/format/glob_test.go +++ b/format/glob_test.go @@ -3,7 +3,7 @@ package format_test import ( "testing" - "git.numtide.com/numtide/treefmt/format" + "github.com/numtide/treefmt/format" "github.com/gobwas/glob" "github.com/stretchr/testify/require" diff --git a/format/task.go b/format/task.go index c7f7c0c6..9da38111 100644 --- a/format/task.go +++ b/format/task.go @@ -4,7 +4,7 @@ import ( "cmp" "slices" - "git.numtide.com/numtide/treefmt/walk" + "github.com/numtide/treefmt/walk" ) type Task struct { diff --git a/go.mod b/go.mod index 67393eae..2d0740bd 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.numtide.com/numtide/treefmt +module github.com/numtide/treefmt go 1.22 diff --git a/init.toml b/init.toml index d332172f..f4458fe8 100644 --- a/init.toml +++ b/init.toml @@ -1,4 +1,4 @@ -# One CLI to format the code tree - https://git.numtide.com/numtide/treefmt +# One CLI to format the code tree - https://github.com/numtide/treefmt [formatter.mylanguage] # Formatter to run diff --git a/main.go b/main.go index 23539024..c3f8ed33 100644 --- a/main.go +++ b/main.go @@ -5,9 +5,9 @@ import ( "fmt" "os" - "git.numtide.com/numtide/treefmt/build" - "git.numtide.com/numtide/treefmt/cli" "github.com/alecthomas/kong" + "github.com/numtide/treefmt/build" + "github.com/numtide/treefmt/cli" ) // We embed the sample toml file for use with the init flag. diff --git a/nix/packages/treefmt/default.nix b/nix/packages/treefmt/default.nix index 4092e680..943ba82b 100644 --- a/nix/packages/treefmt/default.nix +++ b/nix/packages/treefmt/default.nix @@ -42,8 +42,8 @@ in ldflags = [ "-s" "-w" - "-X git.numtide.com/numtide/treefmt/build.Name=${pname}" - "-X git.numtide.com/numtide/treefmt/build.Version=v${version}" + "-X github.com/numtide/treefmt/build.Name=${pname}" + "-X github.com/numtide/treefmt/build.Version=v${version}" ]; nativeBuildInputs = @@ -57,7 +57,7 @@ in meta = with lib; { description = "treefmt: one CLI to format your repo"; - homepage = "https://git.numtide.com/numtide/treefmt"; + homepage = "https://github.com/numtide/treefmt"; license = licenses.mit; mainProgram = "treefmt"; }; diff --git a/test/temp.go b/test/temp.go index 2194df2a..59a2bfaf 100644 --- a/test/temp.go +++ b/test/temp.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "git.numtide.com/numtide/treefmt/config" + "github.com/numtide/treefmt/config" "github.com/BurntSushi/toml" cp "github.com/otiai10/copy" diff --git a/walk/filesystem_test.go b/walk/filesystem_test.go index 503ec0d3..b33404b8 100644 --- a/walk/filesystem_test.go +++ b/walk/filesystem_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "git.numtide.com/numtide/treefmt/test" + "github.com/numtide/treefmt/test" "github.com/stretchr/testify/require" ) From 94301f1184cc759401db929e801fb2825ce0bd8c Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Fri, 4 Oct 2024 14:34:10 +0100 Subject: [PATCH 02/15] feat: use latest treefmt release for formatting repo Signed-off-by: Brian McGee --- nix/formatter.nix | 2 -- 1 file changed, 2 deletions(-) diff --git a/nix/formatter.nix b/nix/formatter.nix index 44c3d678..dc8d2443 100644 --- a/nix/formatter.nix +++ b/nix/formatter.nix @@ -7,8 +7,6 @@ inputs.treefmt-nix.lib.mkWrapper pkgs { projectRootFile = "flake.nix"; - package = perSystem.self.treefmt; - programs = { alejandra.enable = true; deadnix.enable = true; From 6cadbbc00af449bd0332e0f8a0079fbc24cfe3a7 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Fri, 4 Oct 2024 14:38:09 +0100 Subject: [PATCH 03/15] feat: create a separate docs devshell Removes dependency on latest build of treefmt from inside the repo. Signed-off-by: Brian McGee --- nix/devshells/default.nix | 27 +-------------------------- nix/devshells/docs.nix | 30 ++++++++++++++++++++++++++++++ nix/formatter.nix | 1 - 3 files changed, 31 insertions(+), 27 deletions(-) create mode 100644 nix/devshells/docs.nix diff --git a/nix/devshells/default.nix b/nix/devshells/default.nix index 2d243f16..7d12631e 100644 --- a/nix/devshells/default.nix +++ b/nix/devshells/default.nix @@ -19,33 +19,8 @@ perSystem.self.treefmt.overrideAttrs (old: { pkgs.delve pkgs.pprof pkgs.graphviz - pkgs.nodejs ] ++ # include formatters for development and testing - (import ../packages/treefmt/formatters.nix pkgs) - # docs related helpers - ++ (let - docs = command: - pkgs.writeShellApplication { - name = "docs:${command}"; - runtimeInputs = [pkgs.nodejs]; - text = ''cd "''${DIRENV_DIR:1}/docs" && npm ci && npm run ${command}''; - }; - in [ - (docs "dev") - (docs "build") - (docs "preview") - (pkgs.writeShellApplication { - name = "vhs"; - runtimeInputs = - [ - perSystem.self.treefmt - pkgs.rsync - pkgs.vhs - ] - ++ (import ../packages/treefmt/formatters.nix pkgs); - text = ''vhs "$@"''; - }) - ]); + (import ../packages/treefmt/formatters.nix pkgs); }) diff --git a/nix/devshells/docs.nix b/nix/devshells/docs.nix new file mode 100644 index 00000000..82bd0f58 --- /dev/null +++ b/nix/devshells/docs.nix @@ -0,0 +1,30 @@ +{ + pkgs, + perSystem, + ... +}: +pkgs.mkShellNoCC { + packages = let + docs = command: + pkgs.writeShellApplication { + name = "docs:${command}"; + runtimeInputs = [pkgs.nodejs]; + text = ''cd "''${DIRENV_DIR:1}/docs" && npm ci && npm run ${command}''; + }; + in [ + (docs "dev") + (docs "build") + (docs "preview") + (pkgs.writeShellApplication { + name = "vhs"; + runtimeInputs = + [ + perSystem.self.treefmt + pkgs.rsync + pkgs.vhs + ] + ++ (import ../packages/treefmt/formatters.nix pkgs); + text = ''vhs "$@"''; + }) + ]; +} diff --git a/nix/formatter.nix b/nix/formatter.nix index dc8d2443..daffed5e 100644 --- a/nix/formatter.nix +++ b/nix/formatter.nix @@ -1,7 +1,6 @@ { pkgs, inputs, - perSystem, ... }: inputs.treefmt-nix.lib.mkWrapper pkgs { From 5d3de7c3a98f20088ec0e775c40ad7a169034bee Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Fri, 4 Oct 2024 14:38:57 +0100 Subject: [PATCH 04/15] feat: simplify .envrc Signed-off-by: Brian McGee --- .envrc | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.envrc b/.envrc index 3a79d2a6..b36cb446 100644 --- a/.envrc +++ b/.envrc @@ -1,11 +1,6 @@ dotenv dotenv_if_exists .env.local -if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then - source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4=" -fi - -watch_file flake.nix watch_file nix/devshell.nix use flake From 413ef859559f1c7c1226ff94dd3e23f9a6c1e44f Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Sat, 5 Oct 2024 18:35:30 +0100 Subject: [PATCH 05/15] feat: replace kong with cobra/viper `treefmt.toml` has been extended to include an entry for most of the flags that can be passed to `treefmt`. In addition, values can now be specified via the environment, prefixed with `TREEFMT_`. Finally, the order of precedence for value lookup has been fixed: flag -> env -> treefmt.toml. Closes #351 --- LICENSE | 21 - cli/cli.go | 61 --- cli/helpers_test.go | 85 ---- cli/mappers.go | 37 -- {cli => cmd/format}/format.go | 337 ++++++--------- cmd/init/init.go | 20 + cmd/init/init.toml | 11 + cmd/root.go | 121 ++++++ cli/format_test.go => cmd/root_test.go | 334 ++++++++++----- config/config.go | 151 ++++++- config/config_test.go | 555 ++++++++++++++++++++++++- config/formatter.go | 14 - go.mod | 24 +- go.sum | 98 +++-- main.go | 37 +- nix/devshells/default.nix | 17 +- nix/packages/treefmt/gomod2nix.toml | 66 ++- test/temp.go | 5 +- walk/type_enum.go | 94 +++++ walk/walker.go | 67 ++- 20 files changed, 1492 insertions(+), 663 deletions(-) delete mode 100644 cli/cli.go delete mode 100644 cli/helpers_test.go delete mode 100644 cli/mappers.go rename {cli => cmd/format}/format.go (66%) create mode 100644 cmd/init/init.go create mode 100644 cmd/init/init.toml create mode 100644 cmd/root.go rename cli/format_test.go => cmd/root_test.go (73%) delete mode 100644 config/formatter.go create mode 100644 walk/type_enum.go diff --git a/LICENSE b/LICENSE index b5414631..e69de29b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) Numtide & Contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/cli/cli.go b/cli/cli.go deleted file mode 100644 index be69c65a..00000000 --- a/cli/cli.go +++ /dev/null @@ -1,61 +0,0 @@ -package cli - -import ( - "os" - - "github.com/gobwas/glob" - - "github.com/alecthomas/kong" - "github.com/charmbracelet/log" - "github.com/numtide/treefmt/format" - "github.com/numtide/treefmt/walk" -) - -func New() *Format { - return &Format{} -} - -type Format struct { - AllowMissingFormatter bool `default:"false" help:"Do not exit with error if a configured formatter is missing."` - WorkingDirectory kong.ChangeDirFlag `default:"." short:"C" help:"Run as if treefmt was started in the specified working directory instead of the current working directory."` - NoCache bool `help:"Ignore the evaluation cache entirely. Useful for CI."` - ClearCache bool `short:"c" help:"Reset the evaluation cache. Use in case the cache is not precise enough."` - ConfigFile string `type:"existingfile" help:"Load the config file from the given path (defaults to searching upwards for treefmt.toml or .treefmt.toml)."` - FailOnChange bool `help:"Exit with error if any changes were made. Useful for CI."` - Formatters []string `short:"f" help:"Specify formatters to apply. Defaults to all formatters."` - TreeRoot string `type:"existingdir" xor:"tree-root" env:"PRJ_ROOT" help:"The root directory from which treefmt will start walking the filesystem (defaults to the directory containing the config file)."` - TreeRootFile string `type:"string" xor:"tree-root" help:"File to search for to find the project root (if --tree-root is not passed)."` - Walk walk.Type `enum:"auto,git,filesystem" default:"auto" help:"The method used to traverse the files within --tree-root. Currently supports 'auto', 'git' or 'filesystem'."` - Verbosity int `name:"verbose" short:"v" type:"counter" default:"0" env:"LOG_LEVEL" help:"Set the verbosity of logs e.g. -vv."` - Version bool `name:"version" short:"V" help:"Print version."` - Init bool `name:"init" short:"i" help:"Create a new treefmt.toml."` - - OnUnmatched log.Level `name:"on-unmatched" short:"u" default:"warn" help:"Log paths that did not match any formatters at the specified log level, with fatal exiting the process with an error. Possible values are ."` - - Paths []string `name:"paths" arg:"" type:"path" optional:"" help:"Paths to format. Defaults to formatting the whole tree."` - Stdin bool `help:"Format the context passed in via stdin."` - - CpuProfile string `optional:"" help:"The file into which a cpu profile will be written."` - - Ci bool `help:"Runs treefmt in a CI mode, enabling --no-cache, --fail-on-change and adjusting some other settings best suited to a CI use case."` - - formatters map[string]*format.Formatter - globalExcludes []glob.Glob - - filesCh chan *walk.File - formattedCh chan *format.Task - processedCh chan *format.Task -} - -func (f *Format) configureLogging() { - log.SetReportTimestamp(false) - log.SetOutput(os.Stderr) - - if f.Verbosity == 0 { - log.SetLevel(log.WarnLevel) - } else if f.Verbosity == 1 { - log.SetLevel(log.InfoLevel) - } else if f.Verbosity > 1 { - log.SetLevel(log.DebugLevel) - } -} diff --git a/cli/helpers_test.go b/cli/helpers_test.go deleted file mode 100644 index 64666cbb..00000000 --- a/cli/helpers_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "os" - "testing" - - "github.com/charmbracelet/log" - - "github.com/numtide/treefmt/stats" - - "github.com/numtide/treefmt/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, New(), NewOptions()...) - ctx, err := p.Parse(args) - if err != nil { - return nil, err - } - - tempDir := t.TempDir() - tempOut := test.TempFile(t, tempDir, "combined_output", nil) - - // capture standard outputs before swapping them - stdout := os.Stdout - stderr := os.Stderr - - // swap them temporarily - os.Stdout = tempOut - os.Stderr = tempOut - - log.SetOutput(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("failed to reset temp output for reading: %w", err) - } - - out, err := io.ReadAll(tempOut) - if err != nil { - return nil, fmt.Errorf("failed to read temp output: %w", err) - } - - // swap outputs back - os.Stdout = stdout - os.Stderr = stderr - log.SetOutput(stderr) - - return out, nil -} - -func assertStats(t *testing.T, as *require.Assertions, traversed int32, emitted int32, matched int32, formatted int32) { - t.Helper() - as.Equal(traversed, stats.Value(stats.Traversed), "stats.traversed") - as.Equal(emitted, stats.Value(stats.Emitted), "stats.emitted") - as.Equal(matched, stats.Value(stats.Matched), "stats.matched") - as.Equal(formatted, stats.Value(stats.Formatted), "stats.formatted") -} diff --git a/cli/mappers.go b/cli/mappers.go deleted file mode 100644 index 546f1d4f..00000000 --- a/cli/mappers.go +++ /dev/null @@ -1,37 +0,0 @@ -package cli - -import ( - "fmt" - "reflect" - - "github.com/alecthomas/kong" - "github.com/charmbracelet/log" -) - -func NewOptions() []kong.Option { - return []kong.Option{ - kong.TypeMapper(reflect.TypeOf(log.DebugLevel), logLevelDecoder()), - } -} - -func logLevelDecoder() kong.MapperFunc { - return func(ctx *kong.DecodeContext, target reflect.Value) error { - t, err := ctx.Scan.PopValue("string") - if err != nil { - return err - } - var str string - switch v := t.Value.(type) { - case string: - str = v - default: - return fmt.Errorf("expected a string but got %q (%T)", t, t.Value) - } - level, err := log.ParseLevel(str) - if err != nil { - return fmt.Errorf("failed to parse '%v' as log level: %w", level, err) - } - target.Set(reflect.ValueOf(level)) - return nil - } -} diff --git a/cli/format.go b/cmd/format/format.go similarity index 66% rename from cli/format.go rename to cmd/format/format.go index 5cb473d8..9046d297 100644 --- a/cli/format.go +++ b/cmd/format/format.go @@ -1,4 +1,4 @@ -package cli +package format import ( "context" @@ -10,20 +10,21 @@ import ( "path/filepath" "runtime" "runtime/pprof" - "strings" "syscall" "time" - "github.com/numtide/treefmt/format" - "github.com/numtide/treefmt/stats" - "mvdan.cc/sh/v3/expand" - + "github.com/charmbracelet/log" + "github.com/gobwas/glob" "github.com/numtide/treefmt/cache" "github.com/numtide/treefmt/config" + "github.com/numtide/treefmt/format" + "github.com/numtide/treefmt/stats" "github.com/numtide/treefmt/walk" - - "github.com/charmbracelet/log" + "github.com/spf13/cobra" + "github.com/spf13/viper" "golang.org/x/sync/errgroup" + + "mvdan.cc/sh/v3/expand" ) const ( @@ -32,25 +33,18 @@ const ( var ErrFailOnChange = errors.New("unexpected changes detected, --fail-on-change is enabled") -func (f *Format) Run() (err error) { - // set log level and other options - f.configureLogging() +func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { + cmd.SilenceUsage = true + + cfg, err := config.FromViper(v) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } // initialise stats collection stats.Init() - // ci mode - if f.Ci { - f.NoCache = true - f.FailOnChange = true - - // ensure INFO level - if f.Verbosity < 1 { - f.Verbosity = 1 - } - // reconfigure logging - f.configureLogging() - + if cfg.CI { log.Info("ci mode enabled") startAfter := time.Now(). @@ -70,9 +64,51 @@ func (f *Format) Run() (err error) { <-time.After(time.Until(startAfter)) } + if cfg.Stdin { + // check we have only received one path arg which we use for the file extension / matching to formatters + if len(paths) != 1 { + return fmt.Errorf("exactly one path should be specified when using the --stdin flag") + } + + // read stdin into a temporary file with the same file extension + pattern := fmt.Sprintf("*%s", filepath.Ext(paths[0])) + + file, err := os.CreateTemp("", pattern) + if err != nil { + return fmt.Errorf("failed to create a temporary file for processing stdin: %w", err) + } + + if _, err = io.Copy(file, os.Stdin); err != nil { + return fmt.Errorf("failed to copy stdin into a temporary file") + } + + // set the tree root to match the temp directory + cfg.TreeRoot, err = filepath.Abs(filepath.Dir(file.Name())) + if err != nil { + return fmt.Errorf("failed to get absolute path for tree root: %w", err) + } + + // configure filesystem walker to traverse the temporary tree root + cfg.Walk = "filesystem" + + // update paths with temp file + paths[0] = file.Name() + + } else { + // checks all paths are contained within the tree root + for idx, path := range paths { + rootPath := filepath.Join(cfg.TreeRoot, path) + if _, err = os.Stat(rootPath); err != nil { + return fmt.Errorf("path %s not found within the tree root %s", path, cfg.TreeRoot) + } + // update the path entry with an absolute path + paths[idx] = filepath.Clean(rootPath) + } + } + // cpu profiling - if f.CpuProfile != "" { - cpuProfile, err := os.Create(f.CpuProfile) + if cfg.CpuProfile != "" { + cpuProfile, err := os.Create(cfg.CpuProfile) if err != nil { return fmt.Errorf("failed to open file for writing cpu profile: %w", err) } else if err = pprof.StartCPUProfile(cpuProfile); err != nil { @@ -96,78 +132,21 @@ func (f *Format) Run() (err error) { } }() - // find the config file unless specified - if f.ConfigFile == "" { - pwd, err := os.Getwd() - if err != nil { - return err - } - f.ConfigFile, _, err = findUp(pwd, "treefmt.toml", ".treefmt.toml") - if err != nil { - return err - } - } - - // default the tree root to the directory containing the config file - if f.TreeRoot == "" { - f.TreeRoot = filepath.Dir(f.ConfigFile) - } - - // search the tree root using the --tree-root-file if specified - if f.TreeRootFile != "" { - pwd, err := os.Getwd() - if err != nil { - return err - } - _, f.TreeRoot, err = findUp(pwd, f.TreeRootFile) - if err != nil { - return err - } - } - - log.Debugf("config-file=%s tree-root=%s", f.ConfigFile, f.TreeRoot) - - // ensure all path arguments exist and are contained within the tree root - for _, path := range f.Paths { - relPath, err := filepath.Rel(f.TreeRoot, path) - if err != nil { - return fmt.Errorf("failed to determine relative path for %s to the tree root %s: %w", path, f.TreeRoot, err) - } - if strings.Contains(relPath, "..") { - return fmt.Errorf("path %s is outside the tree root %s", path, f.TreeRoot) - } - if f.Stdin { - // skip checking if the file exists if we are processing from stdin - // the file path is just used for matching against glob rules - continue - } - // check the path exists - _, err = os.Stat(path) - if err != nil { - return err - } - } - - // read config - cfg, err := config.ReadFile(f.ConfigFile, f.Formatters) - if err != nil { - return fmt.Errorf("failed to read config file %v: %w", f.ConfigFile, err) - } - // compile global exclude globs - if f.globalExcludes, err = format.CompileGlobs(cfg.Global.Excludes); err != nil { + globalExcludes, err := format.CompileGlobs(cfg.Excludes) + if err != nil { return fmt.Errorf("failed to compile global excludes: %w", err) } // initialise formatters - f.formatters = make(map[string]*format.Formatter) + formatters := make(map[string]*format.Formatter) env := expand.ListEnviron(os.Environ()...) - for name, formatterCfg := range cfg.Formatters { - formatter, err := format.NewFormatter(name, f.TreeRoot, env, formatterCfg) + for name, formatterCfg := range cfg.FormatterConfigs { + formatter, err := format.NewFormatter(name, cfg.TreeRoot, env, formatterCfg) - if errors.Is(err, format.ErrCommandNotFound) && f.AllowMissingFormatter { + if errors.Is(err, format.ErrCommandNotFound) && cfg.AllowMissingFormatter { log.Debugf("formatter command not found: %v", name) continue } else if err != nil { @@ -175,15 +154,15 @@ func (f *Format) Run() (err error) { } // store formatter by name - f.formatters[name] = formatter + formatters[name] = formatter } // open the cache if configured - if !f.NoCache { - if err = cache.Open(f.TreeRoot, f.ClearCache, f.formatters); err != nil { + if !cfg.NoCache { + if err = cache.Open(cfg.TreeRoot, cfg.ClearCache, formatters); err != nil { // if we can't open the cache, we log a warning and fallback to no cache log.Warnf("failed to open cache: %v", err) - f.NoCache = true + cfg.NoCache = true } } @@ -203,68 +182,54 @@ func (f *Format) Run() (err error) { // create a channel for files needing to be processed // we use a multiple of batch size here as a rudimentary concurrency optimization based on the host machine - f.filesCh = make(chan *walk.File, BatchSize*runtime.NumCPU()) + filesCh := make(chan *walk.File, BatchSize*runtime.NumCPU()) // create a channel for files that have been formatted - f.formattedCh = make(chan *format.Task, cap(f.filesCh)) + formattedCh := make(chan *format.Task, cap(filesCh)) // create a channel for files that have been processed - f.processedCh = make(chan *format.Task, cap(f.filesCh)) + processedCh := make(chan *format.Task, cap(filesCh)) // start concurrent processing tasks in reverse order - eg.Go(f.updateCache(ctx)) - eg.Go(f.detectFormatted(ctx)) - eg.Go(f.applyFormatters(ctx)) - eg.Go(f.walkFilesystem(ctx)) + eg.Go(updateCache(ctx, cfg, processedCh)) + eg.Go(detectFormatted(ctx, cfg, formattedCh, processedCh)) + eg.Go(applyFormatters(ctx, cfg, globalExcludes, formatters, filesCh, formattedCh)) + eg.Go(walkFilesystem(ctx, cfg, paths, filesCh)) // wait for everything to complete return eg.Wait() } -func (f *Format) walkFilesystem(ctx context.Context) func() error { +func walkFilesystem( + ctx context.Context, + cfg *config.Config, + paths []string, + filesCh chan *walk.File, +) func() error { return func() error { // close the files channel when we're done walking the file system - defer close(f.filesCh) + defer close(filesCh) eg, ctx := errgroup.WithContext(ctx) pathsCh := make(chan string, BatchSize) // By default, we use the cli arg, but if the stdin flag has been set we force a filesystem walk // since we will only be processing one file from a temp directory - walkerType := f.Walk - - if f.Stdin { - walkerType = walk.Filesystem - - // check we have only received one path arg which we use for the file extension / matching to formatters - if len(f.Paths) != 1 { - return fmt.Errorf("exactly one path should be specified when using the --stdin flag") - } - - // read stdin into a temporary file with the same file extension - pattern := fmt.Sprintf("*%s", filepath.Ext(f.Paths[0])) - file, err := os.CreateTemp("", pattern) - if err != nil { - return fmt.Errorf("failed to create a temporary file for processing stdin: %w", err) - } - - if _, err = io.Copy(file, os.Stdin); err != nil { - return fmt.Errorf("failed to copy stdin into a temporary file") - } - - f.Paths[0] = file.Name() + walkerType, err := walk.TypeString(cfg.Walk) + if err != nil { + return fmt.Errorf("invalid walk type: %w", err) } walkPaths := func() error { defer close(pathsCh) var idx int - for idx < len(f.Paths) { + for idx < len(paths) { select { case <-ctx.Done(): return ctx.Err() default: - pathsCh <- f.Paths[idx] + pathsCh <- paths[idx] idx += 1 } } @@ -272,22 +237,22 @@ func (f *Format) walkFilesystem(ctx context.Context) func() error { return nil } - if len(f.Paths) > 0 { + if len(paths) > 0 { eg.Go(walkPaths) } else { // no explicit paths to process, so we only need to process root - pathsCh <- f.TreeRoot + pathsCh <- cfg.TreeRoot close(pathsCh) } // create a filesystem walker - walker, err := walk.New(walkerType, f.TreeRoot, pathsCh) + walker, err := walk.New(walkerType, cfg.TreeRoot, pathsCh) if err != nil { return fmt.Errorf("failed to create walker: %w", err) } // if no cache has been configured, or we are processing from stdin, we invoke the walker directly - if f.NoCache || f.Stdin { + if cfg.NoCache || cfg.Stdin { return walker.Walk(ctx, func(file *walk.File, err error) error { select { case <-ctx.Done(): @@ -295,7 +260,7 @@ func (f *Format) walkFilesystem(ctx context.Context) func() error { default: stats.Add(stats.Traversed, 1) stats.Add(stats.Emitted, 1) - f.filesCh <- file + filesCh <- file return nil } }) @@ -303,7 +268,7 @@ func (f *Format) walkFilesystem(ctx context.Context) func() error { // otherwise we pass the walker to the cache and have it generate files for processing based on whether or not // they have been added/changed since the last invocation - if err = cache.ChangeSet(ctx, walker, f.filesCh); err != nil { + if err = cache.ChangeSet(ctx, walker, filesCh); err != nil { return fmt.Errorf("failed to generate change set: %w", err) } return nil @@ -311,7 +276,14 @@ func (f *Format) walkFilesystem(ctx context.Context) func() error { } // applyFormatters -func (f *Format) applyFormatters(ctx context.Context) func() error { +func applyFormatters( + ctx context.Context, + cfg *config.Config, + globalExcludes []glob.Glob, + formatters map[string]*format.Formatter, + filesCh chan *walk.File, + formattedCh chan *format.Task, +) func() error { // create our own errgroup for concurrent formatting tasks. // we don't want a cancel clause, in order to let formatters run up to the end. fg := errgroup.Group{} @@ -353,7 +325,7 @@ func (f *Format) applyFormatters(ctx context.Context) func() error { // pass each file to the formatted channel for _, task := range tasks { task.Errors = formatErrors - f.formattedCh <- task + formattedCh <- task } return nil @@ -375,17 +347,22 @@ func (f *Format) applyFormatters(ctx context.Context) func() error { return func() error { defer func() { // close processed channel - close(f.formattedCh) + close(formattedCh) }() + unmatchedLevel, err := log.ParseLevel(cfg.OnUnmatched) + if err != nil { + return fmt.Errorf("invalid on-unmatched value: %w", err) + } + // iterate the files channel - for file := range f.filesCh { + for file := range filesCh { // first check if this file has been globally excluded - if format.PathMatches(file.RelPath, f.globalExcludes) { + if format.PathMatches(file.RelPath, globalExcludes) { log.Debugf("path matched global excludes: %s", file.RelPath) // mark it as processed and continue to the next - f.formattedCh <- &format.Task{ + formattedCh <- &format.Task{ File: file, } continue @@ -393,7 +370,7 @@ func (f *Format) applyFormatters(ctx context.Context) func() error { // check if any formatters are interested in this file var matches []*format.Formatter - for _, formatter := range f.formatters { + for _, formatter := range formatters { if formatter.Wants(file) { matches = append(matches, formatter) } @@ -401,12 +378,13 @@ func (f *Format) applyFormatters(ctx context.Context) func() error { // see if any formatters matched if len(matches) == 0 { - if f.OnUnmatched == log.FatalLevel { + + if unmatchedLevel == log.FatalLevel { return fmt.Errorf("no formatter for path: %s", file.RelPath) } - log.Logf(f.OnUnmatched, "no formatter for path: %s", file.RelPath) + log.Logf(unmatchedLevel, "no formatter for path: %s", file.RelPath) // mark it as processed and continue to the next - f.formattedCh <- &format.Task{ + formattedCh <- &format.Task{ File: file, } } else { @@ -431,11 +409,11 @@ func (f *Format) applyFormatters(ctx context.Context) func() error { } } -func (f *Format) detectFormatted(ctx context.Context) func() error { +func detectFormatted(ctx context.Context, cfg *config.Config, formattedCh chan *format.Task, processedCh chan *format.Task) func() error { return func() error { defer func() { // close formatted channel - close(f.processedCh) + close(processedCh) }() for { @@ -445,7 +423,7 @@ func (f *Format) detectFormatted(ctx context.Context) func() error { case <-ctx.Done(): return ctx.Err() // take the next task that has been processed - case task, ok := <-f.formattedCh: + case task, ok := <-formattedCh: if !ok { // channel has been closed, no further files to process return nil @@ -463,7 +441,7 @@ func (f *Format) detectFormatted(ctx context.Context) func() error { stats.Add(stats.Formatted, 1) logMethod := log.Debug - if f.FailOnChange { + if cfg.FailOnChange { // surface the changed file more obviously logMethod = log.Error } @@ -482,13 +460,13 @@ func (f *Format) detectFormatted(ctx context.Context) func() error { } // mark as processed - f.processedCh <- task + processedCh <- task } } } } -func (f *Format) updateCache(ctx context.Context) func() error { +func updateCache(ctx context.Context, cfg *config.Config, processedCh chan *format.Task) func() error { return func() error { // used to batch updates for more efficient txs batch := make([]*format.Task, 0, BatchSize) @@ -509,7 +487,7 @@ func (f *Format) updateCache(ctx context.Context) func() error { // if we are processing from stdin that means we are outputting to stdout, no caching involved // if f.NoCache is set that means either the user explicitly disabled the cache or we failed to open on - if f.Stdin || f.NoCache { + if cfg.Stdin || cfg.NoCache { // do nothing processBatch = func() error { return nil } } @@ -521,7 +499,7 @@ func (f *Format) updateCache(ctx context.Context) func() error { case <-ctx.Done(): return ctx.Err() // respond to formatted files - case task, ok := <-f.processedCh: + case task, ok := <-processedCh: if !ok { // channel has been closed, no further files to process break LOOP @@ -529,7 +507,7 @@ func (f *Format) updateCache(ctx context.Context) func() error { file := task.File - if f.Stdin { + if cfg.Stdin { // dump file into stdout f, err := os.Open(file.Path) if err != nil { @@ -566,70 +544,15 @@ func (f *Format) updateCache(ctx context.Context) func() error { } // if fail on change has been enabled, check that no files were actually formatted, throwing an error if so - if f.FailOnChange && stats.Value(stats.Formatted) != 0 { + if cfg.FailOnChange && stats.Value(stats.Formatted) != 0 { return ErrFailOnChange } // print stats to stdout unless we are processing stdin and printing the results to stdout - if !f.Stdin { + if !cfg.Stdin { stats.Print() } return nil } } - -func findUp(searchDir string, fileNames ...string) (path string, dir string, err error) { - for _, dir := range eachDir(searchDir) { - for _, f := range fileNames { - path := filepath.Join(dir, f) - if fileExists(path) { - return path, dir, nil - } - } - } - return "", "", fmt.Errorf("could not find %s in %s", fileNames, searchDir) -} - -func eachDir(path string) (paths []string) { - path, err := filepath.Abs(path) - if err != nil { - return - } - - paths = []string{path} - - if path == "/" { - return - } - - for i := len(path) - 1; i >= 0; i-- { - if path[i] == os.PathSeparator { - path = path[:i] - if path == "" { - path = "/" - } - paths = append(paths, path) - } - } - - return -} - -func fileExists(path string) bool { - // Some broken filesystems like SSHFS return file information on stat() but - // then cannot open the file. So we use os.Open. - f, err := os.Open(path) - if err != nil { - return false - } - defer f.Close() - - // Next, check that the file is a regular file. - fi, err := f.Stat() - if err != nil { - return false - } - - return fi.Mode().IsRegular() -} diff --git a/cmd/init/init.go b/cmd/init/init.go new file mode 100644 index 00000000..91c5dc11 --- /dev/null +++ b/cmd/init/init.go @@ -0,0 +1,20 @@ +package init + +import ( + _ "embed" + "fmt" + "os" +) + +// We embed the sample toml file for use with the init flag. +// +//go:embed init.toml +var initBytes []byte + +func Run() error { + if err := os.WriteFile("treefmt.toml", initBytes, 0o644); err != nil { + return fmt.Errorf("failed to write treefmt.toml: %w", err) + } + fmt.Printf("Generated treefmt.toml. Now it's your turn to edit it.\n") + return nil +} diff --git a/cmd/init/init.toml b/cmd/init/init.toml new file mode 100644 index 00000000..d332172f --- /dev/null +++ b/cmd/init/init.toml @@ -0,0 +1,11 @@ +# One CLI to format the code tree - https://git.numtide.com/numtide/treefmt + +[formatter.mylanguage] +# Formatter to run +command = "command-to-run" +# Command-line arguments for the command +options = [] +# Glob pattern of files to include +includes = [ "*." ] +# Glob patterns of files to exclude +excludes = [] \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 00000000..65ac7a43 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/log" + "github.com/numtide/treefmt/build" + "github.com/numtide/treefmt/cmd/format" + _init "github.com/numtide/treefmt/cmd/init" + "github.com/numtide/treefmt/config" + "github.com/numtide/treefmt/walk" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewRoot() *cobra.Command { + var ( + treefmtInit bool + configFile string + ) + + // create a viper instance for reading in config + v := config.NewViper() + + // create out root command + cmd := &cobra.Command{ + Use: "treefmt ", + Short: "One CLI to format your repo", + Version: build.Version, + RunE: func(cmd *cobra.Command, args []string) error { + return runE(v, cmd, args) + }, + } + + // update version template + cmd.SetVersionTemplate("treefmt {{.Version}}") + + // add our config flags to the command's flag set + pfs := config.SetFlags(cmd.Flags()) + + // xor tree-root and tree-root-file flags + cmd.MarkFlagsMutuallyExclusive("tree-root", "tree-root-file") + + // add a couple of special flags which don't have a corresponding entry in treefmt.toml + pfs.StringVar(&configFile, "config-file", "", "Load the config file from the given path (defaults to searching upwards for treefmt.toml or .treefmt.toml).") + pfs.BoolVarP(&treefmtInit, "init", "i", false, "Create a treefmt.toml file in the current directory.") + + // bind prj_root to the tree-root flag, allowing viper to handle environment override for us + // conforms with https://github.com/numtide/prj-spec/blob/main/PRJ_SPEC.md + cobra.CheckErr(v.BindPFlag("prj_root", pfs.Lookup("tree-root"))) + + // bind our command's flags to viper, ensuring a flag → env → config order of precedence when looking for a value + if err := v.BindPFlags(pfs); err != nil { + cobra.CheckErr(fmt.Errorf("failed to bind global config to viper: %w", err)) + } + + return cmd +} + +func runE(v *viper.Viper, cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + + // change working directory if required + workingDir, err := filepath.Abs(v.GetString("working-dir")) + if err != nil { + return fmt.Errorf("failed to get absolute path for working directory: %w", err) + } else if err = os.Chdir(workingDir); err != nil { + return fmt.Errorf("failed to change working directory: %w", err) + } + + // check if we are running the init command + if init, err := flags.GetBool("init"); err != nil { + return fmt.Errorf("failed to read init flag: %w", err) + } else if init { + return _init.Run() + } + + // otherwise attempt to load the config file + + // use the path specified by the flag + configFile, err := flags.GetString("config-file") + if err != nil { + return fmt.Errorf("failed to read config-file flag: %w", err) + } + + // fallback to env + if configFile == "" { + configFile = os.Getenv("TREEFMT_CONFIG") + } + + // find the config file if one was not specified + if configFile == "" { + if configFile, _, err = walk.FindUp(workingDir, "treefmt.toml", ".treefmt.toml"); err != nil { + return fmt.Errorf("failed to find treefmt config file: %w", err) + } + } + + // read in the config + v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err != nil { + cobra.CheckErr(fmt.Errorf("failed to read config file '%s': %w", configFile, err)) + } + + // configure logging + log.SetOutput(os.Stderr) + log.SetReportTimestamp(false) + + switch v.GetInt("verbose") { + case 0: + log.SetLevel(log.WarnLevel) + case 1: + log.SetLevel(log.InfoLevel) + default: + log.SetLevel(log.DebugLevel) + } + + // format + return format.Run(v, cmd, args) +} diff --git a/cli/format_test.go b/cmd/root_test.go similarity index 73% rename from cli/format_test.go rename to cmd/root_test.go index 6ce199b6..95dbec4d 100644 --- a/cli/format_test.go +++ b/cmd/root_test.go @@ -1,8 +1,9 @@ -package cli +package cmd import ( "bufio" "fmt" + "io" "os" "os/exec" "path" @@ -11,8 +12,13 @@ import ( "testing" "time" - "github.com/numtide/treefmt/config" + "github.com/charmbracelet/log" + "github.com/numtide/treefmt/stats" + + format2 "github.com/numtide/treefmt/cmd/format" + "github.com/numtide/treefmt/format" + "github.com/numtide/treefmt/test" "github.com/go-git/go-billy/v5/osfs" @@ -78,7 +84,8 @@ func TestOnUnmatched(t *testing.T) { as.NoError(err) checkOutput("INFO", out) - out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-vv", "-u", "debug") + t.Setenv("TREEFMT_ON_UNMATCHED", "debug") + out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-vv") as.NoError(err) checkOutput("DEBU", out) } @@ -101,6 +108,13 @@ func TestCpuProfile(t *testing.T) { as.FileExists(filepath.Join(tempDir, "cpu.pprof")) _, err = os.Stat(filepath.Join(tempDir, "cpu.pprof")) as.NoError(err) + + t.Setenv("TREEFMT_CPU_PROFILE", "env.pprof") + _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter") + as.NoError(err) + as.FileExists(filepath.Join(tempDir, "env.pprof")) + _, err = os.Stat(filepath.Join(tempDir, "env.pprof")) + as.NoError(err) } func TestAllowMissingFormatter(t *testing.T) { @@ -109,40 +123,46 @@ func TestAllowMissingFormatter(t *testing.T) { tempDir := test.TempExamples(t) configPath := tempDir + "/treefmt.toml" - test.WriteConfig(t, configPath, config.Config{ - Formatters: map[string]*config.Formatter{ - "foo-fmt": { - Command: "foo-fmt", + cfg := map[string]any{ + "formatter": map[string]any{ + "foo-fmt": map[string]any{ + "command": "foo-fmt", }, }, - }) + } + + test.WriteConfig(t, configPath, cfg) _, 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) + + t.Setenv("TREEFMT_ALLOW_MISSING_FORMATTER", "true") + _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir) + as.NoError(err) } func TestSpecifyingFormatters(t *testing.T) { as := require.New(t) - cfg := config.Config{ - Formatters: map[string]*config.Formatter{ - "elm": { - Command: "touch", - Options: []string{"-m"}, - Includes: []string{"*.elm"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "elm": map[string]any{ + "command": "touch", + "options": []string{"-m"}, + "includes": []string{"*.elm"}, }, - "nix": { - Command: "touch", - Options: []string{"-m"}, - Includes: []string{"*.nix"}, + "nix": map[string]any{ + "command": "touch", + "options": []string{"-m"}, + "includes": []string{"*.nix"}, }, - "ruby": { - Command: "touch", - Options: []string{"-m"}, - Includes: []string{"*.rb"}, + "ruby": map[string]any{ + "command": "touch", + "options": []string{"-m"}, + "includes": []string{"*.rb"}, }, }, } @@ -182,7 +202,8 @@ func TestSpecifyingFormatters(t *testing.T) { _, 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") + t.Setenv("TREEFMT_FORMATTERS", "bar,foo") + _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.Errorf(err, "formatter not found in config: bar") } @@ -193,11 +214,11 @@ func TestIncludesAndExcludes(t *testing.T) { configPath := tempDir + "/touch.toml" // test without any excludes - cfg := config.Config{ - Formatters: map[string]*config.Formatter{ - "echo": { - Command: "echo", - Includes: []string{"*"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "echo": map[string]any{ + "command": "echo", + "includes": []string{"*"}, }, }, } @@ -208,7 +229,7 @@ func TestIncludesAndExcludes(t *testing.T) { assertStats(t, as, 32, 32, 32, 0) // globally exclude nix files - cfg.Global.Excludes = []string{"*.nix"} + cfg["excludes"] = []string{"*.nix"} test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) @@ -216,41 +237,43 @@ func TestIncludesAndExcludes(t *testing.T) { assertStats(t, as, 32, 32, 31, 0) // add haskell files to the global exclude - cfg.Global.Excludes = []string{"*.nix", "*.hs"} + cfg["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, 32, 32, 25, 0) - echo := cfg.Formatters["echo"] + echo := cfg["formatter"].(map[string]any)["echo"].(map[string]any) // remove python files from the echo formatter - echo.Excludes = []string{"*.py"} + 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, 32, 32, 23, 0) - // remove go files from the echo formatter - echo.Excludes = []string{"*.py", "*.go"} + // remove go files from the echo formatter via env + t.Setenv("TREEFMT_FORMATTER_ECHO_EXCLUDES", "*.py,*.go") test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 22, 0) + t.Setenv("TREEFMT_FORMATTER_ECHO_EXCLUDES", "") // reset + // adjust the includes for echo to only include elm files - echo.Includes = []string{"*.elm"} + 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, 32, 32, 1, 0) - // add js files to echo formatter - echo.Includes = []string{"*.elm", "*.js"} + // add js files to echo formatter via env + t.Setenv("TREEFMT_FORMATTER_ECHO_INCLUDES", "*.elm,*.js") test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) @@ -258,6 +281,29 @@ func TestIncludesAndExcludes(t *testing.T) { assertStats(t, as, 32, 32, 2, 0) } +func TestPrjRootEnvVariable(t *testing.T) { + as := require.New(t) + + tempDir := test.TempExamples(t) + configPath := tempDir + "/treefmt.toml" + + // test without any excludes + cfg := map[string]any{ + "formatter": map[string]any{ + "echo": map[string]any{ + "command": "echo", + "includes": []string{"*"}, + }, + }, + } + + test.WriteConfig(t, configPath, cfg) + t.Setenv("PRJ_ROOT", tempDir) + _, err := cmd(t, "--config-file", configPath) + as.NoError(err) + assertStats(t, as, 32, 32, 32, 0) +} + func TestCache(t *testing.T) { as := require.New(t) @@ -265,11 +311,11 @@ func TestCache(t *testing.T) { configPath := tempDir + "/touch.toml" // test without any excludes - cfg := config.Config{ - Formatters: map[string]*config.Formatter{ - "echo": { - Command: "echo", - Includes: []string{"*"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "echo": map[string]any{ + "command": "echo", + "includes": []string{"*"}, }, }, } @@ -325,11 +371,11 @@ func TestChangeWorkingDirectory(t *testing.T) { configPath := tempDir + "/treefmt.toml" // test without any excludes - cfg := config.Config{ - Formatters: map[string]*config.Formatter{ - "echo": { - Command: "echo", - Includes: []string{"*"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "echo": map[string]any{ + "command": "echo", + "includes": []string{"*"}, }, }, } @@ -341,6 +387,12 @@ func TestChangeWorkingDirectory(t *testing.T) { _, err = cmd(t, "-C", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 32, 0) + + // use env + t.Setenv("TREEFMT_WORKING_DIR", tempDir) + _, err = cmd(t, "-c") + as.NoError(err) + assertStats(t, as, 32, 32, 32, 0) } func TestFailOnChange(t *testing.T) { @@ -350,26 +402,27 @@ func TestFailOnChange(t *testing.T) { configPath := tempDir + "/touch.toml" // test without any excludes - cfg := config.Config{ - Formatters: map[string]*config.Formatter{ - "touch": { - Command: "touch", - Includes: []string{"*"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "touch": map[string]any{ + "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) + as.ErrorIs(err, format2.ErrFailOnChange) // we have second precision mod time tracking time.Sleep(time.Second) // test with no cache + t.Setenv("TREEFMT_FAIL_ON_CHANGE", "true") test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "--fail-on-change", "--config-file", configPath, "--tree-root", tempDir, "--no-cache") - as.ErrorIs(err, ErrFailOnChange) + _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") + as.ErrorIs(err, format2.ErrFailOnChange) } func TestBustCacheOnFormatterChange(t *testing.T) { @@ -394,16 +447,16 @@ func TestBustCacheOnFormatterChange(t *testing.T) { as.NoError(os.Setenv("PATH", binPath+":"+os.Getenv("PATH"))) // start with 2 formatters - cfg := config.Config{ - Formatters: map[string]*config.Formatter{ - "python": { - Command: "black", - Includes: []string{"*.py"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "python": map[string]any{ + "command": "black", + "includes": []string{"*.py"}, }, - "elm": { - Command: "elm-format", - Options: []string{"--yes"}, - Includes: []string{"*.elm"}, + "elm": map[string]any{ + "command": "elm-format", + "options": []string{"--yes"}, + "includes": []string{"*.elm"}, }, }, } @@ -439,10 +492,12 @@ func TestBustCacheOnFormatterChange(t *testing.T) { assertStats(t, as, 32, 0, 0, 0) // add go formatter - cfg.Formatters["go"] = &config.Formatter{ - Command: "gofmt", - Options: []string{"-w"}, - Includes: []string{"*.go"}, + formatters := cfg["formatter"].(map[string]any) + + formatters["go"] = map[string]any{ + "command": "gofmt", + "options": []string{"-w"}, + "includes": []string{"*.go"}, } test.WriteConfig(t, configPath, cfg) @@ -456,7 +511,7 @@ func TestBustCacheOnFormatterChange(t *testing.T) { assertStats(t, as, 32, 0, 0, 0) // remove python formatter - delete(cfg.Formatters, "python") + delete(formatters, "python") test.WriteConfig(t, configPath, cfg) _, err = cmd(t, args...) @@ -469,7 +524,7 @@ func TestBustCacheOnFormatterChange(t *testing.T) { assertStats(t, as, 32, 0, 0, 0) // remove elm formatter - delete(cfg.Formatters, "elm") + delete(formatters, "elm") test.WriteConfig(t, configPath, cfg) _, err = cmd(t, args...) @@ -489,14 +544,15 @@ func TestGitWorktree(t *testing.T) { configPath := filepath.Join(tempDir, "/treefmt.toml") // basic config - cfg := config.Config{ - Formatters: map[string]*config.Formatter{ - "echo": { - Command: "echo", - Includes: []string{"*"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "echo": map[string]any{ + "command": "echo", + "includes": []string{"*"}, }, }, } + test.WriteConfig(t, configPath, cfg) // init a git repo @@ -564,7 +620,7 @@ func TestGitWorktree(t *testing.T) { // try with a bad path _, err = cmd(t, "-C", tempDir, "-c", "haskell", "foo") - as.ErrorContains(err, fmt.Sprintf("stat %s: no such file or directory", filepath.Join(tempDir, "foo"))) + as.ErrorContains(err, "path foo not found within the tree root") assertStats(t, as, 0, 0, 0, 0) // try with a path not in the git index, e.g. it is skipped @@ -596,11 +652,11 @@ func TestPathsArg(t *testing.T) { as.NoError(os.Chdir(tempDir)) // basic config - cfg := config.Config{ - Formatters: map[string]*config.Formatter{ - "echo": { - Command: "echo", - Includes: []string{"*"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "echo": map[string]any{ + "command": "echo", + "includes": []string{"*"}, }, }, } @@ -618,15 +674,15 @@ func TestPathsArg(t *testing.T) { // specify a bad path _, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Bar.hs") - as.ErrorContains(err, "no such file or directory") + as.ErrorContains(err, "path haskell/Nested/Bar.hs not found within the tree root") // specify a path outside the tree root externalPath := filepath.Join(cwd, "go.mod") _, err = cmd(t, "-c", externalPath) - as.ErrorContains(err, fmt.Sprintf("%s is outside the tree root %s", externalPath, tempDir)) + as.ErrorContains(err, fmt.Sprintf("path %s not found within the tree root", externalPath)) } -func TestStdIn(t *testing.T) { +func TestStdin(t *testing.T) { as := require.New(t) // capture current cwd, so we can replace it after the test is finished @@ -657,7 +713,8 @@ func TestStdIn(t *testing.T) { contents = `{ foo, ... }: "hello"` os.Stdin = test.TempFile(t, "", "stdin", &contents) - out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.nix") + t.Setenv("TREEFMT_STDIN", "true") + out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "test.nix") as.NoError(err) assertStats(t, as, 1, 1, 1, 1) @@ -700,29 +757,27 @@ func TestDeterministicOrderingInPipeline(t *testing.T) { tempDir := test.TempExamples(t) configPath := tempDir + "/treefmt.toml" - test.WriteConfig(t, configPath, config.Config{ - Formatters: map[string]*config.Formatter{ - // a and b have no priority set, which means they default to 0 and should execute first - // a and b should execute in lexicographical order - // c should execute first since it has a priority of 1 - "fmt-a": { - Command: "test-fmt", - Options: []string{"fmt-a"}, - Includes: []string{"*.py"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "fmt-a": map[string]any{ + "command": "test-fmt", + "options": []string{"fmt-a"}, + "includes": []string{"*.py"}, }, - "fmt-b": { - Command: "test-fmt", - Options: []string{"fmt-b"}, - Includes: []string{"*.py"}, + "fmt-b": map[string]any{ + "command": "test-fmt", + "options": []string{"fmt-b"}, + "includes": []string{"*.py"}, }, - "fmt-c": { - Command: "test-fmt", - Options: []string{"fmt-c"}, - Includes: []string{"*.py"}, - Priority: 1, + "fmt-c": map[string]any{ + "command": "test-fmt", + "options": []string{"fmt-c"}, + "includes": []string{"*.py"}, + "priority": 1, }, }, - }) + } + test.WriteConfig(t, configPath, cfg) _, err = cmd(t, "-C", tempDir) as.NoError(err) @@ -778,11 +833,11 @@ func TestRunInSubdir(t *testing.T) { as.NoError(os.Chdir(filepath.Join(tempDir, "elm"))) // basic config - cfg := config.Config{ - Formatters: map[string]*config.Formatter{ - "echo": { - Command: "./echo", - Includes: []string{"*"}, + cfg := map[string]any{ + "formatter": map[string]any{ + "echo": map[string]any{ + "command": "./echo", + "includes": []string{"*"}, }, }, } @@ -794,7 +849,66 @@ func TestRunInSubdir(t *testing.T) { assertStats(t, as, 32, 32, 32, 0) // specify some explicit paths, relative to the tree root - _, err = cmd(t, "-c", "elm.json", "../haskell/Nested/Foo.hs") + _, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") as.NoError(err) assertStats(t, as, 2, 2, 2, 0) } + +func cmd(t *testing.T, args ...string) ([]byte, error) { + t.Helper() + + tempDir := t.TempDir() + tempOut := test.TempFile(t, tempDir, "combined_output", nil) + + // capture standard outputs before swapping them + stdout := os.Stdout + stderr := os.Stderr + + // swap them temporarily + os.Stdout = tempOut + os.Stderr = tempOut + + log.SetOutput(tempOut) + + defer func() { + // swap outputs back + os.Stdout = stdout + os.Stderr = stderr + log.SetOutput(stderr) + }() + + // run the command + root := NewRoot() + + if args == nil { + // we must pass an empty array otherwise cobra with use os.Args[1:] + args = []string{} + } + root.SetArgs(args) + root.SetOut(tempOut) + root.SetErr(tempOut) + + if err := root.Execute(); err != nil { + return nil, err + } + + // reset and read the temporary output + if _, err := tempOut.Seek(0, 0); err != nil { + return nil, fmt.Errorf("failed to reset temp output for reading: %w", err) + } + + out, err := io.ReadAll(tempOut) + if err != nil { + return nil, fmt.Errorf("failed to read temp output: %w", err) + } + + return out, nil +} + +func assertStats(t *testing.T, as *require.Assertions, traversed int32, emitted int32, matched int32, formatted int32) { + t.Helper() + as.Equal(traversed, stats.Value(stats.Traversed), "stats.traversed") + as.Equal(emitted, stats.Value(stats.Emitted), "stats.emitted") + as.Equal(matched, stats.Value(stats.Matched), "stats.matched") + as.Equal(formatted, stats.Value(stats.Formatted), "stats.formatted") +} diff --git a/config/config.go b/config/config.go index 553aee62..f2c38ac2 100644 --- a/config/config.go +++ b/config/config.go @@ -2,32 +2,144 @@ package config import ( "fmt" + "path/filepath" + "strings" - "github.com/BurntSushi/toml" + "github.com/numtide/treefmt/walk" + "github.com/spf13/pflag" + "github.com/spf13/viper" ) // Config is used to represent the list of configured Formatters. type Config struct { + AllowMissingFormatter bool `mapstructure:"allow-missing-formatter,omitempty"` + CI bool `mapstructure:"ci,omitempty"` + ClearCache bool `mapstructure:"clear-cache,omitempty"` + CpuProfile string `mapstructure:"cpu-profile,omitempty"` + Excludes []string `mapstructure:"excludes"` + FailOnChange bool `mapstructure:"fail-on-change,omitempty"` + Formatters []string `mapstructure:"formatters,omitempty"` + NoCache bool `mapstructure:"no-cache,omitempty"` + OnUnmatched string `mapstructure:"on-unmatched,omitempty"` + TreeRoot string `mapstructure:"tree-root,omitempty"` + TreeRootFile string `mapstructure:"tree-root-file,omitempty"` + Verbosity uint8 `mapstructure:"verbose"` + Walk string `mapstructure:"walk,omitempty"` + WorkingDirectory string `mapstructure:"working-dir,omitempty"` + Stdin bool `mapstructure:"stdin,omitempty"` + + FormatterConfigs map[string]*Formatter `mapstructure:"formatter"` + Global struct { - // Excludes is an optional list of glob patterns used to exclude certain files from all formatters. - Excludes []string `toml:"excludes"` - } `toml:"global"` - Formatters map[string]*Formatter `toml:"formatter"` + Excludes []string `mapstructure:"excludes,omitempty"` + } `mapstructure:"global"` +} + +type Formatter struct { + // Command is the command to invoke when applying this Formatter. + Command string `toml:"command" mapstructure:"command" toml:"command"` + // Options are an optional list of args to be passed to Command. + Options []string `toml:"options,omitempty" mapstructure:"options,omitempty" toml:"options,omitempty"` + // Includes is a list of glob patterns used to determine whether this Formatter should be applied against a path. + Includes []string `toml:"includes,omitempty" mapstructure:"includes,omitempty" toml:"includes,omitempty"` + // Excludes is an optional list of glob patterns used to exclude certain files from this Formatter. + Excludes []string `toml:"excludes,omitempty" mapstructure:"excludes,omitempty" toml:"excludes,omitempty"` + // Indicates the order of precedence when executing this Formatter in a sequence of Formatters. + Priority int `toml:"priority,omitempty" mapstructure:"priority,omitempty" toml:"priority,omitempty"` } -// ReadFile reads from path and unmarshals toml into a Config instance. -func ReadFile(path string, names []string) (cfg *Config, err error) { - if _, err = toml.DecodeFile(path, &cfg); err != nil { - return nil, fmt.Errorf("failed to decode config file: %w", err) +// SetFlags appends our flags to the provided flag set. +// We have a flag matching most entries in Config, taking care to ensure the name matches the field name defined in the +// mapstructure tag. +// We can rely on a flag's default value being provided in the event the same value was not specified in the config file. +func SetFlags(fs *pflag.FlagSet) *pflag.FlagSet { + fs.Bool("allow-missing-formatter", false, "Do not exit with error if a configured formatter is missing.") + fs.Bool("ci", false, "Runs treefmt in a CI mode, enabling --no-cache, --fail-on-change and adjusting some other settings best suited to a CI use case.") + fs.BoolP("clear-cache", "c", false, "Reset the evaluation cache. Use in case the cache is not precise enough.") + fs.String("cpu-profile", "", "The file into which a cpu profile will be written.") + fs.StringSlice("excludes", nil, "Exclude files or directories matching the specified globs.") + fs.Bool("fail-on-change", false, "Exit with error if any changes were made. Useful for CI.") + fs.StringSliceP("formatters", "f", nil, "Specify formatters to apply. Defaults to all configured formatters.") + fs.Bool("no-cache", false, "Ignore the evaluation cache entirely. Useful for CI.") + fs.StringP("on-unmatched", "u", "warn", "Log paths that did not match any formatters at the specified log level, with fatal exiting the process with an error. Possible values are .") + fs.Bool("stdin", false, "Format the context passed in via stdin.") + + fs.String("tree-root", "", "The root directory from which treefmt will start walking the filesystem (defaults to the directory containing the config file).") + fs.String("tree-root-file", "", "File to search for to find the tree root (if --tree-root is not passed).") + + fs.String("walk", "auto", "The method used to traverse the files within the tree root. Currently supports 'auto', 'git' or 'filesystem'.") + fs.CountP("verbose", "v", "Set the verbosity of logs e.g. -vv.") + fs.StringP("working-dir", "C", ".", "Run as if treefmt was started in the specified working directory instead of the current working directory.") + + return fs +} + +// NewViper creates a Viper instance pre-configured with the following options: +// * TOML config type +// * automatic env enabled +// * `TREEFMT_` env prefix for environment variables +// * replacement of `-` and `.` with `_` when mapping from flags to env e.g. `global.excludes` => `TREEFMT_GLOBAL_EXCLUDES` +func NewViper() *viper.Viper { + v := viper.New() + + // Enforce toml (may open this up to other formats in the future) + v.SetConfigType("toml") + + // Allow env overrides for config and flags. + v.SetEnvPrefix("treefmt") + v.AutomaticEnv() + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) + + return v +} + +// FromViper takes a viper instance and produces a Config instance. +func FromViper(v *viper.Viper) (*Config, error) { + cfg := &Config{} + var err error + + if err = v.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // resolve the working directory to an absolute path + cfg.WorkingDirectory, err = filepath.Abs(cfg.WorkingDirectory) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for working directory: %w", err) + } + + // determine the tree root + if cfg.TreeRoot == "" { + // if none was specified, we first try with tree-root-file + if cfg.TreeRootFile != "" { + // search the tree root using the --tree-root-file if specified + _, cfg.TreeRoot, err = walk.FindUp(cfg.WorkingDirectory, cfg.TreeRootFile) + if err != nil { + return nil, fmt.Errorf("failed to find tree-root based on tree-root-file: %w", err) + } + } else { + // otherwise fallback to the directory containing the config file + cfg.TreeRoot = filepath.Dir(v.ConfigFileUsed()) + } + } + + // resolve tree root to an absolute path + if cfg.TreeRoot, err = filepath.Abs(cfg.TreeRoot); err != nil { + return nil, fmt.Errorf("failed to get absolute path for tree root: %w", err) + } + + // prefer top level excludes, falling back to global.excludes for backwards compatibility + if len(cfg.Excludes) == 0 { + cfg.Excludes = cfg.Global.Excludes } // filter formatters based on provided names - if len(names) > 0 { + if len(cfg.Formatters) > 0 { filtered := make(map[string]*Formatter) // check if the provided names exist in the config - for _, name := range names { - formatterCfg, ok := cfg.Formatters[name] + for _, name := range cfg.Formatters { + formatterCfg, ok := cfg.FormatterConfigs[name] if !ok { return nil, fmt.Errorf("formatter %v not found in config", name) } @@ -35,8 +147,19 @@ func ReadFile(path string, names []string) (cfg *Config, err error) { } // updated formatters - cfg.Formatters = filtered + cfg.FormatterConfigs = filtered + } + + // ci mode + if cfg.CI { + cfg.NoCache = true + cfg.FailOnChange = true + + // ensure at least info level logging + if cfg.Verbosity < 1 { + cfg.Verbosity = 1 + } } - return + return cfg, nil } diff --git a/config/config_test.go b/config/config_test.go index 1a149b63..2522cbfe 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,23 +1,544 @@ package config import ( + "bufio" + "bytes" + "fmt" + "os" + "path/filepath" "testing" + "github.com/BurntSushi/toml" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" ) -func TestReadConfigFile(t *testing.T) { +func newViper(t *testing.T) (*viper.Viper, *pflag.FlagSet) { + t.Helper() + v := NewViper() + + tempDir := t.TempDir() + v.SetConfigFile(filepath.Join(tempDir, "treefmt.toml")) + + flags := SetFlags(pflag.NewFlagSet("test", pflag.ContinueOnError)) + if err := v.BindPFlags(flags); err != nil { + t.Fatal(err) + } + return v, flags +} + +func readValue(t *testing.T, v *viper.Viper, cfg map[string]any, test func(*Config)) { + t.Helper() + + // serialise the config and read it into viper + buf := bytes.NewBuffer(nil) + encoder := toml.NewEncoder(buf) + if err := encoder.Encode(cfg); err != nil { + t.Fatal(fmt.Errorf("failed to marshal config: %w", err)) + } else if err = v.ReadConfig(bufio.NewReader(buf)); err != nil { + t.Fatal(fmt.Errorf("failed to read config: %w", err)) + } + + // + decodedCfg, err := FromViper(v) + if err != nil { + t.Fatal(fmt.Errorf("failed to unmarshal config from viper: %w", err)) + } + + test(decodedCfg) +} + +func TestAllowMissingFormatter(t *testing.T) { as := require.New(t) - cfg, err := ReadFile("../test/examples/treefmt.toml", nil) - as.NoError(err, "failed to read config file") + cfg := make(map[string]any) + v, flags := newViper(t) - as.NotNil(cfg) + checkValue := func(expected bool) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.AllowMissingFormatter) + }) + } + + // default with no flag, env or config + checkValue(false) + + // set config value + cfg["allow-missing-formatter"] = true + checkValue(true) + + // env override + t.Setenv("TREEFMT_ALLOW_MISSING_FORMATTER", "false") + checkValue(false) + + // flag override + as.NoError(flags.Set("allow-missing-formatter", "true")) + checkValue(true) +} + +func TestCI(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValues := func(ci bool, noCache bool, failOnChange bool, verbosity uint8) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(ci, cfg.CI) + as.Equal(noCache, cfg.NoCache) + as.Equal(failOnChange, cfg.FailOnChange) + as.Equal(verbosity, cfg.Verbosity) + }) + } + + // default with no flag, env or config + checkValues(false, false, false, 0) + + // set config value + cfg["ci"] = true + checkValues(true, true, true, 1) + + // env override + t.Setenv("TREEFMT_CI", "false") + checkValues(false, false, false, 0) + + // flag override + as.NoError(flags.Set("ci", "true")) + checkValues(true, true, true, 1) + + // increase verbosity above 1 and check it isn't reset + cfg["verbose"] = 2 + checkValues(true, true, true, 2) +} + +func TestClearCache(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValue := func(expected bool) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.ClearCache) + }) + } + + // default with no flag, env or config + checkValue(false) + + // set config value + cfg["clear-cache"] = true + checkValue(true) + + // env override + t.Setenv("TREEFMT_CLEAR_CACHE", "false") + checkValue(false) + + // flag override + as.NoError(flags.Set("clear-cache", "true")) + checkValue(true) +} + +func TestCpuProfile(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValue := func(expected string) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.CpuProfile) + }) + } + + // default with no flag, env or config + checkValue("") + + // set config value + cfg["cpu-profile"] = "/foo/bar" + checkValue("/foo/bar") + + // env override + t.Setenv("TREEFMT_CPU_PROFILE", "/fizz/buzz") + checkValue("/fizz/buzz") + + // flag override + as.NoError(flags.Set("cpu-profile", "/bla/bla")) + checkValue("/bla/bla") +} + +func TestExcludes(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValue := func(expected []string) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.Excludes) + }) + } + + // default with no env or config + checkValue(nil) + + // set config value + cfg["excludes"] = []string{"foo", "bar"} + checkValue([]string{"foo", "bar"}) + + // test global.excludes fallback + delete(cfg, "excludes") + cfg["global"] = map[string]any{ + "excludes": []string{"fizz", "buzz"}, + } + checkValue([]string{"fizz", "buzz"}) + + // env override + t.Setenv("TREEFMT_EXCLUDES", "foo,bar") + checkValue([]string{"foo", "bar"}) + + // flag override + as.NoError(flags.Set("excludes", "bleep,bloop")) + checkValue([]string{"bleep", "bloop"}) +} + +func TestFailOnChange(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValue := func(expected bool) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.FailOnChange) + }) + } + + // default with no flag, env or config + checkValue(false) + + // set config value + cfg["fail-on-change"] = true + checkValue(true) + + // env override + t.Setenv("TREEFMT_FAIL_ON_CHANGE", "false") + checkValue(false) + + // flag override + as.NoError(flags.Set("fail-on-change", "true")) + checkValue(true) +} + +func TestFormatters(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValue := func(expected []string) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.Formatters) + }) + } + + // default with no env or config + checkValue([]string{}) + + // set config value + cfg["formatter"] = map[string]any{ + "echo": map[string]any{ + "command": "echo", + }, + "touch": map[string]any{ + "command": "touch", + }, + "date": map[string]any{ + "command": "date", + }, + } + cfg["formatters"] = []string{"echo", "touch"} + checkValue([]string{"echo", "touch"}) + + // env override + t.Setenv("TREEFMT_FORMATTERS", "echo,date") + checkValue([]string{"echo", "date"}) + + // flag override + as.NoError(flags.Set("formatters", "date,touch")) + checkValue([]string{"date", "touch"}) + + // bad formatter name + as.NoError(flags.Set("formatters", "foo,echo,date")) + _, err := FromViper(v) + as.ErrorContains(err, "formatter foo not found in config") +} + +func TestNoCache(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + checkValue := func(expected bool) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.NoCache) + }) + } + + // default with no flag, env or config + checkValue(false) + + // set config value + cfg["no-cache"] = true + checkValue(true) + + // env override + t.Setenv("TREEFMT_NO_CACHE", "false") + checkValue(false) + + // flag override + as.NoError(flags.Set("no-cache", "true")) + checkValue(true) +} + +func TestOnUnmatched(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValue := func(expected string) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.OnUnmatched) + }) + } + + // default with no flag, env or config + checkValue("warn") + + // set config value + cfg["on-unmatched"] = "error" + checkValue("error") + + // env override + t.Setenv("TREEFMT_ON_UNMATCHED", "debug") + checkValue("debug") + + // flag override + as.NoError(flags.Set("on-unmatched", "fatal")) + checkValue("fatal") +} + +func TestTreeRoot(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValue := func(expected string) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.TreeRoot) + }) + } + + // default with no flag, env or config + // should match the absolute path of the directory in which the config file is located + checkValue(filepath.Dir(v.ConfigFileUsed())) + + // set config value + cfg["tree-root"] = "/foo/bar" + checkValue("/foo/bar") + + // env override + t.Setenv("TREEFMT_TREE_ROOT", "/fizz/buzz") + checkValue("/fizz/buzz") + + // flag override + as.NoError(flags.Set("tree-root", "/flip/flop")) + checkValue("/flip/flop") +} + +func TestTreeRootFile(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + // create a directory structure with config files at various levels + tempDir := t.TempDir() + as.NoError(os.MkdirAll(filepath.Join(tempDir, "foo", "bar"), 0o755)) + as.NoError(os.WriteFile(filepath.Join(tempDir, "foo", "bar", "a.txt"), []byte{}, 0o644)) + as.NoError(os.WriteFile(filepath.Join(tempDir, "foo", "go.mod"), []byte{}, 0o644)) + as.NoError(os.MkdirAll(filepath.Join(tempDir, ".git"), 0o755)) + as.NoError(os.WriteFile(filepath.Join(tempDir, ".git", "config"), []byte{}, 0o644)) + + checkValue := func(treeRoot string, treeRootFile string) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(treeRoot, cfg.TreeRoot) + as.Equal(treeRootFile, cfg.TreeRootFile) + }) + } + + // default with no flag, env or config + // should match the absolute path of the directory in which the config file is located + checkValue(filepath.Dir(v.ConfigFileUsed()), "") + + // set config value + // should match the lowest directory + workDir := filepath.Join(tempDir, "foo", "bar") + cfg["working-dir"] = workDir + cfg["tree-root-file"] = "a.txt" + checkValue(workDir, "a.txt") + + // env override + // should match the directory above + t.Setenv("TREEFMT_TREE_ROOT_FILE", "go.mod") + checkValue(filepath.Join(tempDir, "foo"), "go.mod") + + // flag override + // should match the root of the temp directory structure + as.NoError(flags.Set("tree-root-file", ".git/config")) + checkValue(tempDir, ".git/config") +} + +func TestVerbosity(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, _ := newViper(t) + + checkValue := func(expected uint8) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.Verbosity) + }) + } + + // default with no flag, env or config + checkValue(0) + + // set config value + cfg["verbose"] = 1 + checkValue(1) + + // env override + t.Setenv("TREEFMT_VERBOSE", "2") + checkValue(2) + + // flag override + // todo unsure how to set a count flag via the flags api + // as.NoError(flags.Set("verbose", "v")) + // checkValue(1) +} + +func TestWalk(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValue := func(expected string) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.Walk) + }) + } + + // default with no flag, env or config + checkValue("auto") + + // set config value + cfg["walk"] = "git" + checkValue("git") + + // env override + t.Setenv("TREEFMT_WALK", "filesystem") + checkValue("filesystem") + + // flag override + as.NoError(flags.Set("walk", "auto")) + checkValue("auto") +} + +func TestWorkingDirectory(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValue := func(expected string) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(expected, cfg.WorkingDirectory) + }) + } + + cwd, err := os.Getwd() + as.NoError(err, "failed to get current working directory") + cwd, err = filepath.Abs(cwd) + as.NoError(err, "failed to get absolute path of current working directory") + + // default with no flag, env or config + // current working directory by default + checkValue(cwd) + + // set config value + // should resolve input paths to absolute paths + cfg["working-dir"] = "/foo/bar/baz/../fizz" + checkValue("/foo/bar/fizz") + + // env override + t.Setenv("TREEFMT_WORKING_DIR", "/fizz/buzz/..") + checkValue("/fizz") + + // flag override + as.NoError(flags.Set("working-dir", "/flip/flop")) + checkValue("/flip/flop") +} + +func TestStdin(t *testing.T) { + as := require.New(t) + + cfg := make(map[string]any) + v, flags := newViper(t) + + checkValues := func(stdin bool) { + readValue(t, v, cfg, func(cfg *Config) { + as.Equal(stdin, cfg.Stdin) + }) + } + + // default with no flag, env or config + checkValues(false) + + // set config value + cfg["stdin"] = true + checkValues(true) + + // env override + t.Setenv("TREEFMT_STDIN", "false") + checkValues(false) + + // flag override + as.NoError(flags.Set("stdin", "true")) + checkValues(true) +} + +func TestSampleConfigFile(t *testing.T) { + as := require.New(t) + + v := viper.New() + v.SetConfigFile("../test/examples/treefmt.toml") + as.NoError(v.ReadInConfig(), "failed to read config file") + + cfg, err := FromViper(v) + as.NoError(err, "failed to unmarshal config from viper") + + as.NotNil(cfg) as.Equal([]string{"*.toml"}, cfg.Global.Excludes) // python - python, ok := cfg.Formatters["python"] + python, ok := cfg.FormatterConfigs["python"] as.True(ok, "python formatter not found") as.Equal("black", python.Command) as.Nil(python.Options) @@ -25,7 +546,7 @@ func TestReadConfigFile(t *testing.T) { as.Nil(python.Excludes) // elm - elm, ok := cfg.Formatters["elm"] + elm, ok := cfg.FormatterConfigs["elm"] as.True(ok, "elm formatter not found") as.Equal("elm-format", elm.Command) as.Equal([]string{"--yes"}, elm.Options) @@ -33,7 +554,7 @@ func TestReadConfigFile(t *testing.T) { as.Nil(elm.Excludes) // go - golang, ok := cfg.Formatters["go"] + golang, ok := cfg.FormatterConfigs["go"] as.True(ok, "go formatter not found") as.Equal("gofmt", golang.Command) as.Equal([]string{"-w"}, golang.Options) @@ -41,7 +562,7 @@ func TestReadConfigFile(t *testing.T) { as.Nil(golang.Excludes) // haskell - haskell, ok := cfg.Formatters["haskell"] + haskell, ok := cfg.FormatterConfigs["haskell"] as.True(ok, "haskell formatter not found") as.Equal("ormolu", haskell.Command) as.Equal([]string{ @@ -55,7 +576,7 @@ func TestReadConfigFile(t *testing.T) { as.Equal([]string{"examples/haskell/"}, haskell.Excludes) // alejandra - alejandra, ok := cfg.Formatters["alejandra"] + alejandra, ok := cfg.FormatterConfigs["alejandra"] as.True(ok, "alejandra formatter not found") as.Equal("alejandra", alejandra.Command) as.Nil(alejandra.Options) @@ -64,7 +585,7 @@ func TestReadConfigFile(t *testing.T) { as.Equal(1, alejandra.Priority) // deadnix - deadnix, ok := cfg.Formatters["deadnix"] + deadnix, ok := cfg.FormatterConfigs["deadnix"] as.True(ok, "deadnix formatter not found") as.Equal("deadnix", deadnix.Command) as.Equal([]string{"-e"}, deadnix.Options) @@ -73,7 +594,7 @@ func TestReadConfigFile(t *testing.T) { as.Equal(2, deadnix.Priority) // ruby - ruby, ok := cfg.Formatters["ruby"] + ruby, ok := cfg.FormatterConfigs["ruby"] as.True(ok, "ruby formatter not found") as.Equal("rufo", ruby.Command) as.Equal([]string{"-x"}, ruby.Options) @@ -81,7 +602,7 @@ func TestReadConfigFile(t *testing.T) { as.Nil(ruby.Excludes) // prettier - prettier, ok := cfg.Formatters["prettier"] + prettier, ok := cfg.FormatterConfigs["prettier"] as.True(ok, "prettier formatter not found") as.Equal("prettier", prettier.Command) as.Equal([]string{"--write", "--tab-width", "4"}, prettier.Options) @@ -100,7 +621,7 @@ func TestReadConfigFile(t *testing.T) { as.Equal([]string{"CHANGELOG.md"}, prettier.Excludes) // rust - rust, ok := cfg.Formatters["rust"] + rust, ok := cfg.FormatterConfigs["rust"] as.True(ok, "rust formatter not found") as.Equal("rustfmt", rust.Command) as.Equal([]string{"--edition", "2018"}, rust.Options) @@ -108,7 +629,7 @@ func TestReadConfigFile(t *testing.T) { as.Nil(rust.Excludes) // shellcheck - shellcheck, ok := cfg.Formatters["shellcheck"] + shellcheck, ok := cfg.FormatterConfigs["shellcheck"] as.True(ok, "shellcheck formatter not found") as.Equal("shellcheck", shellcheck.Command) as.Equal(1, shellcheck.Priority) @@ -117,7 +638,7 @@ func TestReadConfigFile(t *testing.T) { as.Nil(shellcheck.Excludes) // shfmt - shfmt, ok := cfg.Formatters["shfmt"] + shfmt, ok := cfg.FormatterConfigs["shfmt"] as.True(ok, "shfmt formatter not found") as.Equal("shfmt", shfmt.Command) as.Equal(2, shfmt.Priority) @@ -126,7 +647,7 @@ func TestReadConfigFile(t *testing.T) { as.Nil(shfmt.Excludes) // opentofu - opentofu, ok := cfg.Formatters["opentofu"] + opentofu, ok := cfg.FormatterConfigs["opentofu"] as.True(ok, "opentofu formatter not found") as.Equal("tofu", opentofu.Command) as.Equal([]string{"fmt"}, opentofu.Options) @@ -134,7 +655,7 @@ func TestReadConfigFile(t *testing.T) { as.Nil(opentofu.Excludes) // missing - foo, ok := cfg.Formatters["foo-fmt"] + foo, ok := cfg.FormatterConfigs["foo-fmt"] as.True(ok, "foo formatter not found") as.Equal("foo-fmt", foo.Command) } diff --git a/config/formatter.go b/config/formatter.go deleted file mode 100644 index 1a780db1..00000000 --- a/config/formatter.go +++ /dev/null @@ -1,14 +0,0 @@ -package config - -type Formatter struct { - // Command is the command to invoke when applying this Formatter. - Command string `toml:"command"` - // Options are an optional list of args to be passed to Command. - Options []string `toml:"options,omitempty"` - // Includes is a list of glob patterns used to determine whether this Formatter should be applied against a path. - Includes []string `toml:"includes,omitempty"` - // Excludes is an optional list of glob patterns used to exclude certain files from this Formatter. - Excludes []string `toml:"excludes,omitempty"` - // Indicates the order of precedence when executing this Formatter in a sequence of Formatters. - Priority int `toml:"priority,omitempty"` -} diff --git a/go.mod b/go.mod index 2d0740bd..4018bb47 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.22 require ( github.com/BurntSushi/toml v1.4.0 github.com/adrg/xdg v0.5.0 - github.com/alecthomas/kong v1.2.1 github.com/charmbracelet/log v0.4.0 github.com/go-git/go-billy/v5 v5.5.1-0.20240930170605-5f263c979534 github.com/go-git/go-git/v5 v5.12.1-0.20240930111449-d1843220b6ab github.com/gobwas/glob v0.2.3 github.com/otiai10/copy v1.14.0 + github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 github.com/vmihailenco/msgpack/v5 v5.4.1 go.etcd.io/bbolt v1.3.11 @@ -26,31 +28,47 @@ require ( github.com/charmbracelet/lipgloss v0.10.0 // indirect github.com/cloudflare/circl v1.3.8 // indirect github.com/cyphar/filepath-securejoin v0.2.5 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.27.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/net v0.29.0 // indirect golang.org/x/sys v0.25.0 // indirect golang.org/x/term v0.24.0 // indirect + golang.org/x/text v0.18.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7360e3b1..6d72d5c5 100644 --- a/go.sum +++ b/go.sum @@ -9,16 +9,6 @@ github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0k github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY= github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4= -github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= -github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= -github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= -github.com/alecthomas/kong v1.2.0 h1:rzOKVDXrKg6hpQi+99VFbgkiXLCRbnYp18PAlK6wYas= -github.com/alecthomas/kong v1.2.0/go.mod h1:rKTSFhbdp3Ryefn8x5MOEprnRFQ7nlmMC01GKhehhBM= -github.com/alecthomas/kong v1.2.1 h1:E8jH4Tsgv6wCRX2nGrdPyHDUCSG83WH2qE4XLACD33Q= -github.com/alecthomas/kong v1.2.1/go.mod h1:rKTSFhbdp3Ryefn8x5MOEprnRFQ7nlmMC01GKhehhBM= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -33,45 +23,31 @@ github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1 github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI= github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elazarl/goproxy v0.0.0-20240618083138-03be62527ccb h1:2SoxRauy2IqekRMggrQk3yNI5X6omSnk6ugVbFywwXs= github.com/elazarl/goproxy v0.0.0-20240618083138-03be62527ccb/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.5.1-0.20240819193939-9b484184bdcc h1:fpw3vn8skBvfPwsKRq6K2o/55ZcwAid/9lubG/NyNNE= -github.com/go-git/go-billy/v5 v5.5.1-0.20240819193939-9b484184bdcc/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= -github.com/go-git/go-billy/v5 v5.5.1-0.20240828070317-59c50b000c7a h1:CDPmu0p7gv6zJn35T/RtZyIq98I2SwHtLrp697pM3KI= -github.com/go-git/go-billy/v5 v5.5.1-0.20240828070317-59c50b000c7a/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= -github.com/go-git/go-billy/v5 v5.5.1-0.20240902165505-04d471ab6285 h1:M1YAPXgyNZxbsGCVYIdOXrRBgbw+3+X5xhOXkNEehzw= -github.com/go-git/go-billy/v5 v5.5.1-0.20240902165505-04d471ab6285/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= -github.com/go-git/go-billy/v5 v5.5.1-0.20240917100134-b0c83cae0621 h1:iyMznNr6ULbB8jeiQ66tv2qZfGUef+61o7qa0BKfoho= -github.com/go-git/go-billy/v5 v5.5.1-0.20240917100134-b0c83cae0621/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= -github.com/go-git/go-billy/v5 v5.5.1-0.20240924073428-9745bbbd3431 h1:sQW0J3LNJrRunpY1fO7QO4m8dr6N3QTHuRh+WXbWyqA= -github.com/go-git/go-billy/v5 v5.5.1-0.20240924073428-9745bbbd3431/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= -github.com/go-git/go-billy/v5 v5.5.1-0.20240927131424-c1ee0b97d109 h1:7oA/JFyGfyGz60ykn+9oU+prmtdDA7hVFsaJpxwY5pc= -github.com/go-git/go-billy/v5 v5.5.1-0.20240927131424-c1ee0b97d109/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= github.com/go-git/go-billy/v5 v5.5.1-0.20240930170605-5f263c979534 h1:ReIiJ3+RmLoagnYcjfgxfxAaIG+zkzttS56LvUsnKN8= github.com/go-git/go-billy/v5 v5.5.1-0.20240930170605-5f263c979534/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.12.1-0.20240821195137-5c762aefcd8d h1:+KOoJFltZdLrtMrrOqaTYr8LWc7q296l6Y/+/bS9At0= -github.com/go-git/go-git/v5 v5.12.1-0.20240821195137-5c762aefcd8d/go.mod h1:tTeL/MQl8Pjm1QfKA/x/F0E04y9g5EynnXfV52kvvTw= -github.com/go-git/go-git/v5 v5.12.1-0.20240905150439-cef892e5701b h1:Y9dDjdayADW+IO4Yrwa0Pd7uLrEdac7mtfLAH69Ho2U= -github.com/go-git/go-git/v5 v5.12.1-0.20240905150439-cef892e5701b/go.mod h1:50xCkQWA/V4E2fDE+DpgupVhAqJhPv74BLsWyoOm1lc= -github.com/go-git/go-git/v5 v5.12.1-0.20240906142134-9cf0e3ee57dd h1:EaDJxDdERXsQegyT0DqsrDTo/OBnAmmrBIiq3OnHcdU= -github.com/go-git/go-git/v5 v5.12.1-0.20240906142134-9cf0e3ee57dd/go.mod h1:bN6A1YeroE4hsEk6jE8Tk507NxnKZNJLVABgVuChAFg= -github.com/go-git/go-git/v5 v5.12.1-0.20240925075259-8a7ce8143681 h1:2gWyKkIaiSvaBN+THAUg43AyUGz0RLuCUa7mCWTY93g= -github.com/go-git/go-git/v5 v5.12.1-0.20240925075259-8a7ce8143681/go.mod h1:bN6A1YeroE4hsEk6jE8Tk507NxnKZNJLVABgVuChAFg= github.com/go-git/go-git/v5 v5.12.1-0.20240930111449-d1843220b6ab h1:90RNld1ZF+pwfooOog4MslWouh9+IxERrqKxpHbJAdg= github.com/go-git/go-git/v5 v5.12.1-0.20240930111449-d1843220b6ab/go.mod h1:bN6A1YeroE4hsEk6jE8Tk507NxnKZNJLVABgVuChAFg= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -84,8 +60,10 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -99,11 +77,15 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= @@ -116,28 +98,57 @@ github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= @@ -147,13 +158,15 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= @@ -167,8 +180,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -189,8 +200,6 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -198,8 +207,6 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -209,8 +216,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -220,10 +227,13 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= mvdan.cc/sh/v3 v3.9.0 h1:it14fyjCdQUk4jf/aYxLO3FG8jFarR9GzMCtnlvvD7c= diff --git a/main.go b/main.go index c3f8ed33..06a08b61 100644 --- a/main.go +++ b/main.go @@ -1,40 +1,17 @@ +/* +Copyright © 2024 NAME HERE +*/ package main import ( - _ "embed" - "fmt" "os" - "github.com/alecthomas/kong" - "github.com/numtide/treefmt/build" - "github.com/numtide/treefmt/cli" + "github.com/numtide/treefmt/cmd" ) -// We embed the sample toml file for use with the init flag. -// -//go:embed init.toml -var initBytes []byte - func main() { - // This is to maintain compatibility with 1.0.0 which allows specifying the version with a `treefmt --version` flag - // on the 'default' command. With Kong it would be better to have `treefmt version` so it would be treated as a - // separate command. As it is, we would need to weaken some of the `existingdir` and `existingfile` checks kong is - // doing for us in the default format command. - for _, arg := range os.Args { - if arg == "--version" || arg == "-V" { - fmt.Printf("%s %s\n", build.Name, build.Version) - return - } else if arg == "--init" || arg == "-i" { - if err := os.WriteFile("treefmt.toml", initBytes, 0o644); err != nil { - fmt.Printf("Failed to write treefmt.toml: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Generated treefmt.toml. Now it's your turn to edit it.\n") - return - } + // todo how are exit codes thrown by commands? + if err := cmd.NewRoot().Execute(); err != nil { + os.Exit(1) } - - ctx := kong.Parse(cli.New(), cli.NewOptions()...) - ctx.FatalIfErrorf(ctx.Run()) } diff --git a/nix/devshells/default.nix b/nix/devshells/default.nix index 7d12631e..f9034d8f 100644 --- a/nix/devshells/default.nix +++ b/nix/devshells/default.nix @@ -13,13 +13,16 @@ perSystem.self.treefmt.overrideAttrs (old: { nativeBuildInputs = old.nativeBuildInputs - ++ [ - pkgs.goreleaser - pkgs.golangci-lint - pkgs.delve - pkgs.pprof - pkgs.graphviz - ] + ++ (with pkgs; [ + goreleaser + golangci-lint + delve + pprof + graphviz + cobra-cli + enumer + perSystem.gomod2nix.default + ]) ++ # include formatters for development and testing (import ../packages/treefmt/formatters.nix pkgs); diff --git a/nix/packages/treefmt/gomod2nix.toml b/nix/packages/treefmt/gomod2nix.toml index 0a72186c..9e07d269 100644 --- a/nix/packages/treefmt/gomod2nix.toml +++ b/nix/packages/treefmt/gomod2nix.toml @@ -1,4 +1,3 @@ -# Generated with `nix develop .#renovate -c gomod2nix:update` schema = 3 [mod] @@ -36,11 +35,14 @@ schema = 3 version = "v0.2.5" hash = "sha256-Hb9fRUHnMJJwy7XuHRG2l0YiTKh/5jUz2YJVdYScIfE=" [mod."github.com/davecgh/go-spew"] - version = "v1.1.1" - hash = "sha256-nhzSUrE1fCkN0+RL04N4h8jWmRFPPPWbCuDc7Ss0akI=" + version = "v1.1.2-0.20180830191138-d8f796af33cc" + hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" [mod."github.com/emirpasic/gods"] version = "v1.18.1" hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" + [mod."github.com/fsnotify/fsnotify"] + version = "v1.7.0" + hash = "sha256-MdT2rQyQHspPJcx6n9ozkLbsktIOJutOqDuKpNAtoZY=" [mod."github.com/go-git/gcfg"] version = "v1.5.1-0.20230307220236-3a3c6141e376" hash = "sha256-f4k0gSYuo0/q3WOoTxl2eFaj7WZpdz29ih6CKc8Ude8=" @@ -59,6 +61,12 @@ schema = 3 [mod."github.com/golang/groupcache"] version = "v0.0.0-20210331224755-41bb18bfe9da" hash = "sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0=" + [mod."github.com/hashicorp/hcl"] + version = "v1.0.0" + hash = "sha256-xsRCmYyBfglMxeWUvTZqkaRLSW+V2FvNodEDjTGg1WA=" + [mod."github.com/inconshreveable/mousetrap"] + version = "v1.1.0" + hash = "sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=" [mod."github.com/jbenet/go-context"] version = "v0.0.0-20150711004518-d14ea06fba99" hash = "sha256-VANNCWNNpARH/ILQV9sCQsBWgyL2iFT+4AHZREpxIWE=" @@ -68,12 +76,18 @@ schema = 3 [mod."github.com/lucasb-eyer/go-colorful"] version = "v1.2.0" hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" + [mod."github.com/magiconair/properties"] + version = "v1.8.7" + hash = "sha256-XQ2bnc2s7/IH3WxEO4GishZurMyKwEclZy1DXg+2xXc=" [mod."github.com/mattn/go-isatty"] version = "v0.0.20" hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" [mod."github.com/mattn/go-runewidth"] version = "v0.0.15" hash = "sha256-WP39EU2UrQbByYfnwrkBDoKN7xzXsBssDq3pNryBGm0=" + [mod."github.com/mitchellh/mapstructure"] + version = "v1.5.0" + hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE=" [mod."github.com/muesli/cancelreader"] version = "v0.2.2" hash = "sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ=" @@ -86,24 +100,54 @@ schema = 3 [mod."github.com/otiai10/copy"] version = "v1.14.0" hash = "sha256-xsaL1ddkPS544y0Jv7u/INUALBYmYq29ddWvysLXk4A=" + [mod."github.com/pelletier/go-toml/v2"] + version = "v2.2.2" + hash = "sha256-ukxk1Cfm6cQW18g/aa19tLcUu5BnF7VmfAvrDHAOl6A=" [mod."github.com/pjbgf/sha1cd"] version = "v0.3.0" hash = "sha256-kX9BdLh2dxtGNaDvc24NORO+C0AZ7JzbrXrtecCdB7w=" [mod."github.com/pmezard/go-difflib"] - version = "v1.0.0" - hash = "sha256-/FtmHnaGjdvEIKAJtrUfEhV7EVo5A/eYrtdnUkuxLDA=" + version = "v1.0.1-0.20181226105442-5d4384ee4fb2" + hash = "sha256-XA4Oj1gdmdV/F/+8kMI+DBxKPthZ768hbKsO3d9Gx90=" [mod."github.com/rivo/uniseg"] version = "v0.4.7" hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" + [mod."github.com/sagikazarmark/locafero"] + version = "v0.4.0" + hash = "sha256-7I1Oatc7GAaHgAqBFO6Tv4IbzFiYeU9bJAfJhXuWaXk=" + [mod."github.com/sagikazarmark/slog-shim"] + version = "v0.1.0" + hash = "sha256-F92BQXXmn3mCwu3mBaGh+joTRItQDSDhsjU6SofkYdA=" [mod."github.com/sergi/go-diff"] version = "v1.3.2-0.20230802210424-5b0b94c5c0d3" hash = "sha256-UcLU83CPMbSoKI8RLvLJ7nvGaE2xRSL1RjoHCVkMzUM=" [mod."github.com/skeema/knownhosts"] version = "v1.3.0" hash = "sha256-piR5IdfqxK9nxyErJ+IRDLnkaeNQwX93ztTFZyPm5MQ=" + [mod."github.com/sourcegraph/conc"] + version = "v0.3.0" + hash = "sha256-mIdMs9MLAOBKf3/0quf1iI3v8uNWydy7ae5MFa+F2Ko=" + [mod."github.com/spf13/afero"] + version = "v1.11.0" + hash = "sha256-+rV3cDZr13N8E0rJ7iHmwsKYKH+EhV+IXBut+JbBiIE=" + [mod."github.com/spf13/cast"] + version = "v1.6.0" + hash = "sha256-hxioqRZfXE0AE5099wmn3YG0AZF8Wda2EB4c7zHF6zI=" + [mod."github.com/spf13/cobra"] + version = "v1.8.1" + hash = "sha256-yDF6yAHycV1IZOrt3/hofR+QINe+B2yqkcIaVov3Ky8=" + [mod."github.com/spf13/pflag"] + version = "v1.0.5" + hash = "sha256-w9LLYzxxP74WHT4ouBspH/iQZXjuAh2WQCHsuvyEjAw=" + [mod."github.com/spf13/viper"] + version = "v1.19.0" + hash = "sha256-MZ8EAvdgpGbw6kmUz8UOaAAAMdPPGd14TrCBAY+A1T4=" [mod."github.com/stretchr/testify"] version = "v1.9.0" hash = "sha256-uUp/On+1nK+lARkTVtb5RxlW15zxtw2kaAFuIASA+J0=" + [mod."github.com/subosito/gotenv"] + version = "v1.6.0" + hash = "sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A=" [mod."github.com/vmihailenco/msgpack/v5"] version = "v5.4.1" hash = "sha256-pDplX6xU6UpNLcFbO1pRREW5vCnSPvSU+ojAwFDv3Hk=" @@ -116,6 +160,12 @@ schema = 3 [mod."go.etcd.io/bbolt"] version = "v1.3.11" hash = "sha256-SVWYZtE9TBgAo8xJSmo9DtSwuNa056N3zGvPLDJgiA8=" + [mod."go.uber.org/atomic"] + version = "v1.9.0" + hash = "sha256-D8OtLaViqPShz1w8ijhIHmjw9xVaRu0qD2hXKj63r4Q=" + [mod."go.uber.org/multierr"] + version = "v1.9.0" + hash = "sha256-tlDRooh/V4HDhZohsUrxot/Y6uVInVBtRWCZbj/tPds=" [mod."golang.org/x/crypto"] version = "v0.27.0" hash = "sha256-8HP4+gr4DbXI22GhdgZmCWr1ijtI9HNLsTcE0kltY9o=" @@ -134,6 +184,12 @@ schema = 3 [mod."golang.org/x/term"] version = "v0.24.0" hash = "sha256-PfC5psjzEWKRm1DlnBXX0ntw9OskJFrq1RRjyBa1lOk=" + [mod."golang.org/x/text"] + version = "v0.18.0" + hash = "sha256-aNvJW4gQs+MTfdz6DZqyyHQS2GJ9W8L8qKPVODPn4+k=" + [mod."gopkg.in/ini.v1"] + version = "v1.67.0" + hash = "sha256-V10ahGNGT+NLRdKUyRg1dos5RxLBXBk1xutcnquc/+4=" [mod."gopkg.in/warnings.v0"] version = "v0.1.2" hash = "sha256-ATVL9yEmgYbkJ1DkltDGRn/auGAjqGOfjQyBYyUo8s8=" diff --git a/test/temp.go b/test/temp.go index 59a2bfaf..660bb023 100644 --- a/test/temp.go +++ b/test/temp.go @@ -6,15 +6,14 @@ import ( "testing" "time" - "github.com/numtide/treefmt/config" - "github.com/BurntSushi/toml" cp "github.com/otiai10/copy" "github.com/stretchr/testify/require" ) -func WriteConfig(t *testing.T, path string, cfg config.Config) { +func WriteConfig(t *testing.T, path string, cfg map[string]any) { t.Helper() + f, err := os.Create(path) if err != nil { t.Fatalf("failed to create a new config file: %v", err) diff --git a/walk/type_enum.go b/walk/type_enum.go new file mode 100644 index 00000000..f888fee8 --- /dev/null +++ b/walk/type_enum.go @@ -0,0 +1,94 @@ +// Code generated by "enumer -type=Type -text -transform=snake -output=./type_enum.go"; DO NOT EDIT. + +package walk + +import ( + "fmt" + "strings" +) + +const _TypeName = "autogitfilesystem" + +var _TypeIndex = [...]uint8{0, 4, 7, 17} + +const _TypeLowerName = "autogitfilesystem" + +func (i Type) String() string { + if i < 0 || i >= Type(len(_TypeIndex)-1) { + return fmt.Sprintf("Type(%d)", i) + } + return _TypeName[_TypeIndex[i]:_TypeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _TypeNoOp() { + var x [1]struct{} + _ = x[Auto-(0)] + _ = x[Git-(1)] + _ = x[Filesystem-(2)] +} + +var _TypeValues = []Type{Auto, Git, Filesystem} + +var _TypeNameToValueMap = map[string]Type{ + _TypeName[0:4]: Auto, + _TypeLowerName[0:4]: Auto, + _TypeName[4:7]: Git, + _TypeLowerName[4:7]: Git, + _TypeName[7:17]: Filesystem, + _TypeLowerName[7:17]: Filesystem, +} + +var _TypeNames = []string{ + _TypeName[0:4], + _TypeName[4:7], + _TypeName[7:17], +} + +// TypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func TypeString(s string) (Type, error) { + if val, ok := _TypeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _TypeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to Type values", s) +} + +// TypeValues returns all values of the enum +func TypeValues() []Type { + return _TypeValues +} + +// TypeStrings returns a slice of all String values of the enum +func TypeStrings() []string { + strs := make([]string, len(_TypeNames)) + copy(strs, _TypeNames) + return strs +} + +// IsAType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i Type) IsAType() bool { + for _, v := range _TypeValues { + if i == v { + return true + } + } + return false +} + +// MarshalText implements the encoding.TextMarshaler interface for Type +func (i Type) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for Type +func (i *Type) UnmarshalText(text []byte) error { + var err error + *i, err = TypeString(string(text)) + return err +} diff --git a/walk/walker.go b/walk/walker.go index e3afb77f..e3b8231c 100644 --- a/walk/walker.go +++ b/walk/walker.go @@ -5,15 +5,17 @@ import ( "fmt" "io/fs" "os" + "path/filepath" "time" ) -type Type string +//go:generate enumer -type=Type -text -transform=snake -output=./type_enum.go +type Type int const ( - Git Type = "git" - Auto Type = "auto" - Filesystem Type = "filesystem" + Auto Type = iota + Git Type = iota + Filesystem ) type File struct { @@ -35,7 +37,7 @@ func (f File) HasChanged() (bool, fs.FileInfo, error) { } // POSIX specifies EPOCH time for Mod time, but some filesystems give more precision. - // Some formatters mess with the mod time (e.g., dos2unix) but not to the same precision, + // Some formatters mess with the mod time (e.g. dos2unix) but not to the same precision, // triggering false positives. // We truncate everything below a second. if f.Info.ModTime().Truncate(time.Second) != current.ModTime().Truncate(time.Second) { @@ -77,3 +79,58 @@ func Detect(root string, pathsCh chan string) (Walker, error) { } return NewFilesystem(root, pathsCh) } + +func FindUp(searchDir string, fileNames ...string) (path string, dir string, err error) { + for _, dir := range eachDir(searchDir) { + for _, f := range fileNames { + path := filepath.Join(dir, f) + if fileExists(path) { + return path, dir, nil + } + } + } + return "", "", fmt.Errorf("could not find %s in %s", fileNames, searchDir) +} + +func eachDir(path string) (paths []string) { + path, err := filepath.Abs(path) + if err != nil { + return + } + + paths = []string{path} + + if path == "/" { + return + } + + for i := len(path) - 1; i >= 0; i-- { + if path[i] == os.PathSeparator { + path = path[:i] + if path == "" { + path = "/" + } + paths = append(paths, path) + } + } + + return +} + +func fileExists(path string) bool { + // Some broken filesystems like SSHFS return file information on stat() but + // then cannot open the file. So we use os.Open. + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + // Next, check that the file is a regular file. + fi, err := f.Stat() + if err != nil { + return false + } + + return fi.Mode().IsRegular() +} From e92e4ab79bd9e28ea22352b3d7fd648d6bc3754d Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Mon, 7 Oct 2024 14:04:21 +0100 Subject: [PATCH 06/15] feat: simplify devshell Signed-off-by: Brian McGee --- nix/devshells/default.nix | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/nix/devshells/default.nix b/nix/devshells/default.nix index f9034d8f..d6f52080 100644 --- a/nix/devshells/default.nix +++ b/nix/devshells/default.nix @@ -3,17 +3,12 @@ perSystem, ... }: -perSystem.self.treefmt.overrideAttrs (old: { - GOROOT = "${old.go}/share/go"; +pkgs.mkShellNoCC { + env.GOROOT = "${pkgs.go}/share/go"; - shellHook = '' - # this is only needed for hermetic builds - unset GO_NO_VENDOR_CHECKS GOSUMDB GOPROXY GOFLAGS - ''; - - nativeBuildInputs = - old.nativeBuildInputs - ++ (with pkgs; [ + packages = + (with pkgs; [ + go goreleaser golangci-lint delve @@ -23,7 +18,6 @@ perSystem.self.treefmt.overrideAttrs (old: { enumer perSystem.gomod2nix.default ]) - ++ - # include formatters for development and testing + ++ # include formatters for development and testing (import ../packages/treefmt/formatters.nix pkgs); -}) +} From 927cf5c9372474c47cebc0b4bd7b96aba899e491 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Mon, 7 Oct 2024 14:04:33 +0100 Subject: [PATCH 07/15] chore: update flake inputs Signed-off-by: Brian McGee --- flake.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/flake.lock b/flake.lock index 71f95a48..e3cbb1bd 100644 --- a/flake.lock +++ b/flake.lock @@ -8,11 +8,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1727084436, - "narHash": "sha256-H5rbzYDlQD/lmTKvvfyohnhB+zdoZfykghjFHi2rS7o=", + "lastModified": 1727784414, + "narHash": "sha256-t3olvkbnZsv/XGFocOWnU+Hy+tT7QuzY2RecGWKs8WU=", "owner": "numtide", "repo": "blueprint", - "rev": "77e32417d97959e3d81d22211cba7c8ba44c0079", + "rev": "be8a5dbb413a047ef95334b17f51541b29be17ee", "type": "github" }, "original": { @@ -62,11 +62,11 @@ ] }, "locked": { - "lastModified": 1725515722, - "narHash": "sha256-+gljgHaflZhQXtr3WjJrGn8NXv7MruVPAORSufuCFnw=", + "lastModified": 1727668934, + "narHash": "sha256-nPpQ/4k6Fjaq2CHNSdO6j1ikiuWApuk/S6lU6ISp5SQ=", "owner": "nix-community", "repo": "gomod2nix", - "rev": "1c6fd4e862bf2f249c9114ad625c64c6c29a8a08", + "rev": "066e0dd2afde263f547cb0905b77cea00521d86c", "type": "github" }, "original": { @@ -92,11 +92,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1726937504, - "narHash": "sha256-bvGoiQBvponpZh8ClUcmJ6QnsNKw0EMrCQJARK3bI1c=", + "lastModified": 1728018373, + "narHash": "sha256-NOiTvBbRLIOe5F6RbHaAh6++BNjsb149fGZd1T4+KBg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "9357f4f23713673f310988025d9dc261c20e70c6", + "rev": "bc947f541ae55e999ffdb4013441347d83b00feb", "type": "github" }, "original": { @@ -153,11 +153,11 @@ ] }, "locked": { - "lastModified": 1727098951, - "narHash": "sha256-gplorAc0ISAUPemUNOnRUs7jr3WiLiHZb3DJh++IkZs=", + "lastModified": 1727984844, + "narHash": "sha256-xpRqITAoD8rHlXQafYZOLvUXCF6cnZkPfoq67ThN0Hc=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "35dfece10c642eb52928a48bee7ac06a59f93e9a", + "rev": "4446c7a6fc0775df028c5a3f6727945ba8400e64", "type": "github" }, "original": { From 6b22c434e8092008033ffb7915fcefeed9e4cb60 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Mon, 7 Oct 2024 15:06:58 +0100 Subject: [PATCH 08/15] feat: more stringent rules for golangci-lint Signed-off-by: Brian McGee --- .golangci.yml | 38 +++++++++ build/build.go | 4 +- cache/cache.go | 34 ++++---- cmd/format/format.go | 53 +++++++----- cmd/init/init.go | 4 +- cmd/root.go | 9 ++- cmd/root_test.go | 174 ++++++++++++++++++++++------------------ config/config.go | 73 +++++++++++++---- config/config_test.go | 87 ++++++++++++-------- format/formatter.go | 16 ++-- format/glob.go | 1 + format/glob_test.go | 3 +- format/task.go | 2 + test/temp.go | 14 +--- walk/filesystem.go | 13 +-- walk/filesystem_test.go | 12 ++- walk/git.go | 92 +++++++++++---------- walk/git_test.go | 37 ++++----- walk/walker.go | 11 ++- 19 files changed, 420 insertions(+), 257 deletions(-) create mode 100644 .golangci.yml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..7cdee4b8 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,38 @@ +linters: + enable: + - errname + - exhaustive + - gci + - gochecknoglobals + - gochecknoinits + - goconst + - godot + - gofumpt + - goheader + - goimports + - gosec + - importas + - ireturn + - lll + - makezero + - misspell + - nakedret + - nestif + - nilerr + - nilnil + - nlreturn + - noctx + - nolintlint + - prealloc + - predeclared + - revive + - rowserrcheck + - stylecheck + - tagliatelle + - tenv + - testpackage + - unconvert + - unparam + - wastedassign + - whitespace + - wsl diff --git a/build/build.go b/build/build.go index bd1c65ed..d8e21ba9 100644 --- a/build/build.go +++ b/build/build.go @@ -1,6 +1,8 @@ package build var ( - Name = "treefmt" + //nolint:gochecknoglobals + Name = "treefmt" + //nolint:gochecknoglobals Version = "v0.0.1+dev" ) diff --git a/cache/cache.go b/cache/cache.go index 7effc933..ac643896 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -2,21 +2,18 @@ package cache import ( "context" - "crypto/sha1" + "crypto/sha1" //nolint:gosec "encoding/hex" "fmt" "os" "runtime" "time" - "github.com/numtide/treefmt/stats" - + "github.com/adrg/xdg" + "github.com/charmbracelet/log" "github.com/numtide/treefmt/format" + "github.com/numtide/treefmt/stats" "github.com/numtide/treefmt/walk" - - "github.com/charmbracelet/log" - - "github.com/adrg/xdg" "github.com/vmihailenco/msgpack/v5" bolt "go.etcd.io/bbolt" ) @@ -48,11 +45,12 @@ func Open(treeRoot string, clean bool, formatters map[string]*format.Formatter) logger = log.WithPrefix("cache") // determine a unique and consistent db name for the tree root - h := sha1.New() + h := sha1.New() //nolint:gosec h.Write([]byte(treeRoot)) digest := h.Sum(nil) name := hex.EncodeToString(digest) + path, err := xdg.CacheFile(fmt.Sprintf("treefmt/eval-cache/%v.db", name)) if err != nil { return fmt.Errorf("could not resolve local path for the cache: %w", err) @@ -64,7 +62,7 @@ func Open(treeRoot string, clean bool, formatters map[string]*format.Formatter) return fmt.Errorf("failed to open cache at %v: %w", path, err) } - err = db.Update(func(tx *bolt.Tx) error { + return db.Update(func(tx *bolt.Tx) error { // create bucket for tracking paths pathsBucket, err := tx.CreateBucketIfNotExists([]byte(pathsBucket)) if err != nil { @@ -79,7 +77,6 @@ func Open(treeRoot string, clean bool, formatters map[string]*format.Formatter) // check for any newly configured or modified formatters for name, formatter := range formatters { - stat, err := os.Lstat(formatter.Executable()) if err != nil { return fmt.Errorf("failed to stat formatter executable %v: %w", formatter.Executable(), err) @@ -130,6 +127,7 @@ func Open(treeRoot string, clean bool, formatters map[string]*format.Formatter) // indicate a clean is required clean = true } + return nil }); err != nil { return fmt.Errorf("failed to check cache for removed formatters: %w", err) @@ -147,8 +145,6 @@ func Open(treeRoot string, clean bool, formatters map[string]*format.Formatter) return nil }) - - return } // Close closes any open instance of the cache. @@ -156,6 +152,7 @@ func Close() error { if db == nil { return nil } + return db.Close() } @@ -167,10 +164,11 @@ func getEntry(bucket *bolt.Bucket, path string) (*Entry, error) { if err := msgpack.Unmarshal(b, &cached); err != nil { return nil, fmt.Errorf("failed to unmarshal cache info for path '%v': %w", path, err) } + return &cached, nil - } else { - return nil, nil } + //nolint:nilnil + return nil, nil } // putEntry is a helper for writing cache entries into bolt. @@ -183,6 +181,7 @@ func putEntry(bucket *bolt.Bucket, path string, entry *Entry) error { if err = bucket.Put([]byte(path), bytes); err != nil { return fmt.Errorf("failed to put cache path %v: %w", path, err) } + return nil } @@ -196,7 +195,9 @@ func ChangeSet(ctx context.Context, walker walk.Walker, filesCh chan<- *walk.Fil }() var tx *bolt.Tx + var bucket *bolt.Bucket + var processed int defer func() { @@ -226,6 +227,7 @@ func ChangeSet(ctx context.Context, walker walk.Walker, filesCh chan<- *walk.Fil if err != nil { return fmt.Errorf("failed to open a new cache read tx: %w", err) } + bucket = tx.Bucket([]byte(pathsBucket)) } @@ -237,6 +239,7 @@ func ChangeSet(ctx context.Context, walker walk.Walker, filesCh chan<- *walk.Fil changedOrNew := cached == nil || !(cached.Modified == file.Info.ModTime() && cached.Size == file.Info.Size()) stats.Add(stats.Traversed, 1) + if !changedOrNew { // no change return nil @@ -253,10 +256,11 @@ func ChangeSet(ctx context.Context, walker walk.Walker, filesCh chan<- *walk.Fil } // close the current tx if we have reached the batch size - processed += 1 + processed++ if processed == ReadBatchSize { err = tx.Rollback() tx = nil + return err } diff --git a/cmd/format/format.go b/cmd/format/format.go index 9046d297..0df393e1 100644 --- a/cmd/format/format.go +++ b/cmd/format/format.go @@ -23,7 +23,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/sync/errgroup" - "mvdan.cc/sh/v3/expand" ) @@ -60,16 +59,17 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { // Wait until we tick over into the next second before processing to ensure our EPOCH level modtime comparisons // for change detection are accurate. // This can fail in CI between checkout and running treefmt if everything happens too quickly. - // For humans, the second level precision should not be a problem as they are unlikely to run treefmt in sub-second succession. + // For humans, the second level precision should not be a problem as they are unlikely to run treefmt in + // sub-second succession. <-time.After(time.Until(startAfter)) } - if cfg.Stdin { - // check we have only received one path arg which we use for the file extension / matching to formatters - if len(paths) != 1 { - return fmt.Errorf("exactly one path should be specified when using the --stdin flag") - } + // for stdin, check we have only received one path arg which we use for the file extension / matching to formatters + if cfg.Stdin && len(paths) != 1 { + return fmt.Errorf("exactly one path should be specified when using the --stdin flag") + } + if cfg.Stdin { // read stdin into a temporary file with the same file extension pattern := fmt.Sprintf("*%s", filepath.Ext(paths[0])) @@ -93,7 +93,6 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { // update paths with temp file paths[0] = file.Name() - } else { // checks all paths are contained within the tree root for idx, path := range paths { @@ -107,15 +106,17 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { } // cpu profiling - if cfg.CpuProfile != "" { - cpuProfile, err := os.Create(cfg.CpuProfile) + if cfg.CPUProfile != "" { + cpuProfile, err := os.Create(cfg.CPUProfile) if err != nil { return fmt.Errorf("failed to open file for writing cpu profile: %w", err) } else if err = pprof.StartCPUProfile(cpuProfile); err != nil { return fmt.Errorf("failed to start cpu profile: %w", err) } + defer func() { pprof.StopCPUProfile() + if err := cpuProfile.Close(); err != nil { log.Errorf("failed to close cpu profile: %v", err) } @@ -148,6 +149,7 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { if errors.Is(err, format.ErrCommandNotFound) && cfg.AllowMissingFormatter { log.Debugf("formatter command not found: %v", name) + continue } else if err != nil { return fmt.Errorf("%w: failed to initialise formatter: %v", err, name) @@ -162,6 +164,7 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { if err = cache.Open(cfg.TreeRoot, cfg.ClearCache, formatters); err != nil { // if we can't open the cache, we log a warning and fallback to no cache log.Warnf("failed to open cache: %v", err) + cfg.NoCache = true } } @@ -230,7 +233,8 @@ func walkFilesystem( return ctx.Err() default: pathsCh <- paths[idx] - idx += 1 + + idx++ } } @@ -253,7 +257,7 @@ func walkFilesystem( // if no cache has been configured, or we are processing from stdin, we invoke the walker directly if cfg.NoCache || cfg.Stdin { - return walker.Walk(ctx, func(file *walk.File, err error) error { + return walker.Walk(ctx, func(file *walk.File, _ error) error { select { case <-ctx.Done(): return ctx.Err() @@ -261,6 +265,7 @@ func walkFilesystem( stats.Add(stats.Traversed, 1) stats.Add(stats.Emitted, 1) filesCh <- file + return nil } }) @@ -271,11 +276,12 @@ func walkFilesystem( if err = cache.ChangeSet(ctx, walker, filesCh); err != nil { return fmt.Errorf("failed to generate change set: %w", err) } + return nil } } -// applyFormatters +// applyFormatters. func applyFormatters( ctx context.Context, cfg *config.Config, @@ -304,7 +310,6 @@ func applyFormatters( // process the batch if it's full, or we've been asked to flush partial batches if flush || len(batch) == BatchSize { - // copy the batch as we re-use it for the next batch tasks := make([]*format.Task, len(batch)) copy(tasks, batch) @@ -316,6 +321,7 @@ func applyFormatters( formatters := tasks[0].Formatters var formatErrors []error + for idx := range formatters { if err := formatters[idx].Apply(ctx, tasks); err != nil { formatErrors = append(formatErrors, err) @@ -357,7 +363,6 @@ func applyFormatters( // iterate the files channel for file := range filesCh { - // first check if this file has been globally excluded if format.PathMatches(file.RelPath, globalExcludes) { log.Debugf("path matched global excludes: %s", file.RelPath) @@ -365,11 +370,13 @@ func applyFormatters( formattedCh <- &format.Task{ File: file, } + continue } // check if any formatters are interested in this file var matches []*format.Formatter + for _, formatter := range formatters { if formatter.Wants(file) { matches = append(matches, formatter) @@ -378,11 +385,12 @@ func applyFormatters( // see if any formatters matched if len(matches) == 0 { - if unmatchedLevel == log.FatalLevel { return fmt.Errorf("no formatter for path: %s", file.RelPath) } + log.Logf(unmatchedLevel, "no formatter for path: %s", file.RelPath) + // mark it as processed and continue to the next formattedCh <- &format.Task{ File: file, @@ -405,11 +413,17 @@ func applyFormatters( if err := fg.Wait(); err != nil { return fmt.Errorf("formatting failure: %w", err) } + return nil } } -func detectFormatted(ctx context.Context, cfg *config.Config, formattedCh chan *format.Task, processedCh chan *format.Task) func() error { +func detectFormatted( + ctx context.Context, + cfg *config.Config, + formattedCh chan *format.Task, + processedCh chan *format.Task, +) func() error { return func() error { defer func() { // close formatted channel @@ -418,7 +432,6 @@ func detectFormatted(ctx context.Context, cfg *config.Config, formattedCh chan * for { select { - // detect ctx cancellation case <-ctx.Done(): return ctx.Err() @@ -431,6 +444,7 @@ func detectFormatted(ctx context.Context, cfg *config.Config, formattedCh chan * // check if the file has changed file := task.File + changed, newInfo, err := file.HasChanged() if err != nil { return err @@ -478,10 +492,13 @@ func updateCache(ctx context.Context, cfg *config.Config, processedCh chan *form for idx := range batch { files[idx] = batch[idx].File } + if err := cache.Update(files); err != nil { return err } + batch = batch[:0] + return nil } diff --git a/cmd/init/init.go b/cmd/init/init.go index 91c5dc11..4b780317 100644 --- a/cmd/init/init.go +++ b/cmd/init/init.go @@ -12,9 +12,11 @@ import ( var initBytes []byte func Run() error { - if err := os.WriteFile("treefmt.toml", initBytes, 0o644); err != nil { + if err := os.WriteFile("treefmt.toml", initBytes, 0o600); err != nil { return fmt.Errorf("failed to write treefmt.toml: %w", err) } + fmt.Printf("Generated treefmt.toml. Now it's your turn to edit it.\n") + return nil } diff --git a/cmd/root.go b/cmd/root.go index 65ac7a43..b92a8fad 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,7 +26,7 @@ func NewRoot() *cobra.Command { // create out root command cmd := &cobra.Command{ - Use: "treefmt ", + Use: fmt.Sprintf("%s ", build.Name), Short: "One CLI to format your repo", Version: build.Version, RunE: func(cmd *cobra.Command, args []string) error { @@ -44,7 +44,11 @@ func NewRoot() *cobra.Command { cmd.MarkFlagsMutuallyExclusive("tree-root", "tree-root-file") // add a couple of special flags which don't have a corresponding entry in treefmt.toml - pfs.StringVar(&configFile, "config-file", "", "Load the config file from the given path (defaults to searching upwards for treefmt.toml or .treefmt.toml).") + pfs.StringVar( + &configFile, "config-file", "", + "Load the config file from the given path (defaults to searching upwards for treefmt.toml or .treefmt.toml).", + ) + pfs.BoolVarP(&treefmtInit, "init", "i", false, "Create a treefmt.toml file in the current directory.") // bind prj_root to the tree-root flag, allowing viper to handle environment override for us @@ -99,6 +103,7 @@ func runE(v *viper.Viper, cmd *cobra.Command, args []string) error { // read in the config v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err != nil { cobra.CheckErr(fmt.Errorf("failed to read config file '%s': %w", configFile, err)) } diff --git a/cmd/root_test.go b/cmd/root_test.go index 95dbec4d..57381a2e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,4 +1,4 @@ -package cmd +package cmd_test import ( "bufio" @@ -13,19 +13,15 @@ import ( "time" "github.com/charmbracelet/log" - "github.com/numtide/treefmt/stats" - - format2 "github.com/numtide/treefmt/cmd/format" - - "github.com/numtide/treefmt/format" - - "github.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/numtide/treefmt/cmd" + format2 "github.com/numtide/treefmt/cmd/format" + "github.com/numtide/treefmt/format" + "github.com/numtide/treefmt/stats" + "github.com/numtide/treefmt/test" "github.com/stretchr/testify/require" ) @@ -56,7 +52,7 @@ func TestOnUnmatched(t *testing.T) { // - "haskell/treefmt.toml" } - _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--on-unmatched", "fatal") + _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "--on-unmatched", "fatal") as.ErrorContains(err, fmt.Sprintf("no formatter for path: %s", paths[0])) checkOutput := func(level string, output []byte) { @@ -68,24 +64,24 @@ func TestOnUnmatched(t *testing.T) { var out []byte // default is warn - out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c") + out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c") as.NoError(err) checkOutput("WARN", out) - out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "--on-unmatched", "warn") + out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "--on-unmatched", "warn") as.NoError(err) checkOutput("WARN", out) - out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-u", "error") + out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-u", "error") as.NoError(err) checkOutput("ERRO", out) - out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-v", "--on-unmatched", "info") + out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-v", "--on-unmatched", "info") as.NoError(err) checkOutput("INFO", out) t.Setenv("TREEFMT_ON_UNMATCHED", "debug") - out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-vv") + out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-vv") as.NoError(err) checkOutput("DEBU", out) } @@ -103,14 +99,14 @@ func TestCpuProfile(t *testing.T) { as.NoError(os.Chdir(cwd)) }) - _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--cpu-profile", "cpu.pprof") + _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "--cpu-profile", "cpu.pprof") as.NoError(err) as.FileExists(filepath.Join(tempDir, "cpu.pprof")) _, err = os.Stat(filepath.Join(tempDir, "cpu.pprof")) as.NoError(err) t.Setenv("TREEFMT_CPU_PROFILE", "env.pprof") - _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter") + _, err = execute(t, "-C", tempDir, "--allow-missing-formatter") as.NoError(err) as.FileExists(filepath.Join(tempDir, "env.pprof")) _, err = os.Stat(filepath.Join(tempDir, "env.pprof")) @@ -133,14 +129,14 @@ func TestAllowMissingFormatter(t *testing.T) { test.WriteConfig(t, configPath, cfg) - _, err := cmd(t, "--config-file", configPath, "--tree-root", tempDir) + _, err := execute(t, "--config-file", configPath, "--tree-root", tempDir) as.ErrorIs(err, format.ErrCommandNotFound) - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--allow-missing-formatter") + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "--allow-missing-formatter") as.NoError(err) t.Setenv("TREEFMT_ALLOW_MISSING_FORMATTER", "true") - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) } @@ -178,32 +174,44 @@ func TestSpecifyingFormatters(t *testing.T) { } setup() - _, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + + _, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + as.NoError(err) assertStats(t, as, 32, 32, 3, 3) setup() - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "elm,nix") + + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "elm,nix") + as.NoError(err) assertStats(t, as, 32, 32, 2, 2) setup() - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "-f", "ruby,nix") + + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "-f", "ruby,nix") + as.NoError(err) assertStats(t, as, 32, 32, 2, 2) setup() - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "nix") + + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "nix") + as.NoError(err) assertStats(t, as, 32, 32, 1, 1) // test bad names setup() - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "foo") + + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "foo") + as.Errorf(err, "formatter not found in config: foo") t.Setenv("TREEFMT_FORMATTERS", "bar,foo") - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + as.Errorf(err, "formatter not found in config: bar") } @@ -224,7 +232,7 @@ func TestIncludesAndExcludes(t *testing.T) { } test.WriteConfig(t, configPath, cfg) - _, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 32, 0) @@ -232,7 +240,7 @@ func TestIncludesAndExcludes(t *testing.T) { cfg["excludes"] = []string{"*.nix"} test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 31, 0) @@ -240,7 +248,7 @@ func TestIncludesAndExcludes(t *testing.T) { cfg["excludes"] = []string{"*.nix", "*.hs"} test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 25, 0) @@ -250,7 +258,7 @@ func TestIncludesAndExcludes(t *testing.T) { echo["excludes"] = []string{"*.py"} test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 23, 0) @@ -258,7 +266,7 @@ func TestIncludesAndExcludes(t *testing.T) { t.Setenv("TREEFMT_FORMATTER_ECHO_EXCLUDES", "*.py,*.go") test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 22, 0) @@ -268,7 +276,7 @@ func TestIncludesAndExcludes(t *testing.T) { echo["includes"] = []string{"*.elm"} test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 1, 0) @@ -276,7 +284,7 @@ func TestIncludesAndExcludes(t *testing.T) { t.Setenv("TREEFMT_FORMATTER_ECHO_INCLUDES", "*.elm,*.js") test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 2, 0) } @@ -299,7 +307,7 @@ func TestPrjRootEnvVariable(t *testing.T) { test.WriteConfig(t, configPath, cfg) t.Setenv("PRJ_ROOT", tempDir) - _, err := cmd(t, "--config-file", configPath) + _, err := execute(t, "--config-file", configPath) as.NoError(err) assertStats(t, as, 32, 32, 32, 0) } @@ -323,34 +331,34 @@ func TestCache(t *testing.T) { var err error test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 32, 0) - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 0, 0, 0) // clear cache - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-c") + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "-c") as.NoError(err) assertStats(t, as, 32, 32, 32, 0) - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 0, 0, 0) // clear cache - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-c") + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "-c") as.NoError(err) assertStats(t, as, 32, 32, 32, 0) - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir) + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, 32, 0, 0, 0) // no cache - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") as.NoError(err) assertStats(t, as, 32, 32, 32, 0) } @@ -384,13 +392,13 @@ func TestChangeWorkingDirectory(t *testing.T) { // 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) + _, err = execute(t, "-C", tempDir) as.NoError(err) assertStats(t, as, 32, 32, 32, 0) // use env t.Setenv("TREEFMT_WORKING_DIR", tempDir) - _, err = cmd(t, "-c") + _, err = execute(t, "-c") as.NoError(err) assertStats(t, as, 32, 32, 32, 0) } @@ -412,7 +420,7 @@ func TestFailOnChange(t *testing.T) { } test.WriteConfig(t, configPath, cfg) - _, err := cmd(t, "--fail-on-change", "--config-file", configPath, "--tree-root", tempDir) + _, err := execute(t, "--fail-on-change", "--config-file", configPath, "--tree-root", tempDir) as.ErrorIs(err, format2.ErrFailOnChange) // we have second precision mod time tracking @@ -421,7 +429,7 @@ func TestFailOnChange(t *testing.T) { // test with no cache t.Setenv("TREEFMT_FAIL_ON_CHANGE", "true") test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") + _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") as.ErrorIs(err, format2.ErrFailOnChange) } @@ -444,7 +452,7 @@ func TestBustCacheOnFormatterChange(t *testing.T) { } // prepend our test bin directory to PATH - as.NoError(os.Setenv("PATH", binPath+":"+os.Getenv("PATH"))) + t.Setenv("PATH", binPath+":"+os.Getenv("PATH")) // start with 2 formatters cfg := map[string]any{ @@ -463,31 +471,31 @@ func TestBustCacheOnFormatterChange(t *testing.T) { test.WriteConfig(t, configPath, cfg) args := []string{"--config-file", configPath, "--tree-root", tempDir} - _, err := cmd(t, args...) + _, err := execute(t, args...) as.NoError(err) assertStats(t, as, 32, 32, 3, 0) // tweak mod time of elm formatter as.NoError(test.RecreateSymlink(t, binPath+"/"+"elm-format")) - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 32, 3, 0) // check cache is working - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 0, 0, 0) // tweak mod time of python formatter as.NoError(test.RecreateSymlink(t, binPath+"/"+"black")) - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 32, 3, 0) // check cache is working - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 0, 0, 0) @@ -499,14 +507,15 @@ func TestBustCacheOnFormatterChange(t *testing.T) { "options": []string{"-w"}, "includes": []string{"*.go"}, } + test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 32, 4, 0) // check cache is working - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 0, 0, 0) @@ -514,12 +523,12 @@ func TestBustCacheOnFormatterChange(t *testing.T) { delete(formatters, "python") test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 32, 2, 0) // check cache is working - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 0, 0, 0) @@ -527,12 +536,12 @@ func TestBustCacheOnFormatterChange(t *testing.T) { delete(formatters, "elm") test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 32, 1, 0) // check cache is working - _, err = cmd(t, args...) + _, err = execute(t, args...) as.NoError(err) assertStats(t, as, 32, 0, 0, 0) } @@ -570,7 +579,7 @@ func TestGitWorktree(t *testing.T) { as.NoError(err, "failed to get git worktree") run := func(traversed int32, emitted int32, matched int32, formatted int32) { - _, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) assertStats(t, as, traversed, emitted, matched, formatted) } @@ -592,7 +601,7 @@ func TestGitWorktree(t *testing.T) { run(28, 28, 28, 0) // walk with filesystem instead of git - _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--walk", "filesystem") + _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--walk", "filesystem") as.NoError(err) assertStats(t, as, 60, 60, 60, 0) @@ -606,20 +615,20 @@ func TestGitWorktree(t *testing.T) { }) // format specific sub paths - _, err = cmd(t, "-C", tempDir, "-c", "go", "-vv") + _, err = execute(t, "-C", tempDir, "-c", "go", "-vv") as.NoError(err) assertStats(t, as, 2, 2, 2, 0) - _, err = cmd(t, "-C", tempDir, "-c", "go", "haskell") + _, err = execute(t, "-C", tempDir, "-c", "go", "haskell") as.NoError(err) assertStats(t, as, 9, 9, 9, 0) - _, err = cmd(t, "-C", tempDir, "-c", "go", "haskell", "ruby") + _, err = execute(t, "-C", tempDir, "-c", "go", "haskell", "ruby") as.NoError(err) assertStats(t, as, 10, 10, 10, 0) // try with a bad path - _, err = cmd(t, "-C", tempDir, "-c", "haskell", "foo") + _, err = execute(t, "-C", tempDir, "-c", "haskell", "foo") as.ErrorContains(err, "path foo not found within the tree root") assertStats(t, as, 0, 0, 0, 0) @@ -628,7 +637,7 @@ func TestGitWorktree(t *testing.T) { as.NoError(err) assertStats(t, as, 0, 0, 0, 0) - _, err = cmd(t, "-C", tempDir, "-c", "foo.txt") + _, err = execute(t, "-C", tempDir, "-c", "foo.txt") as.NoError(err) assertStats(t, as, 0, 0, 0, 0) } @@ -663,22 +672,22 @@ func TestPathsArg(t *testing.T) { test.WriteConfig(t, configPath, cfg) // without any path args - _, err = cmd(t) + _, err = execute(t) as.NoError(err) assertStats(t, as, 32, 32, 32, 0) // specify some explicit paths - _, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") + _, err = execute(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") as.NoError(err) assertStats(t, as, 2, 2, 2, 0) // specify a bad path - _, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Bar.hs") + _, err = execute(t, "-c", "elm/elm.json", "haskell/Nested/Bar.hs") as.ErrorContains(err, "path haskell/Nested/Bar.hs not found within the tree root") // specify a path outside the tree root externalPath := filepath.Join(cwd, "go.mod") - _, err = cmd(t, "-c", externalPath) + _, err = execute(t, "-c", externalPath) as.ErrorContains(err, fmt.Sprintf("path %s not found within the tree root", externalPath)) } @@ -697,6 +706,7 @@ func TestStdin(t *testing.T) { // capture current stdin and replace it on test cleanup prevStdIn := os.Stdin + t.Cleanup(func() { os.Stdin = prevStdIn }) @@ -705,7 +715,7 @@ func TestStdin(t *testing.T) { contents := `{ foo, ... }: "hello"` os.Stdin = test.TempFile(t, "", "stdin", &contents) // we get an error about the missing filename parameter. - out, err := cmd(t, "-C", tempDir, "--allow-missing-formatter", "--stdin") + out, err := execute(t, "-C", tempDir, "--allow-missing-formatter", "--stdin") as.EqualError(err, "exactly one path should be specified when using the --stdin flag") as.Equal("", string(out)) @@ -714,7 +724,7 @@ func TestStdin(t *testing.T) { os.Stdin = test.TempFile(t, "", "stdin", &contents) t.Setenv("TREEFMT_STDIN", "true") - out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "test.nix") + out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "test.nix") as.NoError(err) assertStats(t, as, 1, 1, 1, 1) @@ -731,7 +741,7 @@ func TestStdin(t *testing.T) { ` os.Stdin = test.TempFile(t, "", "stdin", &contents) - out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.md") + out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.md") as.NoError(err) assertStats(t, as, 1, 1, 1, 1) @@ -779,7 +789,7 @@ func TestDeterministicOrderingInPipeline(t *testing.T) { } test.WriteConfig(t, configPath, cfg) - _, err = cmd(t, "-C", tempDir) + _, err = execute(t, "-C", tempDir) as.NoError(err) matcher := regexp.MustCompile("^fmt-(.*)") @@ -791,18 +801,21 @@ func TestDeterministicOrderingInPipeline(t *testing.T) { for _, p := range paths { file, err := os.Open(filepath.Join(tempDir, p)) as.NoError(err) - scanner := bufio.NewScanner(file) idx := 0 + scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() + matches := matcher.FindAllString(line, -1) if len(matches) != 1 { continue } + as.Equal(sequence[idx], matches[0]) - idx += 1 + + idx++ } } } @@ -825,8 +838,10 @@ func TestRunInSubdir(t *testing.T) { // Also test that formatters are resolved relative to the treefmt root echoPath, err := exec.LookPath("echo") as.NoError(err) + echoRel := path.Join(tempDir, "echo") err = os.Symlink(echoPath, echoRel) + as.NoError(err) // change working directory to sub directory @@ -844,17 +859,17 @@ func TestRunInSubdir(t *testing.T) { test.WriteConfig(t, configPath, cfg) // without any path args, should reformat the whole tree - _, err = cmd(t) + _, err = execute(t) as.NoError(err) assertStats(t, as, 32, 32, 32, 0) // specify some explicit paths, relative to the tree root - _, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") + _, err = execute(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") as.NoError(err) assertStats(t, as, 2, 2, 2, 0) } -func cmd(t *testing.T, args ...string) ([]byte, error) { +func execute(t *testing.T, args ...string) ([]byte, error) { t.Helper() tempDir := t.TempDir() @@ -878,12 +893,13 @@ func cmd(t *testing.T, args ...string) ([]byte, error) { }() // run the command - root := NewRoot() + root := cmd.NewRoot() if args == nil { // we must pass an empty array otherwise cobra with use os.Args[1:] args = []string{} } + root.SetArgs(args) root.SetOut(tempOut) root.SetErr(tempOut) diff --git a/config/config.go b/config/config.go index f2c38ac2..5eb9f9e9 100644 --- a/config/config.go +++ b/config/config.go @@ -15,7 +15,7 @@ type Config struct { AllowMissingFormatter bool `mapstructure:"allow-missing-formatter,omitempty"` CI bool `mapstructure:"ci,omitempty"` ClearCache bool `mapstructure:"clear-cache,omitempty"` - CpuProfile string `mapstructure:"cpu-profile,omitempty"` + CPUProfile string `mapstructure:"cpu-profile,omitempty"` Excludes []string `mapstructure:"excludes"` FailOnChange bool `mapstructure:"fail-on-change,omitempty"` Formatters []string `mapstructure:"formatters,omitempty"` @@ -37,39 +37,78 @@ type Config struct { type Formatter struct { // Command is the command to invoke when applying this Formatter. - Command string `toml:"command" mapstructure:"command" toml:"command"` + Command string `mapstructure:"command"` // Options are an optional list of args to be passed to Command. - Options []string `toml:"options,omitempty" mapstructure:"options,omitempty" toml:"options,omitempty"` + Options []string `mapstructure:"options,omitempty"` // Includes is a list of glob patterns used to determine whether this Formatter should be applied against a path. - Includes []string `toml:"includes,omitempty" mapstructure:"includes,omitempty" toml:"includes,omitempty"` + Includes []string `mapstructure:"includes,omitempty"` // Excludes is an optional list of glob patterns used to exclude certain files from this Formatter. - Excludes []string `toml:"excludes,omitempty" mapstructure:"excludes,omitempty" toml:"excludes,omitempty"` + Excludes []string `mapstructure:"excludes,omitempty"` // Indicates the order of precedence when executing this Formatter in a sequence of Formatters. - Priority int `toml:"priority,omitempty" mapstructure:"priority,omitempty" toml:"priority,omitempty"` + Priority int `mapstructure:"priority,omitempty"` } // SetFlags appends our flags to the provided flag set. // We have a flag matching most entries in Config, taking care to ensure the name matches the field name defined in the // mapstructure tag. -// We can rely on a flag's default value being provided in the event the same value was not specified in the config file. +// We can rely on a flag's default value being provided in the event the same value was not specified in the config +// file. func SetFlags(fs *pflag.FlagSet) *pflag.FlagSet { - fs.Bool("allow-missing-formatter", false, "Do not exit with error if a configured formatter is missing.") - fs.Bool("ci", false, "Runs treefmt in a CI mode, enabling --no-cache, --fail-on-change and adjusting some other settings best suited to a CI use case.") - fs.BoolP("clear-cache", "c", false, "Reset the evaluation cache. Use in case the cache is not precise enough.") + fs.Bool( + "allow-missing-formatter", false, + "Do not exit with error if a configured formatter is missing.", + ) + + fs.Bool( + "ci", false, + "Runs treefmt in a CI mode, enabling --no-cache, --fail-on-change and adjusting some other settings "+ + "best suited to a CI use case.", + ) + + fs.BoolP( + "clear-cache", "c", false, + "Reset the evaluation cache. Use in case the cache is not precise enough.", + ) + fs.String("cpu-profile", "", "The file into which a cpu profile will be written.") fs.StringSlice("excludes", nil, "Exclude files or directories matching the specified globs.") fs.Bool("fail-on-change", false, "Exit with error if any changes were made. Useful for CI.") - fs.StringSliceP("formatters", "f", nil, "Specify formatters to apply. Defaults to all configured formatters.") + + fs.StringSliceP( + "formatters", "f", nil, + "Specify formatters to apply. Defaults to all configured formatters.", + ) + fs.Bool("no-cache", false, "Ignore the evaluation cache entirely. Useful for CI.") - fs.StringP("on-unmatched", "u", "warn", "Log paths that did not match any formatters at the specified log level, with fatal exiting the process with an error. Possible values are .") + + fs.StringP( + "on-unmatched", "u", "warn", + "Log paths that did not match any formatters at the specified log level, with fatal exiting the "+ + "process with an error. Possible values are .", + ) + fs.Bool("stdin", false, "Format the context passed in via stdin.") - fs.String("tree-root", "", "The root directory from which treefmt will start walking the filesystem (defaults to the directory containing the config file).") + fs.String( + "tree-root", "", + "The root directory from which treefmt will start walking the "+ + "filesystem (defaults to the directory containing the config file).", + ) + fs.String("tree-root-file", "", "File to search for to find the tree root (if --tree-root is not passed).") - fs.String("walk", "auto", "The method used to traverse the files within the tree root. Currently supports 'auto', 'git' or 'filesystem'.") + fs.String( + "walk", "auto", + "The method used to traverse the files within the tree root. Currently supports 'auto', 'git' or "+ + "'filesystem'.", + ) + fs.CountP("verbose", "v", "Set the verbosity of logs e.g. -vv.") - fs.StringP("working-dir", "C", ".", "Run as if treefmt was started in the specified working directory instead of the current working directory.") + + fs.StringP( + "working-dir", "C", ".", + "Run as if treefmt was started in the specified working directory instead of the current working "+"directory.", + ) return fs } @@ -78,7 +117,7 @@ func SetFlags(fs *pflag.FlagSet) *pflag.FlagSet { // * TOML config type // * automatic env enabled // * `TREEFMT_` env prefix for environment variables -// * replacement of `-` and `.` with `_` when mapping from flags to env e.g. `global.excludes` => `TREEFMT_GLOBAL_EXCLUDES` +// * replacement of `-` and `.` with `_` when mapping from flags to env. func NewViper() *viper.Viper { v := viper.New() @@ -96,6 +135,7 @@ func NewViper() *viper.Viper { // FromViper takes a viper instance and produces a Config instance. func FromViper(v *viper.Viper) (*Config, error) { cfg := &Config{} + var err error if err = v.Unmarshal(cfg); err != nil { @@ -143,6 +183,7 @@ func FromViper(v *viper.Viper) (*Config, error) { if !ok { return nil, fmt.Errorf("formatter %v not found in config", name) } + filtered[name] = formatterCfg } diff --git a/config/config_test.go b/config/config_test.go index 2522cbfe..b5e2980a 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,4 +1,4 @@ -package config +package config_test import ( "bufio" @@ -9,31 +9,34 @@ import ( "testing" "github.com/BurntSushi/toml" + "github.com/numtide/treefmt/config" "github.com/spf13/pflag" "github.com/spf13/viper" - "github.com/stretchr/testify/require" ) func newViper(t *testing.T) (*viper.Viper, *pflag.FlagSet) { t.Helper() - v := NewViper() + v := config.NewViper() tempDir := t.TempDir() + v.SetConfigFile(filepath.Join(tempDir, "treefmt.toml")) - flags := SetFlags(pflag.NewFlagSet("test", pflag.ContinueOnError)) + flags := config.SetFlags(pflag.NewFlagSet("test", pflag.ContinueOnError)) if err := v.BindPFlags(flags); err != nil { t.Fatal(err) } + return v, flags } -func readValue(t *testing.T, v *viper.Viper, cfg map[string]any, test func(*Config)) { +func readValue(t *testing.T, v *viper.Viper, cfg map[string]any, test func(*config.Config)) { t.Helper() // serialise the config and read it into viper buf := bytes.NewBuffer(nil) + encoder := toml.NewEncoder(buf) if err := encoder.Encode(cfg); err != nil { t.Fatal(fmt.Errorf("failed to marshal config: %w", err)) @@ -42,7 +45,7 @@ func readValue(t *testing.T, v *viper.Viper, cfg map[string]any, test func(*Conf } // - decodedCfg, err := FromViper(v) + decodedCfg, err := config.FromViper(v) if err != nil { t.Fatal(fmt.Errorf("failed to unmarshal config from viper: %w", err)) } @@ -57,7 +60,7 @@ func TestAllowMissingFormatter(t *testing.T) { v, flags := newViper(t) checkValue := func(expected bool) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.AllowMissingFormatter) }) } @@ -85,7 +88,7 @@ func TestCI(t *testing.T) { v, flags := newViper(t) checkValues := func(ci bool, noCache bool, failOnChange bool, verbosity uint8) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(ci, cfg.CI) as.Equal(noCache, cfg.NoCache) as.Equal(failOnChange, cfg.FailOnChange) @@ -110,6 +113,7 @@ func TestCI(t *testing.T) { // increase verbosity above 1 and check it isn't reset cfg["verbose"] = 2 + checkValues(true, true, true, 2) } @@ -120,7 +124,7 @@ func TestClearCache(t *testing.T) { v, flags := newViper(t) checkValue := func(expected bool) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.ClearCache) }) } @@ -148,8 +152,8 @@ func TestCpuProfile(t *testing.T) { v, flags := newViper(t) checkValue := func(expected string) { - readValue(t, v, cfg, func(cfg *Config) { - as.Equal(expected, cfg.CpuProfile) + readValue(t, v, cfg, func(cfg *config.Config) { + as.Equal(expected, cfg.CPUProfile) }) } @@ -158,14 +162,17 @@ func TestCpuProfile(t *testing.T) { // set config value cfg["cpu-profile"] = "/foo/bar" + checkValue("/foo/bar") // env override t.Setenv("TREEFMT_CPU_PROFILE", "/fizz/buzz") + checkValue("/fizz/buzz") // flag override as.NoError(flags.Set("cpu-profile", "/bla/bla")) + checkValue("/bla/bla") } @@ -176,7 +183,7 @@ func TestExcludes(t *testing.T) { v, flags := newViper(t) checkValue := func(expected []string) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.Excludes) }) } @@ -186,6 +193,7 @@ func TestExcludes(t *testing.T) { // set config value cfg["excludes"] = []string{"foo", "bar"} + checkValue([]string{"foo", "bar"}) // test global.excludes fallback @@ -193,6 +201,7 @@ func TestExcludes(t *testing.T) { cfg["global"] = map[string]any{ "excludes": []string{"fizz", "buzz"}, } + checkValue([]string{"fizz", "buzz"}) // env override @@ -211,7 +220,7 @@ func TestFailOnChange(t *testing.T) { v, flags := newViper(t) checkValue := func(expected bool) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.FailOnChange) }) } @@ -239,7 +248,7 @@ func TestFormatters(t *testing.T) { v, flags := newViper(t) checkValue := func(expected []string) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.Formatters) }) } @@ -260,19 +269,24 @@ func TestFormatters(t *testing.T) { }, } cfg["formatters"] = []string{"echo", "touch"} + checkValue([]string{"echo", "touch"}) // env override t.Setenv("TREEFMT_FORMATTERS", "echo,date") + checkValue([]string{"echo", "date"}) // flag override as.NoError(flags.Set("formatters", "date,touch")) + checkValue([]string{"date", "touch"}) // bad formatter name as.NoError(flags.Set("formatters", "foo,echo,date")) - _, err := FromViper(v) + + _, err := config.FromViper(v) + as.ErrorContains(err, "formatter foo not found in config") } @@ -283,7 +297,7 @@ func TestNoCache(t *testing.T) { v, flags := newViper(t) checkValue := func(expected bool) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.NoCache) }) } @@ -311,7 +325,7 @@ func TestOnUnmatched(t *testing.T) { v, flags := newViper(t) checkValue := func(expected string) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.OnUnmatched) }) } @@ -321,10 +335,12 @@ func TestOnUnmatched(t *testing.T) { // set config value cfg["on-unmatched"] = "error" + checkValue("error") // env override t.Setenv("TREEFMT_ON_UNMATCHED", "debug") + checkValue("debug") // flag override @@ -339,7 +355,7 @@ func TestTreeRoot(t *testing.T) { v, flags := newViper(t) checkValue := func(expected string) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.TreeRoot) }) } @@ -350,14 +366,17 @@ func TestTreeRoot(t *testing.T) { // set config value cfg["tree-root"] = "/foo/bar" + checkValue("/foo/bar") // env override t.Setenv("TREEFMT_TREE_ROOT", "/fizz/buzz") + checkValue("/fizz/buzz") // flag override as.NoError(flags.Set("tree-root", "/flip/flop")) + checkValue("/flip/flop") } @@ -370,13 +389,13 @@ func TestTreeRootFile(t *testing.T) { // create a directory structure with config files at various levels tempDir := t.TempDir() as.NoError(os.MkdirAll(filepath.Join(tempDir, "foo", "bar"), 0o755)) - as.NoError(os.WriteFile(filepath.Join(tempDir, "foo", "bar", "a.txt"), []byte{}, 0o644)) - as.NoError(os.WriteFile(filepath.Join(tempDir, "foo", "go.mod"), []byte{}, 0o644)) + as.NoError(os.WriteFile(filepath.Join(tempDir, "foo", "bar", "a.txt"), []byte{}, 0o600)) + as.NoError(os.WriteFile(filepath.Join(tempDir, "foo", "go.mod"), []byte{}, 0o600)) as.NoError(os.MkdirAll(filepath.Join(tempDir, ".git"), 0o755)) - as.NoError(os.WriteFile(filepath.Join(tempDir, ".git", "config"), []byte{}, 0o644)) + as.NoError(os.WriteFile(filepath.Join(tempDir, ".git", "config"), []byte{}, 0o600)) checkValue := func(treeRoot string, treeRootFile string) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(treeRoot, cfg.TreeRoot) as.Equal(treeRootFile, cfg.TreeRootFile) }) @@ -391,6 +410,7 @@ func TestTreeRootFile(t *testing.T) { workDir := filepath.Join(tempDir, "foo", "bar") cfg["working-dir"] = workDir cfg["tree-root-file"] = "a.txt" + checkValue(workDir, "a.txt") // env override @@ -411,7 +431,7 @@ func TestVerbosity(t *testing.T) { v, _ := newViper(t) checkValue := func(expected uint8) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.Verbosity) }) } @@ -421,16 +441,14 @@ func TestVerbosity(t *testing.T) { // set config value cfg["verbose"] = 1 + checkValue(1) + // todo unsure how to set a count flag via the flags api + // env override t.Setenv("TREEFMT_VERBOSE", "2") checkValue(2) - - // flag override - // todo unsure how to set a count flag via the flags api - // as.NoError(flags.Set("verbose", "v")) - // checkValue(1) } func TestWalk(t *testing.T) { @@ -440,7 +458,7 @@ func TestWalk(t *testing.T) { v, flags := newViper(t) checkValue := func(expected string) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.Walk) }) } @@ -450,10 +468,12 @@ func TestWalk(t *testing.T) { // set config value cfg["walk"] = "git" + checkValue("git") // env override t.Setenv("TREEFMT_WALK", "filesystem") + checkValue("filesystem") // flag override @@ -468,7 +488,7 @@ func TestWorkingDirectory(t *testing.T) { v, flags := newViper(t) checkValue := func(expected string) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(expected, cfg.WorkingDirectory) }) } @@ -485,14 +505,17 @@ func TestWorkingDirectory(t *testing.T) { // set config value // should resolve input paths to absolute paths cfg["working-dir"] = "/foo/bar/baz/../fizz" + checkValue("/foo/bar/fizz") // env override t.Setenv("TREEFMT_WORKING_DIR", "/fizz/buzz/..") + checkValue("/fizz") // flag override as.NoError(flags.Set("working-dir", "/flip/flop")) + checkValue("/flip/flop") } @@ -503,7 +526,7 @@ func TestStdin(t *testing.T) { v, flags := newViper(t) checkValues := func(stdin bool) { - readValue(t, v, cfg, func(cfg *Config) { + readValue(t, v, cfg, func(cfg *config.Config) { as.Equal(stdin, cfg.Stdin) }) } @@ -531,7 +554,7 @@ func TestSampleConfigFile(t *testing.T) { v.SetConfigFile("../test/examples/treefmt.toml") as.NoError(v.ReadInConfig(), "failed to read config file") - cfg, err := FromViper(v) + cfg, err := config.FromViper(v) as.NoError(err, "failed to unmarshal config from viper") as.NotNil(cfg) diff --git a/format/formatter.go b/format/formatter.go index a77d00d7..41c5b844 100644 --- a/format/formatter.go +++ b/format/formatter.go @@ -8,11 +8,10 @@ import ( "os/exec" "time" - "github.com/numtide/treefmt/config" - "github.com/numtide/treefmt/walk" - "github.com/charmbracelet/log" "github.com/gobwas/glob" + "github.com/numtide/treefmt/config" + "github.com/numtide/treefmt/walk" "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/interp" ) @@ -34,7 +33,7 @@ type Formatter struct { excludes []glob.Glob } -// Executable returns the path to the executable defined by Command +// Executable returns the path to the executable defined by Command. func (f *Formatter) Executable() string { return f.executable } @@ -64,6 +63,7 @@ func (f *Formatter) Apply(ctx context.Context, tasks []*Task) error { } // execute the command + //nolint:gosec cmd := exec.CommandContext(ctx, f.executable, args...) // replace the default Cancel handler installed by CommandContext because it sends SIGKILL (-9). cmd.Cancel = func() error { @@ -76,14 +76,16 @@ func (f *Formatter) Apply(ctx context.Context, tasks []*Task) error { if out, err := cmd.CombinedOutput(); err != nil { f.log.Errorf("failed to apply with options '%v': %s", f.config.Options, err) + if len(out) > 0 { _, _ = fmt.Fprintf(os.Stderr, "%s error:\n%s\n", f.name, out) } + return fmt.Errorf("formatter '%s' with options '%v' failed to apply: %w", f.config.Command, f.config.Options, err) - } else { - f.log.Infof("%v file(s) processed in %v", len(tasks), time.Since(start)) } + f.log.Infof("%v file(s) processed in %v", len(tasks), time.Since(start)) + return nil } @@ -94,6 +96,7 @@ func (f *Formatter) Wants(file *walk.File) bool { if match { f.log.Debugf("match: %v", file) } + return match } @@ -118,6 +121,7 @@ func NewFormatter( if err != nil { return nil, ErrCommandNotFound } + f.executable = executable // initialise internal state diff --git a/format/glob.go b/format/glob.go index e03aafb9..296bd23a 100644 --- a/format/glob.go +++ b/format/glob.go @@ -15,6 +15,7 @@ func CompileGlobs(patterns []string) ([]glob.Glob, error) { if err != nil { return nil, fmt.Errorf("failed to compile include pattern '%v': %w", pattern, err) } + globs[i] = g } diff --git a/format/glob_test.go b/format/glob_test.go index 137cdb25..6a85d3c4 100644 --- a/format/glob_test.go +++ b/format/glob_test.go @@ -3,9 +3,8 @@ package format_test import ( "testing" - "github.com/numtide/treefmt/format" - "github.com/gobwas/glob" + "github.com/numtide/treefmt/format" "github.com/stretchr/testify/require" ) diff --git a/format/task.go b/format/task.go index 9da38111..922dc6db 100644 --- a/format/task.go +++ b/format/task.go @@ -25,6 +25,7 @@ func NewTask(file *walk.File, formatters []*Formatter) Task { // formatters with the same priority are sorted lexicographically to ensure a deterministic outcome result = cmp.Compare(a.Name(), b.Name()) } + return result }) @@ -33,6 +34,7 @@ func NewTask(file *walk.File, formatters []*Formatter) Task { for _, f := range formatters { key += f.name + ":" } + key = key[:len(key)-1] return Task{ diff --git a/test/temp.go b/test/temp.go index 660bb023..9882de32 100644 --- a/test/temp.go +++ b/test/temp.go @@ -1,7 +1,6 @@ package test import ( - "io" "os" "testing" "time" @@ -18,6 +17,7 @@ func WriteConfig(t *testing.T, path string, cfg map[string]any) { 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) @@ -29,7 +29,7 @@ func TempExamples(t *testing.T) string { require.NoError(t, cp.Copy("../test/examples", tempDir), "failed to copy test data to temp dir") // we have second precision mod time tracking, so we wait a second before returning, so we don't trigger false - //positives for things like fail on change + // positives for things like fail on change time.Sleep(time.Second) return tempDir @@ -55,18 +55,12 @@ func TempFile(t *testing.T, dir string, pattern string, contents *string) *os.Fi return file } -func ReadStdout(t *testing.T) string { - _, err := os.Stdout.Seek(0, 0) - require.NoError(t, err, "failed to seek to 0") - bytes, err := io.ReadAll(os.Stdout) - require.NoError(t, err, "failed to read") - return string(bytes) -} - func RecreateSymlink(t *testing.T, path string) error { t.Helper() + src, err := os.Readlink(path) require.NoError(t, err, "failed to read symlink") require.NoError(t, os.Remove(path), "failed to remove symlink") + return os.Symlink(src, path) } diff --git a/walk/filesystem.go b/walk/filesystem.go index 55973ced..312e5daa 100644 --- a/walk/filesystem.go +++ b/walk/filesystem.go @@ -8,21 +8,21 @@ import ( "path/filepath" ) -type filesystemWalker struct { +type FilesystemWalker struct { root string pathsCh chan string relPathOffset int } -func (f filesystemWalker) Root() string { +func (f FilesystemWalker) Root() string { return f.root } -func (f filesystemWalker) relPath(path string) (string, error) { +func (f FilesystemWalker) relPath(path string) (string, error) { return filepath.Rel(f.root, path) } -func (f filesystemWalker) Walk(_ context.Context, fn WalkFunc) error { +func (f FilesystemWalker) Walk(_ context.Context, fn WalkerFunc) error { walkFn := func(path string, info fs.FileInfo, _ error) error { if info == nil { return fmt.Errorf("no such file or directory '%s'", path) @@ -43,6 +43,7 @@ func (f filesystemWalker) Walk(_ context.Context, fn WalkFunc) error { RelPath: relPath, Info: info, } + return fn(&file, err) } @@ -55,8 +56,8 @@ func (f filesystemWalker) Walk(_ context.Context, fn WalkFunc) error { return nil } -func NewFilesystem(root string, paths chan string) (Walker, error) { - return filesystemWalker{ +func NewFilesystem(root string, paths chan string) (*FilesystemWalker, error) { + return &FilesystemWalker{ root: root, pathsCh: paths, relPathOffset: len(root) + 1, diff --git a/walk/filesystem_test.go b/walk/filesystem_test.go index b33404b8..462e776d 100644 --- a/walk/filesystem_test.go +++ b/walk/filesystem_test.go @@ -1,4 +1,4 @@ -package walk +package walk_test import ( "context" @@ -6,9 +6,11 @@ import ( "testing" "github.com/numtide/treefmt/test" + "github.com/numtide/treefmt/walk" "github.com/stretchr/testify/require" ) +//nolint:gochecknoglobals var examplesPaths = []string{ "elm/elm.json", "elm/src/Main.elm", @@ -55,13 +57,15 @@ func TestFilesystemWalker_Walk(t *testing.T) { as := require.New(t) - walker, err := NewFilesystem(tempDir, paths) + walker, err := walk.NewFilesystem(tempDir, paths) as.NoError(err) idx := 0 - err = walker.Walk(context.Background(), func(file *File, err error) error { + err = walker.Walk(context.Background(), func(file *walk.File, _ error) error { as.Equal(examplesPaths[idx], file.RelPath) - idx += 1 + + idx++ + return nil }) as.NoError(err) diff --git a/walk/git.go b/walk/git.go index 4e3c6505..2f3f40a2 100644 --- a/walk/git.go +++ b/walk/git.go @@ -9,67 +9,70 @@ import ( "strings" "github.com/charmbracelet/log" + "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/format/index" - - "github.com/go-git/go-git/v5" ) -// fileTree represents a hierarchical file structure with directories and files. -type fileTree struct { - name string - entries map[string]*fileTree +// FileTree represents a hierarchical file structure with directories and files. +type FileTree struct { + Name string + Entries map[string]*FileTree } -// add inserts a file path into the fileTree structure, creating necessary parent directories if they do not exist. -func (n *fileTree) add(path []string) { +// Add inserts a file path into the FileTree structure, creating necessary parent directories if they do not exist. +func (n *FileTree) Add(path []string) { if len(path) == 0 { return - } else if n.entries == nil { - n.entries = make(map[string]*fileTree) + } else if n.Entries == nil { + n.Entries = make(map[string]*FileTree) } name := path[0] - child, ok := n.entries[name] + + child, ok := n.Entries[name] if !ok { - child = &fileTree{name: name} - n.entries[name] = child + child = &FileTree{Name: name} + n.Entries[name] = child } - child.add(path[1:]) + + child.Add(path[1:]) } -// addPath splits the given path by the filepath separator and inserts it into the fileTree structure. -func (n *fileTree) addPath(path string) { - n.add(strings.Split(path, string(filepath.Separator))) +// AddPath splits the given path by the filepath separator and inserts it into the FileTree structure. +func (n *FileTree) AddPath(path string) { + n.Add(strings.Split(path, string(filepath.Separator))) } -// has returns true if the specified path exists in the fileTree, false otherwise. -func (n *fileTree) has(path []string) bool { +// Has returns true if the specified path exists in the FileTree, false otherwise. +func (n *FileTree) Has(path []string) bool { if len(path) == 0 { return true - } else if len(n.entries) == 0 { + } else if len(n.Entries) == 0 { return false } - child, ok := n.entries[path[0]] + + child, ok := n.Entries[path[0]] if !ok { return false } - return child.has(path[1:]) + + return child.Has(path[1:]) } -// hasPath splits the given path by the filepath separator and checks if it exists in the fileTree. -func (n *fileTree) hasPath(path string) bool { - return n.has(strings.Split(path, string(filepath.Separator))) +// HasPath splits the given path by the filepath separator and checks if it exists in the FileTree. +func (n *FileTree) HasPath(path string) bool { + return n.Has(strings.Split(path, string(filepath.Separator))) } -// readIndex traverses the index entries and adds each file path to the fileTree structure. -func (n *fileTree) readIndex(idx *index.Index) { +// ReadIndex traverses the index entries and adds each file path to the FileTree structure. +func (n *FileTree) ReadIndex(idx *index.Index) { for _, entry := range idx.Entries { - n.addPath(entry.Name) + n.AddPath(entry.Name) } } -type gitWalker struct { +type GitWalker struct { log *log.Logger root string paths chan string @@ -77,15 +80,15 @@ type gitWalker struct { relPathOffset int } -func (g gitWalker) Root() string { +func (g GitWalker) Root() string { return g.root } -func (g gitWalker) relPath(path string) (string, error) { // +func (g GitWalker) relPath(path string) (string, error) { // return filepath.Rel(g.root, path) } -func (g gitWalker) Walk(ctx context.Context, fn WalkFunc) error { +func (g GitWalker) Walk(ctx context.Context, fn WalkerFunc) error { idx, err := g.repo.Storer.Index() if err != nil { return fmt.Errorf("failed to open git index: %w", err) @@ -93,14 +96,12 @@ func (g gitWalker) Walk(ctx context.Context, fn WalkFunc) error { // if we need to walk a path that is not the root of the repository, we will read the directory structure of the // git index into memory for faster lookups - var cache *fileTree + var cache *FileTree for path := range g.paths { switch path { - case g.root: - - // we can just iterate the index entries + // we can just iterate the index Entries for _, entry := range idx.Entries { select { case <-ctx.Done(): @@ -118,6 +119,7 @@ func (g gitWalker) Walk(ctx context.Context, fn WalkFunc) error { if os.IsNotExist(err) { // the underlying file might have been removed without the change being staged yet g.log.Warnf("Path %s is in the index but appears to have been removed from the filesystem", path) + continue } else if err != nil { return fmt.Errorf("failed to stat %s: %w", path, err) @@ -142,22 +144,22 @@ func (g gitWalker) Walk(ctx context.Context, fn WalkFunc) error { } default: - // read the git index into memory if it hasn't already if cache == nil { - cache = &fileTree{name: ""} - cache.readIndex(idx) + cache = &FileTree{Name: ""} + cache.ReadIndex(idx) } - // git index entries are relative to the repository root, so we need to determine a relative path for the + // git index Entries are relative to the repository root, so we need to determine a relative path for the // one we are currently processing before checking if it exists within the git index relPath, err := g.relPath(path) if err != nil { return fmt.Errorf("failed to find root relative path for %v: %w", path, err) } - if !cache.hasPath(relPath) { + if !cache.HasPath(relPath) { log.Debugf("path %s not found in git index, skipping", relPath) + continue } @@ -173,8 +175,9 @@ func (g gitWalker) Walk(ctx context.Context, fn WalkFunc) error { return fmt.Errorf("failed to determine a relative path for %s: %w", path, err) } - if !cache.hasPath(relPath) { + if !cache.HasPath(relPath) { log.Debugf("path %v not found in git index, skipping", relPath) + return nil } @@ -195,12 +198,13 @@ func (g gitWalker) Walk(ctx context.Context, fn WalkFunc) error { return nil } -func NewGit(root string, paths chan string) (Walker, error) { +func NewGit(root string, paths chan string) (*GitWalker, error) { repo, err := git.PlainOpen(root) if err != nil { return nil, fmt.Errorf("failed to open git repo: %w", err) } - return &gitWalker{ + + return &GitWalker{ log: log.WithPrefix("walker[git]"), root: root, paths: paths, diff --git a/walk/git_test.go b/walk/git_test.go index e557eae3..6f2953d9 100644 --- a/walk/git_test.go +++ b/walk/git_test.go @@ -1,31 +1,32 @@ -package walk +package walk_test import ( "testing" + "github.com/numtide/treefmt/walk" "github.com/stretchr/testify/require" ) func TestFileTree(t *testing.T) { as := require.New(t) - node := &fileTree{name: ""} - node.addPath("foo/bar/baz") - node.addPath("fizz/buzz") - node.addPath("hello/world") - node.addPath("foo/bar/fizz") - node.addPath("foo/fizz/baz") + node := &walk.FileTree{Name: ""} + node.AddPath("foo/bar/baz") + node.AddPath("fizz/buzz") + node.AddPath("hello/world") + node.AddPath("foo/bar/fizz") + node.AddPath("foo/fizz/baz") - as.True(node.hasPath("foo")) - as.True(node.hasPath("foo/bar")) - as.True(node.hasPath("foo/bar/baz")) - as.True(node.hasPath("fizz")) - as.True(node.hasPath("fizz/buzz")) - as.True(node.hasPath("hello")) - as.True(node.hasPath("hello/world")) - as.True(node.hasPath("foo/bar/fizz")) - as.True(node.hasPath("foo/fizz/baz")) + as.True(node.HasPath("foo")) + as.True(node.HasPath("foo/bar")) + as.True(node.HasPath("foo/bar/baz")) + as.True(node.HasPath("fizz")) + as.True(node.HasPath("fizz/buzz")) + as.True(node.HasPath("hello")) + as.True(node.HasPath("hello/world")) + as.True(node.HasPath("foo/bar/fizz")) + as.True(node.HasPath("foo/fizz/baz")) - as.False(node.hasPath("fo")) - as.False(node.hasPath("world")) + as.False(node.HasPath("fo")) + as.False(node.HasPath("world")) } diff --git a/walk/walker.go b/walk/walker.go index e3b8231c..39f10f18 100644 --- a/walk/walker.go +++ b/walk/walker.go @@ -51,13 +51,14 @@ func (f File) String() string { return f.Path } -type WalkFunc func(file *File, err error) error +type WalkerFunc func(file *File, err error) error type Walker interface { Root() string - Walk(ctx context.Context, fn WalkFunc) error + Walk(ctx context.Context, fn WalkerFunc) error } +//nolint:ireturn func New(walkerType Type, root string, pathsCh chan string) (Walker, error) { switch walkerType { case Git: @@ -71,12 +72,14 @@ func New(walkerType Type, root string, pathsCh chan string) (Walker, error) { } } +//nolint:ireturn func Detect(root string, pathsCh chan string) (Walker, error) { // for now, we keep it simple and try git first, filesystem second w, err := NewGit(root, pathsCh) if err == nil { - return w, err + return w, nil } + return NewFilesystem(root, pathsCh) } @@ -89,6 +92,7 @@ func FindUp(searchDir string, fileNames ...string) (path string, dir string, err } } } + return "", "", fmt.Errorf("could not find %s in %s", fileNames, searchDir) } @@ -110,6 +114,7 @@ func eachDir(path string) (paths []string) { if path == "" { path = "/" } + paths = append(paths, path) } } From 1421af331215ad6da6a18d8f747860b8715411e8 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Tue, 8 Oct 2024 09:53:24 +0100 Subject: [PATCH 09/15] feat: remove globals from stats package Signed-off-by: Brian McGee --- cache/cache.go | 12 +-- cmd/format/format.go | 38 ++++--- cmd/root.go | 14 ++- cmd/root_test.go | 252 ++++++++++++++++++++++--------------------- main.go | 3 +- stats/stats.go | 57 +++++----- 6 files changed, 201 insertions(+), 175 deletions(-) diff --git a/cache/cache.go b/cache/cache.go index ac643896..ff8de015 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -30,10 +30,10 @@ type Entry struct { } var ( - db *bolt.DB - logger *log.Logger + db *bolt.DB //nolint:gochecknoglobals + logger *log.Logger //nolint:gochecknoglobals - ReadBatchSize = 1024 * runtime.NumCPU() + ReadBatchSize = 1024 * runtime.NumCPU() //nolint:gochecknoglobals ) // Open creates an instance of bolt.DB for a given treeRoot path. @@ -187,7 +187,7 @@ func putEntry(bucket *bolt.Bucket, path string, entry *Entry) error { // ChangeSet is used to walk a filesystem, starting at root, and outputting any new or changed paths using pathsCh. // It determines if a path is new or has changed by comparing against cache entries. -func ChangeSet(ctx context.Context, walker walk.Walker, filesCh chan<- *walk.File) error { +func ChangeSet(ctx context.Context, statz *stats.Stats, walker walk.Walker, filesCh chan<- *walk.File) error { start := time.Now() defer func() { @@ -238,14 +238,14 @@ func ChangeSet(ctx context.Context, walker walk.Walker, filesCh chan<- *walk.Fil changedOrNew := cached == nil || !(cached.Modified == file.Info.ModTime() && cached.Size == file.Info.Size()) - stats.Add(stats.Traversed, 1) + statz.Add(stats.Traversed, 1) if !changedOrNew { // no change return nil } - stats.Add(stats.Emitted, 1) + statz.Add(stats.Emitted, 1) // pass on the path select { diff --git a/cmd/format/format.go b/cmd/format/format.go index 0df393e1..d4e0ddd5 100644 --- a/cmd/format/format.go +++ b/cmd/format/format.go @@ -32,7 +32,7 @@ const ( var ErrFailOnChange = errors.New("unexpected changes detected, --fail-on-change is enabled") -func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { +func Run(v *viper.Viper, statz *stats.Stats, cmd *cobra.Command, paths []string) error { cmd.SilenceUsage = true cfg, err := config.FromViper(v) @@ -40,9 +40,6 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { return fmt.Errorf("failed to load config: %w", err) } - // initialise stats collection - stats.Init() - if cfg.CI { log.Info("ci mode enabled") @@ -69,6 +66,7 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { return fmt.Errorf("exactly one path should be specified when using the --stdin flag") } + //nolint:nestif if cfg.Stdin { // read stdin into a temporary file with the same file extension pattern := fmt.Sprintf("*%s", filepath.Ext(paths[0])) @@ -194,10 +192,10 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { processedCh := make(chan *format.Task, cap(filesCh)) // start concurrent processing tasks in reverse order - eg.Go(updateCache(ctx, cfg, processedCh)) - eg.Go(detectFormatted(ctx, cfg, formattedCh, processedCh)) - eg.Go(applyFormatters(ctx, cfg, globalExcludes, formatters, filesCh, formattedCh)) - eg.Go(walkFilesystem(ctx, cfg, paths, filesCh)) + eg.Go(updateCache(ctx, cfg, statz, processedCh)) + eg.Go(detectFormatted(ctx, cfg, statz, formattedCh, processedCh)) + eg.Go(applyFormatters(ctx, cfg, statz, globalExcludes, formatters, filesCh, formattedCh)) + eg.Go(walkFilesystem(ctx, cfg, statz, paths, filesCh)) // wait for everything to complete return eg.Wait() @@ -206,6 +204,7 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error { func walkFilesystem( ctx context.Context, cfg *config.Config, + statz *stats.Stats, paths []string, filesCh chan *walk.File, ) func() error { @@ -262,8 +261,8 @@ func walkFilesystem( case <-ctx.Done(): return ctx.Err() default: - stats.Add(stats.Traversed, 1) - stats.Add(stats.Emitted, 1) + statz.Add(stats.Traversed, 1) + statz.Add(stats.Emitted, 1) filesCh <- file return nil @@ -273,7 +272,7 @@ func walkFilesystem( // otherwise we pass the walker to the cache and have it generate files for processing based on whether or not // they have been added/changed since the last invocation - if err = cache.ChangeSet(ctx, walker, filesCh); err != nil { + if err = cache.ChangeSet(ctx, statz, walker, filesCh); err != nil { return fmt.Errorf("failed to generate change set: %w", err) } @@ -285,6 +284,7 @@ func walkFilesystem( func applyFormatters( ctx context.Context, cfg *config.Config, + statz *stats.Stats, globalExcludes []glob.Glob, formatters map[string]*format.Formatter, filesCh chan *walk.File, @@ -397,7 +397,7 @@ func applyFormatters( } } else { // record the match - stats.Add(stats.Matched, 1) + statz.Add(stats.Matched, 1) // create a new format task, add it to a batch based on its batch key and try to apply if the batch is full task := format.NewTask(file, matches) tryApply(&task) @@ -421,6 +421,7 @@ func applyFormatters( func detectFormatted( ctx context.Context, cfg *config.Config, + statz *stats.Stats, formattedCh chan *format.Task, processedCh chan *format.Task, ) func() error { @@ -452,7 +453,7 @@ func detectFormatted( if changed { // record the change - stats.Add(stats.Formatted, 1) + statz.Add(stats.Formatted, 1) logMethod := log.Debug if cfg.FailOnChange { @@ -480,7 +481,12 @@ func detectFormatted( } } -func updateCache(ctx context.Context, cfg *config.Config, processedCh chan *format.Task) func() error { +func updateCache( + ctx context.Context, + cfg *config.Config, + statz *stats.Stats, + processedCh chan *format.Task, +) func() error { return func() error { // used to batch updates for more efficient txs batch := make([]*format.Task, 0, BatchSize) @@ -561,13 +567,13 @@ func updateCache(ctx context.Context, cfg *config.Config, processedCh chan *form } // if fail on change has been enabled, check that no files were actually formatted, throwing an error if so - if cfg.FailOnChange && stats.Value(stats.Formatted) != 0 { + if cfg.FailOnChange && statz.Value(stats.Formatted) != 0 { return ErrFailOnChange } // print stats to stdout unless we are processing stdin and printing the results to stdout if !cfg.Stdin { - stats.Print() + statz.Print() } return nil diff --git a/cmd/root.go b/cmd/root.go index b92a8fad..809d726e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,12 +10,13 @@ import ( "github.com/numtide/treefmt/cmd/format" _init "github.com/numtide/treefmt/cmd/init" "github.com/numtide/treefmt/config" + "github.com/numtide/treefmt/stats" "github.com/numtide/treefmt/walk" "github.com/spf13/cobra" "github.com/spf13/viper" ) -func NewRoot() *cobra.Command { +func NewRoot() (*cobra.Command, *stats.Stats) { var ( treefmtInit bool configFile string @@ -24,13 +25,16 @@ func NewRoot() *cobra.Command { // create a viper instance for reading in config v := config.NewViper() + // create a new stats instance + statz := stats.New() + // create out root command cmd := &cobra.Command{ Use: fmt.Sprintf("%s ", build.Name), Short: "One CLI to format your repo", Version: build.Version, RunE: func(cmd *cobra.Command, args []string) error { - return runE(v, cmd, args) + return runE(v, statz, cmd, args) }, } @@ -60,10 +64,10 @@ func NewRoot() *cobra.Command { cobra.CheckErr(fmt.Errorf("failed to bind global config to viper: %w", err)) } - return cmd + return cmd, statz } -func runE(v *viper.Viper, cmd *cobra.Command, args []string) error { +func runE(v *viper.Viper, statz *stats.Stats, cmd *cobra.Command, args []string) error { flags := cmd.Flags() // change working directory if required @@ -122,5 +126,5 @@ func runE(v *viper.Viper, cmd *cobra.Command, args []string) error { } // format - return format.Run(v, cmd, args) + return format.Run(v, statz, cmd, args) } diff --git a/cmd/root_test.go b/cmd/root_test.go index 57381a2e..3da8c0f0 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -52,7 +52,7 @@ func TestOnUnmatched(t *testing.T) { // - "haskell/treefmt.toml" } - _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "--on-unmatched", "fatal") + _, _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "--on-unmatched", "fatal") as.ErrorContains(err, fmt.Sprintf("no formatter for path: %s", paths[0])) checkOutput := func(level string, output []byte) { @@ -64,24 +64,24 @@ func TestOnUnmatched(t *testing.T) { var out []byte // default is warn - out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c") + out, _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c") as.NoError(err) checkOutput("WARN", out) - out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "--on-unmatched", "warn") + out, _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "--on-unmatched", "warn") as.NoError(err) checkOutput("WARN", out) - out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-u", "error") + out, _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-u", "error") as.NoError(err) checkOutput("ERRO", out) - out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-v", "--on-unmatched", "info") + out, _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-v", "--on-unmatched", "info") as.NoError(err) checkOutput("INFO", out) t.Setenv("TREEFMT_ON_UNMATCHED", "debug") - out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-vv") + out, _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-vv") as.NoError(err) checkOutput("DEBU", out) } @@ -99,14 +99,14 @@ func TestCpuProfile(t *testing.T) { as.NoError(os.Chdir(cwd)) }) - _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "--cpu-profile", "cpu.pprof") + _, _, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "--cpu-profile", "cpu.pprof") as.NoError(err) as.FileExists(filepath.Join(tempDir, "cpu.pprof")) _, err = os.Stat(filepath.Join(tempDir, "cpu.pprof")) as.NoError(err) t.Setenv("TREEFMT_CPU_PROFILE", "env.pprof") - _, err = execute(t, "-C", tempDir, "--allow-missing-formatter") + _, _, err = execute(t, "-C", tempDir, "--allow-missing-formatter") as.NoError(err) as.FileExists(filepath.Join(tempDir, "env.pprof")) _, err = os.Stat(filepath.Join(tempDir, "env.pprof")) @@ -129,14 +129,14 @@ func TestAllowMissingFormatter(t *testing.T) { test.WriteConfig(t, configPath, cfg) - _, err := execute(t, "--config-file", configPath, "--tree-root", tempDir) + _, _, err := execute(t, "--config-file", configPath, "--tree-root", tempDir) as.ErrorIs(err, format.ErrCommandNotFound) - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "--allow-missing-formatter") + _, _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "--allow-missing-formatter") as.NoError(err) t.Setenv("TREEFMT_ALLOW_MISSING_FORMATTER", "true") - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) + _, _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) } @@ -175,42 +175,42 @@ func TestSpecifyingFormatters(t *testing.T) { setup() - _, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, statz, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 3, 3) + assertStats(t, as, statz, 32, 32, 3, 3) setup() - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "elm,nix") + _, statz, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "elm,nix") as.NoError(err) - assertStats(t, as, 32, 32, 2, 2) + assertStats(t, as, statz, 32, 32, 2, 2) setup() - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "-f", "ruby,nix") + _, statz, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "-f", "ruby,nix") as.NoError(err) - assertStats(t, as, 32, 32, 2, 2) + assertStats(t, as, statz, 32, 32, 2, 2) setup() - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "nix") + _, statz, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "nix") as.NoError(err) - assertStats(t, as, 32, 32, 1, 1) + assertStats(t, as, statz, 32, 32, 1, 1) // test bad names setup() - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "foo") + _, _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "foo") as.Errorf(err, "formatter not found in config: foo") t.Setenv("TREEFMT_FORMATTERS", "bar,foo") - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.Errorf(err, "formatter not found in config: bar") } @@ -232,25 +232,25 @@ func TestIncludesAndExcludes(t *testing.T) { } test.WriteConfig(t, configPath, cfg) - _, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, statz, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) // globally exclude nix files cfg["excludes"] = []string{"*.nix"} test.WriteConfig(t, configPath, cfg) - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, statz, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 31, 0) + assertStats(t, as, statz, 32, 32, 31, 0) // add haskell files to the global exclude cfg["excludes"] = []string{"*.nix", "*.hs"} test.WriteConfig(t, configPath, cfg) - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, statz, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 25, 0) + assertStats(t, as, statz, 32, 32, 25, 0) echo := cfg["formatter"].(map[string]any)["echo"].(map[string]any) @@ -258,17 +258,17 @@ func TestIncludesAndExcludes(t *testing.T) { echo["excludes"] = []string{"*.py"} test.WriteConfig(t, configPath, cfg) - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, statz, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 23, 0) + assertStats(t, as, statz, 32, 32, 23, 0) // remove go files from the echo formatter via env t.Setenv("TREEFMT_FORMATTER_ECHO_EXCLUDES", "*.py,*.go") test.WriteConfig(t, configPath, cfg) - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, statz, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 22, 0) + assertStats(t, as, statz, 32, 32, 22, 0) t.Setenv("TREEFMT_FORMATTER_ECHO_EXCLUDES", "") // reset @@ -276,17 +276,17 @@ func TestIncludesAndExcludes(t *testing.T) { echo["includes"] = []string{"*.elm"} test.WriteConfig(t, configPath, cfg) - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, statz, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 1, 0) + assertStats(t, as, statz, 32, 32, 1, 0) // add js files to echo formatter via env t.Setenv("TREEFMT_FORMATTER_ECHO_INCLUDES", "*.elm,*.js") test.WriteConfig(t, configPath, cfg) - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, statz, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 2, 0) + assertStats(t, as, statz, 32, 32, 2, 0) } func TestPrjRootEnvVariable(t *testing.T) { @@ -307,9 +307,9 @@ func TestPrjRootEnvVariable(t *testing.T) { test.WriteConfig(t, configPath, cfg) t.Setenv("PRJ_ROOT", tempDir) - _, err := execute(t, "--config-file", configPath) + _, statz, err := execute(t, "--config-file", configPath) as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) } func TestCache(t *testing.T) { @@ -331,36 +331,36 @@ func TestCache(t *testing.T) { var err error test.WriteConfig(t, configPath, cfg) - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) + _, statz, err := execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) + _, statz, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 0, 0, 0) + assertStats(t, as, statz, 32, 0, 0, 0) // clear cache - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "-c") + _, statz, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "-c") as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) + _, statz, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 0, 0, 0) + assertStats(t, as, statz, 32, 0, 0, 0) // clear cache - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "-c") + _, statz, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "-c") as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) + _, statz, err = execute(t, "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, 32, 0, 0, 0) + assertStats(t, as, statz, 32, 0, 0, 0) // no cache - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") + _, statz, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) } func TestChangeWorkingDirectory(t *testing.T) { @@ -392,15 +392,15 @@ func TestChangeWorkingDirectory(t *testing.T) { // 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 = execute(t, "-C", tempDir) + _, statz, err := execute(t, "-C", tempDir) as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) // use env t.Setenv("TREEFMT_WORKING_DIR", tempDir) - _, err = execute(t, "-c") + _, statz, err = execute(t, "-c") as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) } func TestFailOnChange(t *testing.T) { @@ -420,7 +420,7 @@ func TestFailOnChange(t *testing.T) { } test.WriteConfig(t, configPath, cfg) - _, err := execute(t, "--fail-on-change", "--config-file", configPath, "--tree-root", tempDir) + _, _, err := execute(t, "--fail-on-change", "--config-file", configPath, "--tree-root", tempDir) as.ErrorIs(err, format2.ErrFailOnChange) // we have second precision mod time tracking @@ -429,7 +429,7 @@ func TestFailOnChange(t *testing.T) { // test with no cache t.Setenv("TREEFMT_FAIL_ON_CHANGE", "true") test.WriteConfig(t, configPath, cfg) - _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") + _, _, err = execute(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache") as.ErrorIs(err, format2.ErrFailOnChange) } @@ -471,33 +471,33 @@ func TestBustCacheOnFormatterChange(t *testing.T) { test.WriteConfig(t, configPath, cfg) args := []string{"--config-file", configPath, "--tree-root", tempDir} - _, err := execute(t, args...) + _, statz, err := execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 32, 3, 0) + assertStats(t, as, statz, 32, 32, 3, 0) // tweak mod time of elm formatter as.NoError(test.RecreateSymlink(t, binPath+"/"+"elm-format")) - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 32, 3, 0) + assertStats(t, as, statz, 32, 32, 3, 0) // check cache is working - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 0, 0, 0) + assertStats(t, as, statz, 32, 0, 0, 0) // tweak mod time of python formatter as.NoError(test.RecreateSymlink(t, binPath+"/"+"black")) - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 32, 3, 0) + assertStats(t, as, statz, 32, 32, 3, 0) // check cache is working - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 0, 0, 0) + assertStats(t, as, statz, 32, 0, 0, 0) // add go formatter formatters := cfg["formatter"].(map[string]any) @@ -510,40 +510,40 @@ func TestBustCacheOnFormatterChange(t *testing.T) { test.WriteConfig(t, configPath, cfg) - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 32, 4, 0) + assertStats(t, as, statz, 32, 32, 4, 0) // check cache is working - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 0, 0, 0) + assertStats(t, as, statz, 32, 0, 0, 0) // remove python formatter delete(formatters, "python") test.WriteConfig(t, configPath, cfg) - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 32, 2, 0) + assertStats(t, as, statz, 32, 32, 2, 0) // check cache is working - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 0, 0, 0) + assertStats(t, as, statz, 32, 0, 0, 0) // remove elm formatter delete(formatters, "elm") test.WriteConfig(t, configPath, cfg) - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 32, 1, 0) + assertStats(t, as, statz, 32, 32, 1, 0) // check cache is working - _, err = execute(t, args...) + _, statz, err = execute(t, args...) as.NoError(err) - assertStats(t, as, 32, 0, 0, 0) + assertStats(t, as, statz, 32, 0, 0, 0) } func TestGitWorktree(t *testing.T) { @@ -579,9 +579,9 @@ func TestGitWorktree(t *testing.T) { as.NoError(err, "failed to get git worktree") run := func(traversed int32, emitted int32, matched int32, formatted int32) { - _, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) + _, statz, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir) as.NoError(err) - assertStats(t, as, traversed, emitted, matched, formatted) + assertStats(t, as, statz, traversed, emitted, matched, formatted) } // run before adding anything to the worktree @@ -601,9 +601,9 @@ func TestGitWorktree(t *testing.T) { run(28, 28, 28, 0) // walk with filesystem instead of git - _, err = execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--walk", "filesystem") + _, statz, err := execute(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--walk", "filesystem") as.NoError(err) - assertStats(t, as, 60, 60, 60, 0) + assertStats(t, as, statz, 60, 60, 60, 0) // capture current cwd, so we can replace it after the test is finished cwd, err := os.Getwd() @@ -615,31 +615,33 @@ func TestGitWorktree(t *testing.T) { }) // format specific sub paths - _, err = execute(t, "-C", tempDir, "-c", "go", "-vv") + _, statz, err = execute(t, "-C", tempDir, "-c", "go", "-vv") as.NoError(err) - assertStats(t, as, 2, 2, 2, 0) + assertStats(t, as, statz, 2, 2, 2, 0) - _, err = execute(t, "-C", tempDir, "-c", "go", "haskell") + _, statz, err = execute(t, "-C", tempDir, "-c", "go", "haskell") as.NoError(err) - assertStats(t, as, 9, 9, 9, 0) + assertStats(t, as, statz, 9, 9, 9, 0) - _, err = execute(t, "-C", tempDir, "-c", "go", "haskell", "ruby") + _, statz, err = execute(t, "-C", tempDir, "-c", "go", "haskell", "ruby") as.NoError(err) - assertStats(t, as, 10, 10, 10, 0) + assertStats(t, as, statz, 10, 10, 10, 0) // try with a bad path - _, err = execute(t, "-C", tempDir, "-c", "haskell", "foo") + _, _, err = execute(t, "-C", tempDir, "-c", "haskell", "foo") as.ErrorContains(err, "path foo not found within the tree root") - assertStats(t, as, 0, 0, 0, 0) // try with a path not in the git index, e.g. it is skipped _, err = os.Create(filepath.Join(tempDir, "foo.txt")) as.NoError(err) - assertStats(t, as, 0, 0, 0, 0) - _, err = execute(t, "-C", tempDir, "-c", "foo.txt") + _, statz, err = execute(t, "-C", tempDir, "-c", "haskell", "foo.txt") as.NoError(err) - assertStats(t, as, 0, 0, 0, 0) + assertStats(t, as, statz, 7, 7, 7, 0) + + _, statz, err = execute(t, "-C", tempDir, "-c", "foo.txt") + as.NoError(err) + assertStats(t, as, statz, 0, 0, 0, 0) } func TestPathsArg(t *testing.T) { @@ -672,22 +674,22 @@ func TestPathsArg(t *testing.T) { test.WriteConfig(t, configPath, cfg) // without any path args - _, err = execute(t) + _, statz, err := execute(t) as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) // specify some explicit paths - _, err = execute(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") + _, statz, err = execute(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") as.NoError(err) - assertStats(t, as, 2, 2, 2, 0) + assertStats(t, as, statz, 2, 2, 2, 0) // specify a bad path - _, err = execute(t, "-c", "elm/elm.json", "haskell/Nested/Bar.hs") + _, _, err = execute(t, "-c", "elm/elm.json", "haskell/Nested/Bar.hs") as.ErrorContains(err, "path haskell/Nested/Bar.hs not found within the tree root") // specify a path outside the tree root externalPath := filepath.Join(cwd, "go.mod") - _, err = execute(t, "-c", externalPath) + _, _, err = execute(t, "-c", externalPath) as.ErrorContains(err, fmt.Sprintf("path %s not found within the tree root", externalPath)) } @@ -715,7 +717,7 @@ func TestStdin(t *testing.T) { contents := `{ foo, ... }: "hello"` os.Stdin = test.TempFile(t, "", "stdin", &contents) // we get an error about the missing filename parameter. - out, err := execute(t, "-C", tempDir, "--allow-missing-formatter", "--stdin") + out, _, err := execute(t, "-C", tempDir, "--allow-missing-formatter", "--stdin") as.EqualError(err, "exactly one path should be specified when using the --stdin flag") as.Equal("", string(out)) @@ -724,9 +726,9 @@ func TestStdin(t *testing.T) { os.Stdin = test.TempFile(t, "", "stdin", &contents) t.Setenv("TREEFMT_STDIN", "true") - out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "test.nix") + out, statz, err := execute(t, "-C", tempDir, "--allow-missing-formatter", "test.nix") as.NoError(err) - assertStats(t, as, 1, 1, 1, 1) + assertStats(t, as, statz, 1, 1, 1, 1) // the nix formatters should have reduced the example to the following as.Equal(`{ ...}: "hello" @@ -741,9 +743,9 @@ func TestStdin(t *testing.T) { ` os.Stdin = test.TempFile(t, "", "stdin", &contents) - out, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.md") + out, statz, err = execute(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.md") as.NoError(err) - assertStats(t, as, 1, 1, 1, 1) + assertStats(t, as, statz, 1, 1, 1, 1) as.Equal(`| col1 | col2 | | ------ | --------- | @@ -789,7 +791,7 @@ func TestDeterministicOrderingInPipeline(t *testing.T) { } test.WriteConfig(t, configPath, cfg) - _, err = execute(t, "-C", tempDir) + _, _, err = execute(t, "-C", tempDir) as.NoError(err) matcher := regexp.MustCompile("^fmt-(.*)") @@ -859,17 +861,17 @@ func TestRunInSubdir(t *testing.T) { test.WriteConfig(t, configPath, cfg) // without any path args, should reformat the whole tree - _, err = execute(t) + _, statz, err := execute(t) as.NoError(err) - assertStats(t, as, 32, 32, 32, 0) + assertStats(t, as, statz, 32, 32, 32, 0) // specify some explicit paths, relative to the tree root - _, err = execute(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") + _, statz, err = execute(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs") as.NoError(err) - assertStats(t, as, 2, 2, 2, 0) + assertStats(t, as, statz, 2, 2, 2, 0) } -func execute(t *testing.T, args ...string) ([]byte, error) { +func execute(t *testing.T, args ...string) ([]byte, *stats.Stats, error) { t.Helper() tempDir := t.TempDir() @@ -893,7 +895,7 @@ func execute(t *testing.T, args ...string) ([]byte, error) { }() // run the command - root := cmd.NewRoot() + root, statz := cmd.NewRoot() if args == nil { // we must pass an empty array otherwise cobra with use os.Args[1:] @@ -905,26 +907,34 @@ func execute(t *testing.T, args ...string) ([]byte, error) { root.SetErr(tempOut) if err := root.Execute(); err != nil { - return nil, err + return nil, nil, err } // reset and read the temporary output if _, err := tempOut.Seek(0, 0); err != nil { - return nil, fmt.Errorf("failed to reset temp output for reading: %w", err) + return nil, nil, fmt.Errorf("failed to reset temp output for reading: %w", err) } out, err := io.ReadAll(tempOut) if err != nil { - return nil, fmt.Errorf("failed to read temp output: %w", err) + return nil, nil, fmt.Errorf("failed to read temp output: %w", err) } - return out, nil + return out, statz, nil } -func assertStats(t *testing.T, as *require.Assertions, traversed int32, emitted int32, matched int32, formatted int32) { +func assertStats( + t *testing.T, + as *require.Assertions, + statz *stats.Stats, + traversed int32, + emitted int32, + matched int32, + formatted int32, +) { t.Helper() - as.Equal(traversed, stats.Value(stats.Traversed), "stats.traversed") - as.Equal(emitted, stats.Value(stats.Emitted), "stats.emitted") - as.Equal(matched, stats.Value(stats.Matched), "stats.matched") - as.Equal(formatted, stats.Value(stats.Formatted), "stats.formatted") + as.Equal(traversed, statz.Value(stats.Traversed), "stats.traversed") + as.Equal(emitted, statz.Value(stats.Emitted), "stats.emitted") + as.Equal(matched, statz.Value(stats.Matched), "stats.matched") + as.Equal(formatted, statz.Value(stats.Formatted), "stats.formatted") } diff --git a/main.go b/main.go index 06a08b61..3f6efbdb 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,8 @@ import ( func main() { // todo how are exit codes thrown by commands? - if err := cmd.NewRoot().Execute(); err != nil { + root, _ := cmd.NewRoot() + if err := root.Execute(); err != nil { os.Exit(1) } } diff --git a/stats/stats.go b/stats/stats.go index 80f9da37..ef8d84b1 100644 --- a/stats/stats.go +++ b/stats/stats.go @@ -16,36 +16,24 @@ const ( Formatted ) -var ( - counters map[Type]*atomic.Int32 +type Stats struct { start time.Time -) - -func Init() { - // record start time - start = time.Now() - - // init counters - counters = make(map[Type]*atomic.Int32) - counters[Traversed] = &atomic.Int32{} - counters[Emitted] = &atomic.Int32{} - counters[Matched] = &atomic.Int32{} - counters[Formatted] = &atomic.Int32{} + counters map[Type]*atomic.Int32 } -func Add(t Type, delta int32) int32 { - return counters[t].Add(delta) +func (s *Stats) Add(t Type, delta int32) int32 { + return s.counters[t].Add(delta) } -func Value(t Type) int32 { - return counters[t].Load() +func (s *Stats) Value(t Type) int32 { + return s.counters[t].Load() } -func Elapsed() time.Duration { - return time.Since(start) +func (s *Stats) Elapsed() time.Duration { + return time.Since(s.start) } -func Print() { +func (s *Stats) Print() { components := []string{ "traversed %d files", "emitted %d files for processing", @@ -55,10 +43,27 @@ func Print() { fmt.Printf( strings.Join(components, "\n"), - Value(Traversed), - Value(Emitted), - Value(Matched), - Value(Formatted), - Elapsed().Round(time.Millisecond), + s.Value(Traversed), + s.Value(Emitted), + s.Value(Matched), + s.Value(Formatted), + s.Elapsed().Round(time.Millisecond), ) } + +func New() *Stats { + // record start time + start := time.Now() + + // init counters + counters := make(map[Type]*atomic.Int32) + counters[Traversed] = &atomic.Int32{} + counters[Emitted] = &atomic.Int32{} + counters[Matched] = &atomic.Int32{} + counters[Formatted] = &atomic.Int32{} + + return &Stats{ + start: start, + counters: counters, + } +} From 91bc59e52773ae565afa40b44e490cbe94a707a1 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Tue, 8 Oct 2024 11:22:09 +0100 Subject: [PATCH 10/15] fix: silence usage on error Signed-off-by: Brian McGee --- cmd/format/format.go | 4 +--- cmd/root.go | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/format/format.go b/cmd/format/format.go index d4e0ddd5..ad4edab3 100644 --- a/cmd/format/format.go +++ b/cmd/format/format.go @@ -32,9 +32,7 @@ const ( var ErrFailOnChange = errors.New("unexpected changes detected, --fail-on-change is enabled") -func Run(v *viper.Viper, statz *stats.Stats, cmd *cobra.Command, paths []string) error { - cmd.SilenceUsage = true - +func Run(v *viper.Viper, statz *stats.Stats, _ *cobra.Command, paths []string) error { cfg, err := config.FromViper(v) if err != nil { return fmt.Errorf("failed to load config: %w", err) diff --git a/cmd/root.go b/cmd/root.go index 809d726e..86d4c991 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -38,6 +38,9 @@ func NewRoot() (*cobra.Command, *stats.Stats) { }, } + // do not dump out the usage message on error + cmd.SilenceUsage = true + // update version template cmd.SetVersionTemplate("treefmt {{.Version}}") From 1b7ae3fcc4b10c48c3bd8a2df587b3bd2ac6a367 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Tue, 8 Oct 2024 11:33:51 +0100 Subject: [PATCH 11/15] doc: update usage Signed-off-by: Brian McGee --- config/config.go | 2 +- docs/usage.md | 137 ++++++++++++++++++++++++++--------------------- 2 files changed, 76 insertions(+), 63 deletions(-) diff --git a/config/config.go b/config/config.go index 5eb9f9e9..f8c9080d 100644 --- a/config/config.go +++ b/config/config.go @@ -71,7 +71,7 @@ func SetFlags(fs *pflag.FlagSet) *pflag.FlagSet { ) fs.String("cpu-profile", "", "The file into which a cpu profile will be written.") - fs.StringSlice("excludes", nil, "Exclude files or directories matching the specified globs.") + fs.StringSliceP("excludes", "e", nil, "Exclude files or directories matching the specified globs.") fs.Bool("fail-on-change", false, "Exit with error if any changes were made. Useful for CI.") fs.StringSliceP( diff --git a/docs/usage.md b/docs/usage.md index c9f4fbeb..01cc3001 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,97 +7,93 @@ outline: deep `treefmt` has the following specification: ``` -Usage: treefmt [ ...] [flags] +One CLI to format your repo -Arguments: - [ ...] Paths to format. Defaults to formatting the whole tree. +Usage: + treefmt [flags] Flags: - -h, --help Show context-sensitive help. - --allow-missing-formatter Do not exit with error if a configured formatter is missing. - -C, --working-directory="." Run as if treefmt was started in the specified working directory instead of the current working directory. - --no-cache Ignore the evaluation cache entirely. Useful for CI. - -c, --clear-cache Reset the evaluation cache. Use in case the cache is not precise enough. - --config-file=STRING Load the config file from the given path (defaults to searching upwards for treefmt.toml). - --fail-on-change Exit with error if any changes were made. Useful for CI. - -f, --formatters=FORMATTERS,... Specify formatters to apply. Defaults to all formatters. - --tree-root=STRING The root directory from which treefmt will start walking the filesystem (defaults to the directory containing the config file) ($PRJ_ROOT). - --tree-root-file=STRING File to search for to find the project root (if --tree-root is not passed). - --walk="auto" The method used to traverse the files within --tree-root. Currently supports 'auto', 'git' or 'filesystem'. - -v, --verbose Set the verbosity of logs e.g. -vv ($LOG_LEVEL). - -V, --version Print version. - -i, --init Create a new treefmt.toml. - -u, --on-unmatched=warn Log paths that did not match any formatters at the specified log level, with fatal exiting the process with an error. Possible values are - . - --stdin Format the context passed in via stdin. - --cpu-profile=STRING The file into which a cpu profile will be written. - --ci Runs treefmt in a CI mode, enabling --no-cache, --fail-on-change and adjusting some other settings best suited to a CI use case. + --allow-missing-formatter Do not exit with error if a configured formatter is missing. + --ci Runs treefmt in a CI mode, enabling --no-cache, --fail-on-change and adjusting some other settings best suited to a CI use case. + -c, --clear-cache Reset the evaluation cache. Use in case the cache is not precise enough. + --config-file string Load the config file from the given path (defaults to searching upwards for treefmt.toml or .treefmt.toml). + --cpu-profile string The file into which a cpu profile will be written. + -e, --excludes strings Exclude files or directories matching the specified globs. + --fail-on-change Exit with error if any changes were made. Useful for CI. + -f, --formatters strings Specify formatters to apply. Defaults to all configured formatters. + -h, --help help for treefmt + -i, --init Create a treefmt.toml file in the current directory. + --no-cache Ignore the evaluation cache entirely. Useful for CI. + -u, --on-unmatched string Log paths that did not match any formatters at the specified log level, with fatal exiting the process with an error. Possible values are . (default "warn") + --stdin Format the context passed in via stdin. + --tree-root string The root directory from which treefmt will start walking the filesystem (defaults to the directory containing the config file). + --tree-root-file string File to search for to find the tree root (if --tree-root is not passed). + -v, --verbose count Set the verbosity of logs e.g. -vv. + --version version for treefmt + --walk string The method used to traverse the files within the tree root. Currently supports 'auto', 'git' or 'filesystem'. (default "auto") + -C, --working-dir string Run as if treefmt was started in the specified working directory instead of the current working directory. (default ".") ``` ## Arguments -### `[ ...]` +### `...` Paths to format. Defaults to formatting the whole tree ## Flags -### `-h, --help` - -Prints available flags and options - ### `--allow-missing-formatter` Do not exit with an error if some of the configured formatters are missing. -### `-C, --working-directory="."` - -Run as if `treefmt` was started in the specified working directory instead of the current working directory +### `--ci` -### `--no-cache` +Runs treefmt in a CI mode which does the following: -Tells `treefmt` to ignore the evaluation cache entirely. +- ensures `INFO` level logging at a minimum +- enables `--no-cache` and `--fail-on-change` +- introduces a small startup delay so we do not start processing until the second after the process started, thereby + ensuring the accuracy of our change detection based on second-level `modtime`. -With this flag, you can avoid cache invalidation issues, if any. Typically, the machine that is running `treefmt` in -CI is starting with a fresh environment each time, so any calculated cache is lost. +### `--clear-cache` -The `--no-cache` flag eliminates unnecessary work in CI. +Reset the evaluation cache. Use in case the cache is not precise enough. ### `--config-file ` -Run with the specified config file. +Load the config file from the given path (defaults to searching upwards for treefmt.toml or .treefmt.toml). -### `--fail-on-change` +### `--cpu-profile` -Exit with error if any changes were made. +The file into which a cpu profile will be written. -This is useful for CI if you want to detect if someone forgot to format their code. +### `-e, --excludes strings` -### `-f, --formatters ...` +Exclude files or directories matching the specified globs. -Specify formatters to apply. Defaults to all formatters. +### `--fail-on-change` -### `--tree-root="."` +Exit with error if any changes were made. Useful for CI. -The root directory from which `treefmt` will start walking the filesystem. +### `-f, --formatters strings` -### `--walk ` +Specify formatters to apply. Defaults to all configured formatters. -The method used to traverse the files within `--tree-root`. Currently supports `auto`, `git` or `filesystem`. +### `-h, --help` -Default is `auto`, where we will detect if the `` is a git repository and use the `git` walker for -traversal. If not we will fall back to the `filesystem` walker. +Prints available flags and options -### `-v, --verbose` +### `--init` -Set the verbosity of logs e.g. `-vv`. Can also be set with an integer value in an env variable `$LOG_LEVEL`. +Create a treefmt.toml file in the current directory. -Log verbosity is based off the number of 'v' used. With one `-v`, your logs will display `[INFO]` and `[ERROR]` messages, -while `-vv` will also show `[DEBUG]` messages. +### `--no-cache` -### `--init` +Tells `treefmt` to ignore the evaluation cache entirely. -Create a new `treefmt.toml`. +With this flag, you can avoid cache invalidation issues, if any. +Typically, the machine that is running `treefmt` in CI is starting with a fresh environment each time, +so any calculated cache is lost. ### `-u --on-unmatched` @@ -109,23 +105,40 @@ Log paths that did not match any formatters at the specified log level, with fat Format the context passed in via stdin. -### `--cpu-profile` +### `--tree-root string` -The file into which a cpu profile will be written. +The root directory from which `treefmt` will start walking the filesystem. -### `--ci` +[default: the directory containing the config file] -Runs treefmt in a CI mode which does the following: +### `--tree-root-file string` -- ensures `INFO` level logging at a minimum -- enables `--no-cache` and `--fail-on-change` -- introduces a small startup delay so we do not start processing until the second after the process started, thereby - ensuring the accuracy of our change detection based on second-level `modtime`. +File to search for to find the tree root (if `--tree-root` is not passed). -### `-V, --version` +### `-v, --verbose` + +Set the verbosity of logs e.g. `-vv`. Can also be set with an integer value in an env variable `$LOG_LEVEL`. + +Log verbosity is based off the number of 'v' used. With one `-v`, your logs will display `[INFO]` and `[ERROR]` messages, +while `-vv` will also show `[DEBUG]` messages. + +### `--version` Print version. +### `--walk ` + +The method used to traverse the files within `--tree-root`. +Currently, supports `auto`, `git` or `filesystem`. + +Default is `auto`, where we will detect if the `` is a git repository and use the `git` walker for +traversal. +If not we will fall back to the `filesystem` walker. + +### `-C, --working-directory string` + +Run as if `treefmt` was started in the specified working directory instead of the current working directory + ## CI integration Typically, you would use `treefmt` in CI with the `--ci` flag. From af612132956695bae02c52cbe6f29578378f59d9 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Wed, 9 Oct 2024 09:24:01 +0100 Subject: [PATCH 12/15] fixup! feat: replace kong with cobra/viper --- cmd/init/init.toml | 18 ++++++++++++++ config/config.go | 49 ++++++++++++++++++++++++++------------ test/examples/treefmt.toml | 1 - 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/cmd/init/init.toml b/cmd/init/init.toml index d332172f..66bfe1ea 100644 --- a/cmd/init/init.toml +++ b/cmd/init/init.toml @@ -1,5 +1,23 @@ # One CLI to format the code tree - https://git.numtide.com/numtide/treefmt +# At the root level of the config file, you can set values for the various flags which you can pass to treefmt. +# See `treefmt -h` for more info. + +# allow-missing-formatter = true +# ci = true +# clear-cache = true +# cpu-profile = ./cpu.pprof +# excludes = [ "*.md" "*.gif" ] +# fail-on-change = true +# formatters = [ "gofmt" "prettier" ] +# no-cache = true +# on-unmatched = "info" +# tree-root = /tmp/checkout +# tree-root-file = ".git/config" +# verbosity = 2 +# walk = "filesystem" +# working-directory = /tmp/wd + [formatter.mylanguage] # Formatter to run command = "command-to-run" diff --git a/config/config.go b/config/config.go index f8c9080d..74907886 100644 --- a/config/config.go +++ b/config/config.go @@ -31,6 +31,7 @@ type Config struct { FormatterConfigs map[string]*Formatter `mapstructure:"formatter"` Global struct { + // Deprecated: Use Excludes Excludes []string `mapstructure:"excludes,omitempty"` } `mapstructure:"global"` } @@ -56,35 +57,49 @@ type Formatter struct { func SetFlags(fs *pflag.FlagSet) *pflag.FlagSet { fs.Bool( "allow-missing-formatter", false, - "Do not exit with error if a configured formatter is missing.", + "Do not exit with error if a configured formatter is missing. (env $TREEFMT_ALLOW_MISSING_FORMATTER)", ) fs.Bool( "ci", false, "Runs treefmt in a CI mode, enabling --no-cache, --fail-on-change and adjusting some other settings "+ - "best suited to a CI use case.", + "best suited to a CI use case. (env $TREEFMT_CI)", ) fs.BoolP( "clear-cache", "c", false, - "Reset the evaluation cache. Use in case the cache is not precise enough.", + "Reset the evaluation cache. Use in case the cache is not precise enough. (env $TREEFMT_CLEAR_CACHE)", ) - fs.String("cpu-profile", "", "The file into which a cpu profile will be written.") - fs.StringSliceP("excludes", "e", nil, "Exclude files or directories matching the specified globs.") - fs.Bool("fail-on-change", false, "Exit with error if any changes were made. Useful for CI.") + fs.String( + "cpu-profile", "", + "The file into which a cpu profile will be written. (env $TREEFMT_CPU_PROFILE)", + ) + + fs.StringSliceP( + "excludes", "e", nil, + "Exclude files or directories matching the specified globs. (env $TREEFMT_EXCLUDES)", + ) + + fs.Bool( + "fail-on-change", false, + "Exit with error if any changes were made. Useful for CI. (env $TREEFMT_FAIL_ON_CHANGE)", + ) fs.StringSliceP( "formatters", "f", nil, - "Specify formatters to apply. Defaults to all configured formatters.", + "Specify formatters to apply. Defaults to all configured formatters. (env $TREEFMT_FORMATTERS)", ) - fs.Bool("no-cache", false, "Ignore the evaluation cache entirely. Useful for CI.") + fs.Bool( + "no-cache", false, + "Ignore the evaluation cache entirely. Useful for CI. (env $TREEFMT_NO_CACHE)", + ) fs.StringP( "on-unmatched", "u", "warn", - "Log paths that did not match any formatters at the specified log level, with fatal exiting the "+ - "process with an error. Possible values are .", + "Log paths that did not match any formatters at the specified log level. Possible values are "+ + ". (env $TREEFMT_ON_UNMATCHED)", ) fs.Bool("stdin", false, "Format the context passed in via stdin.") @@ -92,22 +107,26 @@ func SetFlags(fs *pflag.FlagSet) *pflag.FlagSet { fs.String( "tree-root", "", "The root directory from which treefmt will start walking the "+ - "filesystem (defaults to the directory containing the config file).", + "filesystem (defaults to the directory containing the config file). (env $TREEFMT_TREE_ROOT)", ) - fs.String("tree-root-file", "", "File to search for to find the tree root (if --tree-root is not passed).") + fs.String( + "tree-root-file", "", + "File to search for to find the tree root (if --tree-root is not passed). (env $TREEFMT_TREE_ROOT_FILE)", + ) fs.String( "walk", "auto", "The method used to traverse the files within the tree root. Currently supports 'auto', 'git' or "+ - "'filesystem'.", + "'filesystem'. ($TREEFMT_WALK)", ) - fs.CountP("verbose", "v", "Set the verbosity of logs e.g. -vv.") + fs.CountP("verbose", "v", "Set the verbosity of logs e.g. -vv. (env $TREEFMT_VERBOSE)") fs.StringP( "working-dir", "C", ".", - "Run as if treefmt was started in the specified working directory instead of the current working "+"directory.", + "Run as if treefmt was started in the specified working directory instead of the current working "+ + "directory. $(TREEFMT_WORKING_DIR)", ) return fs diff --git a/test/examples/treefmt.toml b/test/examples/treefmt.toml index 7c171939..e70f30fc 100644 --- a/test/examples/treefmt.toml +++ b/test/examples/treefmt.toml @@ -1,6 +1,5 @@ # One CLI to format the code tree - https://github.com/numtide/treefmt -[global] excludes = ["*.toml"] [formatter.python] From 9225253d541d4647c0eb9caf1b9a3c1e3a38df85 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Wed, 9 Oct 2024 09:54:38 +0100 Subject: [PATCH 13/15] fixup! doc: update usage Signed-off-by: Brian McGee --- docs/usage.md | 54 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 01cc3001..dd008699 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -13,25 +13,25 @@ Usage: treefmt [flags] Flags: - --allow-missing-formatter Do not exit with error if a configured formatter is missing. - --ci Runs treefmt in a CI mode, enabling --no-cache, --fail-on-change and adjusting some other settings best suited to a CI use case. - -c, --clear-cache Reset the evaluation cache. Use in case the cache is not precise enough. + --allow-missing-formatter Do not exit with error if a configured formatter is missing. (env $TREEFMT_ALLOW_MISSING_FORMATTER) + --ci Runs treefmt in a CI mode, enabling --no-cache, --fail-on-change and adjusting some other settings best suited to a CI use case. (env $TREEFMT_CI) + -c, --clear-cache Reset the evaluation cache. Use in case the cache is not precise enough. (env $TREEFMT_CLEAR_CACHE) --config-file string Load the config file from the given path (defaults to searching upwards for treefmt.toml or .treefmt.toml). - --cpu-profile string The file into which a cpu profile will be written. - -e, --excludes strings Exclude files or directories matching the specified globs. - --fail-on-change Exit with error if any changes were made. Useful for CI. - -f, --formatters strings Specify formatters to apply. Defaults to all configured formatters. + --cpu-profile string The file into which a cpu profile will be written. (env $TREEFMT_CPU_PROFILE) + -e, --excludes strings Exclude files or directories matching the specified globs. (env $TREEFMT_EXCLUDES) + --fail-on-change Exit with error if any changes were made. Useful for CI. (env $TREEFMT_FAIL_ON_CHANGE) + -f, --formatters strings Specify formatters to apply. Defaults to all configured formatters. (env $TREEFMT_FORMATTERS) -h, --help help for treefmt -i, --init Create a treefmt.toml file in the current directory. - --no-cache Ignore the evaluation cache entirely. Useful for CI. - -u, --on-unmatched string Log paths that did not match any formatters at the specified log level, with fatal exiting the process with an error. Possible values are . (default "warn") + --no-cache Ignore the evaluation cache entirely. Useful for CI. (env $TREEFMT_NO_CACHE) + -u, --on-unmatched string Log paths that did not match any formatters at the specified log level. Possible values are . (env $TREEFMT_ON_UNMATCHED) (default "warn") --stdin Format the context passed in via stdin. - --tree-root string The root directory from which treefmt will start walking the filesystem (defaults to the directory containing the config file). - --tree-root-file string File to search for to find the tree root (if --tree-root is not passed). - -v, --verbose count Set the verbosity of logs e.g. -vv. + --tree-root string The root directory from which treefmt will start walking the filesystem (defaults to the directory containing the config file). (env $TREEFMT_TREE_ROOT) + --tree-root-file string File to search for to find the tree root (if --tree-root is not passed). (env $TREEFMT_TREE_ROOT_FILE) + -v, --verbose count Set the verbosity of logs e.g. -vv. (env $TREEFMT_VERBOSE) --version version for treefmt - --walk string The method used to traverse the files within the tree root. Currently supports 'auto', 'git' or 'filesystem'. (default "auto") - -C, --working-dir string Run as if treefmt was started in the specified working directory instead of the current working directory. (default ".") + --walk string The method used to traverse the files within the tree root. Currently supports 'auto', 'git' or 'filesystem'. ($TREEFMT_WALK) (default "auto") + -C, --working-dir string Run as if treefmt was started in the specified working directory instead of the current working directory. $(TREEFMT_WORKING_DIR) (default ".") ``` ## Arguments @@ -44,10 +44,14 @@ Paths to format. Defaults to formatting the whole tree ### `--allow-missing-formatter` +`$TREEFMT_ALLOW_MISSING_FORMATTER` + Do not exit with an error if some of the configured formatters are missing. ### `--ci` +`$TREEFMT_CI` + Runs treefmt in a CI mode which does the following: - ensures `INFO` level logging at a minimum @@ -57,6 +61,8 @@ Runs treefmt in a CI mode which does the following: ### `--clear-cache` +`$TREEFMT_CLEAR_CACHE` + Reset the evaluation cache. Use in case the cache is not precise enough. ### `--config-file ` @@ -65,18 +71,26 @@ Load the config file from the given path (defaults to searching upwards for tree ### `--cpu-profile` +`$TREEFMT_CPU_PROFILE` + The file into which a cpu profile will be written. ### `-e, --excludes strings` +`$TREEFMT_EXCLUDES` + Exclude files or directories matching the specified globs. ### `--fail-on-change` +`$TREEFMT_FAIL_ON_CHANGE` + Exit with error if any changes were made. Useful for CI. ### `-f, --formatters strings` +`$TREEFMT_FORMATTERS` + Specify formatters to apply. Defaults to all configured formatters. ### `-h, --help` @@ -89,6 +103,8 @@ Create a treefmt.toml file in the current directory. ### `--no-cache` +`$TREEFMT_NO_CACHE` + Tells `treefmt` to ignore the evaluation cache entirely. With this flag, you can avoid cache invalidation issues, if any. @@ -97,6 +113,8 @@ so any calculated cache is lost. ### `-u --on-unmatched` +`$TREEFMT_ON_UNMATCHED` + Log paths that did not match any formatters at the specified log level, with fatal exiting the process with an error. Possible values are . [default: warn] @@ -107,12 +125,16 @@ Format the context passed in via stdin. ### `--tree-root string` +`$TREEFMT_TREE_ROOT` + The root directory from which `treefmt` will start walking the filesystem. [default: the directory containing the config file] ### `--tree-root-file string` +`$TREEFMT_TREE_ROOT_FILE` + File to search for to find the tree root (if `--tree-root` is not passed). ### `-v, --verbose` @@ -128,6 +150,8 @@ Print version. ### `--walk ` +`$TREEFMT_WALK` + The method used to traverse the files within `--tree-root`. Currently, supports `auto`, `git` or `filesystem`. @@ -137,6 +161,8 @@ If not we will fall back to the `filesystem` walker. ### `-C, --working-directory string` +`$TREEFMT_WORKING_DIR` + Run as if `treefmt` was started in the specified working directory instead of the current working directory ## CI integration From cb164cf083acd9055c9aa92768e0acd4b303f9d1 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Wed, 9 Oct 2024 09:54:45 +0100 Subject: [PATCH 14/15] doc: update configure Signed-off-by: Brian McGee --- docs/configure.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/configure.md b/docs/configure.md index ee6723a6..3a6c1a7f 100644 --- a/docs/configure.md +++ b/docs/configure.md @@ -7,9 +7,28 @@ outline: deep The `treefmt.toml` configuration file consists of a mixture of global options and formatter sections: ```toml -[global] + +# At the root level of the config file, you can set values for the various flags which you can pass to treefmt. +# See `treefmt -h` for more info. + +# allow-missing-formatter = true +# ci = true +# clear-cache = true +# cpu-profile = ./cpu.pprof +# fail-on-change = true +# formatters = [ "gofmt" "prettier" ] +# no-cache = true +# on-unmatched = "info" +# tree-root = /tmp/checkout +# tree-root-file = ".git/config" +# verbosity = 2 +# walk = "filesystem" +# working-directory = /tmp/wd + excludes = ["*.md", "*.dat"] +# define some formatters + [formatter.elm] command = "elm-format" options = ["--yes"] @@ -40,9 +59,10 @@ includes = ["*.sh"] priority = 1 ``` -## Global Options +## Root Options -- `excludes` - an optional list of [glob patterns](#glob-patterns-format) used to exclude certain files from all formatters. +More information is available [here](usage.md) about possible values you can set at the root of the config file. +In general, if it can be passed as a cli arg you can set it at the root of the config file. ## Formatter Options From ef457929652928d02d68e93eb0d357cf60f12dad Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Wed, 9 Oct 2024 10:25:04 +0100 Subject: [PATCH 15/15] fixup! feat: replace kong with cobra/viper --- config/config_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config_test.go b/config/config_test.go index b5e2980a..816b290d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -558,7 +558,7 @@ func TestSampleConfigFile(t *testing.T) { as.NoError(err, "failed to unmarshal config from viper") as.NotNil(cfg) - as.Equal([]string{"*.toml"}, cfg.Global.Excludes) + as.Equal([]string{"*.toml"}, cfg.Excludes) // python python, ok := cfg.FormatterConfigs["python"]