Skip to content

feat: new fmt command with dedicated formatter configuration #5357

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Feb 17, 2025
Merged
Prev Previous commit
Next Next commit
feat: new command
  • Loading branch information
ldez committed Feb 17, 2025
commit 399d55c305f9a2833a2d681d62c744c0b1835317
5 changes: 5 additions & 0 deletions pkg/commands/flagsets.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func setupLintersFlagSet(v *viper.Viper, fs *pflag.FlagSet) {
color.GreenString("Override linters configuration section to only run the specific linter(s)")) // Flags only.
}

func setupFormattersFlagSet(v *viper.Viper, fs *pflag.FlagSet) {
internal.AddFlagAndBindP(v, fs, fs.StringSliceP, "enable", "E", "formatters.enable", nil,
color.GreenString("Enable specific formatter"))
}

func setupRunFlagSet(v *viper.Viper, fs *pflag.FlagSet) {
internal.AddFlagAndBindP(v, fs, fs.IntP, "concurrency", "j", "run.concurrency", getDefaultConcurrency(),
color.GreenString("Number of CPUs to use (Default: number of logical CPUs)"))
Expand Down
159 changes: 159 additions & 0 deletions pkg/commands/fmt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package commands

import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/golangci/golangci-lint/pkg/config"
"github.com/golangci/golangci-lint/pkg/goformat"
"github.com/golangci/golangci-lint/pkg/goformatters"
"github.com/golangci/golangci-lint/pkg/logutils"
"github.com/golangci/golangci-lint/pkg/result/processors"
)

type fmtCommand struct {
viper *viper.Viper
cmd *cobra.Command

opts config.LoaderOptions

cfg *config.Config

buildInfo BuildInfo

runner *goformat.Runner

log logutils.Log
debugf logutils.DebugFunc
}

func newFmtCommand(logger logutils.Log, info BuildInfo) *fmtCommand {
c := &fmtCommand{
viper: viper.New(),
log: logger,
debugf: logutils.Debug(logutils.DebugKeyExec),
cfg: config.NewDefault(),
buildInfo: info,
}

fmtCmd := &cobra.Command{
Use: "fmt",
Short: "Format Go source files",
RunE: c.execute,
PreRunE: c.preRunE,
PersistentPreRunE: c.persistentPreRunE,
SilenceUsage: true,
}

fmtCmd.SetOut(logutils.StdOut) // use custom output to properly color it in Windows terminals
fmtCmd.SetErr(logutils.StdErr)

flagSet := fmtCmd.Flags()
flagSet.SortFlags = false // sort them as they are defined here

setupConfigFileFlagSet(flagSet, &c.opts)

setupFormattersFlagSet(c.viper, flagSet)

c.cmd = fmtCmd

return c
}

func (c *fmtCommand) persistentPreRunE(cmd *cobra.Command, args []string) error {
c.log.Infof("%s", c.buildInfo.String())

loader := config.NewLoader(c.log.Child(logutils.DebugKeyConfigReader), c.viper, cmd.Flags(), c.opts, c.cfg, args)

err := loader.Load(config.LoadOptions{CheckDeprecation: true, Validation: true})
if err != nil {
return fmt.Errorf("can't load config: %w", err)
}

return nil
}

func (c *fmtCommand) preRunE(_ *cobra.Command, _ []string) error {
metaFormatter, err := goformatters.NewMetaFormatter(c.log, &c.cfg.Formatters, &c.cfg.Run)
if err != nil {
return fmt.Errorf("failed to create meta-formatter: %w", err)
}

matcher := processors.NewGeneratedFileMatcher(c.cfg.Formatters.Exclusions.Generated)

opts, err := goformat.NewRunnerOptions(c.cfg)
if err != nil {
return fmt.Errorf("build walk options: %w", err)
}

c.runner = goformat.NewRunner(c.log, metaFormatter, matcher, opts)

return nil
}

func (c *fmtCommand) execute(_ *cobra.Command, args []string) error {
if !logutils.HaveDebugTag(logutils.DebugKeyLintersOutput) {
// Don't allow linters and loader to print anything
log.SetOutput(io.Discard)
savedStdout, savedStderr := c.setOutputToDevNull()
defer func() {
os.Stdout, os.Stderr = savedStdout, savedStderr
}()
}

paths, err := cleanArgs(args)
if err != nil {
return fmt.Errorf("failed to clean arguments: %w", err)
}

c.log.Infof("Formatting Go files...")

err = c.runner.Run(paths)
if err != nil {
return fmt.Errorf("failed to process files: %w", err)
}

return nil
}

func (c *fmtCommand) setOutputToDevNull() (savedStdout, savedStderr *os.File) {
savedStdout, savedStderr = os.Stdout, os.Stderr
devNull, err := os.Open(os.DevNull)
if err != nil {
c.log.Warnf("Can't open null device %q: %s", os.DevNull, err)
return
}

os.Stdout, os.Stderr = devNull, devNull
return
}

func cleanArgs(args []string) ([]string, error) {
if len(args) == 0 {
abs, err := filepath.Abs(".")
if err != nil {
return nil, err
}

return []string{abs}, nil
}

var expended []string
for _, arg := range args {
abs, err := filepath.Abs(strings.ReplaceAll(arg, "...", ""))
if err != nil {
return nil, err
}

expended = append(expended, abs)
}

return expended, nil
}
1 change: 1 addition & 0 deletions pkg/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func newRootCommand(info BuildInfo) *rootCommand {
rootCmd.AddCommand(
newLintersCommand(log).cmd,
newRunCommand(log, info).cmd,
newFmtCommand(log, info).cmd,
newCacheCommand().cmd,
newConfigCommand(log, info).cmd,
newVersionCommand(info).cmd,
Expand Down
166 changes: 166 additions & 0 deletions pkg/goformat/runner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package goformat

import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/golangci/golangci-lint/pkg/config"
"github.com/golangci/golangci-lint/pkg/fsutils"
"github.com/golangci/golangci-lint/pkg/goformatters"
"github.com/golangci/golangci-lint/pkg/logutils"
"github.com/golangci/golangci-lint/pkg/result/processors"
)

type Runner struct {
log logutils.Log

metaFormatter *goformatters.MetaFormatter
matcher *processors.GeneratedFileMatcher

opts RunnerOptions
}

func NewRunner(log logutils.Log,
metaFormatter *goformatters.MetaFormatter, matcher *processors.GeneratedFileMatcher,
opts RunnerOptions) *Runner {
return &Runner{
log: log,
matcher: matcher,
metaFormatter: metaFormatter,
opts: opts,
}
}

func (c *Runner) Run(paths []string) error {
for _, path := range paths {
err := c.walk(path)
if err != nil {
return err
}
}

return nil
}

func (c *Runner) walk(root string) error {
return filepath.Walk(root, func(path string, f fs.FileInfo, err error) error {
if err != nil {
return err
}

if f.IsDir() && skipDir(f.Name()) {
return fs.SkipDir
}

// Ignore non-Go files.
if !isGoFile(f) {
return nil
}

match, err := c.opts.MatchPatterns(path)
if err != nil || match {
return err
}

input, err := os.ReadFile(path)
if err != nil {
return err
}

match, err = c.matcher.IsGeneratedFile(path, input)
if err != nil || match {
return err
}

output := c.metaFormatter.Format(path, input)

if bytes.Equal(input, output) {
return nil
}

c.log.Infof("format: %s", path)

// On Windows, we need to re-set the permissions from the file. See golang/go#38225.
var perms os.FileMode
if fi, err := os.Stat(path); err == nil {
perms = fi.Mode() & os.ModePerm
}

return os.WriteFile(path, output, perms)
})
}

type RunnerOptions struct {
basePath string
patterns []*regexp.Regexp
generated string
}

func NewRunnerOptions(cfg *config.Config) (RunnerOptions, error) {
basePath, err := fsutils.GetBasePath(context.Background(), cfg.Run.RelativePathMode, cfg.GetConfigDir())
if err != nil {
return RunnerOptions{}, fmt.Errorf("get base path: %w", err)
}

opts := RunnerOptions{
basePath: basePath,
generated: cfg.Formatters.Exclusions.Generated,
}

for _, pattern := range cfg.Formatters.Exclusions.Paths {
exp, err := regexp.Compile(fsutils.NormalizePathInRegex(pattern))
if err != nil {
return RunnerOptions{}, fmt.Errorf("compile path pattern %q: %w", pattern, err)
}

opts.patterns = append(opts.patterns, exp)
}

return opts, nil
}

func (o RunnerOptions) MatchPatterns(path string) (bool, error) {
if len(o.patterns) == 0 {
return false, nil
}

rel, err := filepath.Rel(o.basePath, path)
if err != nil {
return false, err
}

for _, pattern := range o.patterns {
if pattern.MatchString(rel) {
return true, nil
}
}

return false, nil
}

func skipDir(name string) bool {
switch name {
case "vendor", "testdata", "node_modules":
return true

case "third_party", "builtin": // For compatibility with `exclude-dirs-use-default`.
return true

default:
if strings.HasPrefix(name, ".") {
return true
}

return false
}
}

func isGoFile(f fs.FileInfo) bool {
return !f.IsDir() && !strings.HasPrefix(f.Name(), ".") && strings.HasSuffix(f.Name(), ".go")
}