From 9152fd266d94857f54f03503b7670b428605f9a6 Mon Sep 17 00:00:00 2001 From: Dominik Menke Date: Mon, 30 May 2022 22:22:40 +0200 Subject: [PATCH] automatically detect Go module path This started as a proof-of-concept for #30. Signed-off-by: Dominik Menke --- cmd/gci/gcicommand.go | 3 + go.mod | 2 +- pkg/gci/configuration.go | 39 ++++++++ pkg/gci/gci.go | 6 +- pkg/gci/gci_test.go | 3 + pkg/gci/internal/testdata/modules.cfg.yaml | 4 + pkg/gci/internal/testdata/modules.in.go | 8 ++ pkg/gci/internal/testdata/modules.out.go | 8 ++ pkg/gci/mod.go | 100 +++++++++++++++++++++ pkg/gci/sections/module.go | 60 +++++++++++++ pkg/gci/specificity/module.go | 19 ++++ pkg/gci/specificity/specificity.go | 1 + pkg/gci/specificity/specificity_test.go | 2 +- 13 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 pkg/gci/internal/testdata/modules.cfg.yaml create mode 100644 pkg/gci/internal/testdata/modules.in.go create mode 100644 pkg/gci/internal/testdata/modules.out.go create mode 100644 pkg/gci/mod.go create mode 100644 pkg/gci/sections/module.go create mode 100644 pkg/gci/specificity/module.go diff --git a/cmd/gci/gcicommand.go b/cmd/gci/gcicommand.go index 9c4fdd5..0a14f59 100644 --- a/cmd/gci/gcicommand.go +++ b/cmd/gci/gcicommand.go @@ -30,6 +30,9 @@ func (e *Executor) newGciCommand(use, short, long string, aliases []string, stdI if err != nil { return err } + if err = gciCfg.InitializeModules(args); err != nil { + return err + } if *debug { log.SetLevel(zapcore.DebugLevel) } diff --git a/go.mod b/go.mod index 7345497..6ecfbc6 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/spf13/cobra v1.3.0 github.com/stretchr/testify v1.7.0 go.uber.org/zap v1.17.0 + golang.org/x/mod v0.5.0 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/tools v0.1.5 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b @@ -19,7 +20,6 @@ require ( github.com/spf13/pflag v1.0.5 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect - golang.org/x/mod v0.5.0 // indirect golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect ) diff --git a/pkg/gci/configuration.go b/pkg/gci/configuration.go index 5825238..d3d0059 100644 --- a/pkg/gci/configuration.go +++ b/pkg/gci/configuration.go @@ -40,6 +40,45 @@ func (g GciStringConfiguration) Parse() (*GciConfiguration, error) { return &GciConfiguration{g.Cfg, sections, sectionSeparators}, nil } +// InitializeModules collects and remembers Go module names for the given +// files, by traversing the file system. +// +// This method requires that g.Sections contains the Module section, +// otherwise InitializeModules does nothing. This also implies that +// this method should be called after changes to g.Sections, for example +// right after (*GciStringConfiguration).Parse(). +func (g *GciConfiguration) InitializeModules(files []string) error { + var moduleSection *sectionsPkg.Module + for _, section := range g.Sections { + if m, ok := section.(sectionsPkg.Module); ok { + moduleSection = &m + break + } + } + if moduleSection == nil { + // skip collecting Go modules when not needed + return nil + } + + resolver := make(moduleResolver) + knownModulePaths := map[string]struct{}{} // unique list of Go modules + for _, file := range files { + path, err := resolver.Lookup(file) + if err != nil { + return err + } + if path != "" { + knownModulePaths[path] = struct{}{} + } + } + modulePaths := make([]string, 0, len(knownModulePaths)) + for path := range knownModulePaths { + modulePaths = append(modulePaths, path) + } + moduleSection.SetModulePaths(modulePaths) + return nil +} + func initializeGciConfigFromYAML(filePath string) (*GciConfiguration, error) { yamlCfg := GciStringConfiguration{} yamlData, err := ioutil.ReadFile(filePath) diff --git a/pkg/gci/gci.go b/pkg/gci/gci.go index eb30c11..c559a99 100644 --- a/pkg/gci/gci.go +++ b/pkg/gci/gci.go @@ -28,7 +28,11 @@ func (list SectionList) String() []string { } func DefaultSections() SectionList { - return SectionList{sectionsPkg.StandardPackage{}, sectionsPkg.DefaultSection{nil, nil}} + return SectionList{ + sectionsPkg.StandardPackage{}, + sectionsPkg.DefaultSection{nil, nil}, + sectionsPkg.Module{}, + } } func DefaultSectionSeparators() SectionList { diff --git a/pkg/gci/gci_test.go b/pkg/gci/gci_test.go index 15362ac..82edf2e 100644 --- a/pkg/gci/gci_test.go +++ b/pkg/gci/gci_test.go @@ -39,6 +39,9 @@ func TestRun(t *testing.T) { if err != nil { t.Fatal(err) } + if err = gciCfg.InitializeModules([]string{testFile}); err != nil { + t.Fatal(err) + } _, formattedFile, err := LoadFormatGoFile(io.File{fileBaseName + ".in.go"}, *gciCfg) if err != nil { diff --git a/pkg/gci/internal/testdata/modules.cfg.yaml b/pkg/gci/internal/testdata/modules.cfg.yaml new file mode 100644 index 0000000..533277c --- /dev/null +++ b/pkg/gci/internal/testdata/modules.cfg.yaml @@ -0,0 +1,4 @@ +sections: +- Standard +- Module +- Default diff --git a/pkg/gci/internal/testdata/modules.in.go b/pkg/gci/internal/testdata/modules.in.go new file mode 100644 index 0000000..764cc2c --- /dev/null +++ b/pkg/gci/internal/testdata/modules.in.go @@ -0,0 +1,8 @@ +package main +import ( + "github.com/daixiang0/gci" + + "golang.org/x/tools" + + "fmt" +) diff --git a/pkg/gci/internal/testdata/modules.out.go b/pkg/gci/internal/testdata/modules.out.go new file mode 100644 index 0000000..fe0d758 --- /dev/null +++ b/pkg/gci/internal/testdata/modules.out.go @@ -0,0 +1,8 @@ +package main +import ( + "fmt" + + "github.com/daixiang0/gci" + + "golang.org/x/tools" +) diff --git a/pkg/gci/mod.go b/pkg/gci/mod.go new file mode 100644 index 0000000..fe2f44e --- /dev/null +++ b/pkg/gci/mod.go @@ -0,0 +1,100 @@ +package gci + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "golang.org/x/mod/modfile" +) + +// moduleResolver looksup the module path for a given (Go) file. +// To improve performance, the file paths and module paths are +// cached. +// +// Given the following directory structure: +// +// /path/to/example +// +-- go.mod (module example) +// +-- cmd/sample/main.go (package main, imports example/util) +// +-- util/util.go (package util) +// +// After looking up main.go and util.go, the internal cache will contain: +// +// "/path/to/foobar/": "example" +// +// For more complex module structures (i.e. sub-modules), the cache +// might look like this: +// +// "/path/to/example/": "example" +// "/path/to/example/cmd/sample/": "go.example.com/historic/path" +// +// When matching files against this cache, the resolver will select the +// entry with the most specific path (so that, in this example, the file +// cmd/sample/main.go will resolve to go.example.com/historic/path). +type moduleResolver map[string]string + +func (m moduleResolver) Lookup(file string) (string, error) { + abs, err := filepath.Abs(file) + if err != nil { + return "", fmt.Errorf("could not make path absolute: %w", err) + } + + var bestMatch string + for path := range m { + if strings.HasPrefix(abs, path) && len(path) > len(bestMatch) { + bestMatch = path + } + } + if bestMatch != "" { + return m[bestMatch], nil + } + + return m.findRecursively(filepath.Dir(abs)) +} + +func (m moduleResolver) findRecursively(dir string) (string, error) { + // When going up the directory tree, we might never find a go.mod + // file. In this case remember where we started, so that the next + // time we can short circuit the recursive ascent. + stop := dir + + for { + gomod := filepath.Join(dir, "go.mod") + _, err := os.Stat(gomod) + if errors.Is(err, os.ErrNotExist) { + // go.mod doesn't exist at current location + next := filepath.Dir(dir) + if next == dir { + // we're at the top of the filesystem + m[stop] = "" + return "", nil + } + // go one level up + dir = next + continue + } else if err != nil { + // other error (likely EPERM) + return "", fmt.Errorf("module lookup failed: %w", err) + } + + // we found a go.mod + mod, err := ioutil.ReadFile(gomod) + if err != nil { + return "", fmt.Errorf("reading module failed: %w", err) + } + + // store module path at m[dir]. add path separator to avoid + // false-positive (think of /foo and /foobar). + mpath := modfile.ModulePath(mod) + if dir != "/" { + // add trailing path sep, but not for *nix root directory + dir += string(os.PathListSeparator) + } + m[dir] = mpath + return mpath, nil + } +} diff --git a/pkg/gci/sections/module.go b/pkg/gci/sections/module.go new file mode 100644 index 0000000..f4314cc --- /dev/null +++ b/pkg/gci/sections/module.go @@ -0,0 +1,60 @@ +package sections + +import ( + "strings" + + "github.com/daixiang0/gci/pkg/configuration" + importPkg "github.com/daixiang0/gci/pkg/gci/imports" + "github.com/daixiang0/gci/pkg/gci/specificity" +) + +func init() { + prefixType := SectionType{ + generatorFun: func(parameter string, sectionPrefix, sectionSuffix Section) (Section, error) { + return Module{}, nil + }, + aliases: []string{"Module", "Mod"}, + description: "Groups all imports of the corresponding Go module", + }.StandAloneSection().WithoutParameter() + SectionParserInst.registerSectionWithoutErr(&prefixType) +} + +type Module struct { + // modulePaths contains all known Go module path names. + // + // This must be a pointer, because gci.formatImportBlock() will create + // mapping between sections and imports, and slices are unhashable. + modulePaths *[]string +} + +func (m Module) MatchSpecificity(spec importPkg.ImportDef) specificity.MatchSpecificity { + if m.modulePaths == nil { + return specificity.MisMatch{} + } + + importPath := spec.Path() + for _, path := range *m.modulePaths { + if strings.HasPrefix(importPath, path) { + return specificity.Module{} + } + } + return specificity.MisMatch{} +} + +func (m Module) Format(imports []importPkg.ImportDef, cfg configuration.FormatterConfiguration) string { + return inorderSectionFormat(m, imports, cfg) +} + +func (Module) sectionPrefix() Section { return nil } +func (Module) sectionSuffix() Section { return nil } + +func (Module) String() string { + return "Module" +} + +func (m *Module) SetModulePaths(paths []string) { + dup := make([]string, len(paths), len(paths)) + copy(dup, paths) + + m.modulePaths = &dup +} diff --git a/pkg/gci/specificity/module.go b/pkg/gci/specificity/module.go new file mode 100644 index 0000000..0df0ce4 --- /dev/null +++ b/pkg/gci/specificity/module.go @@ -0,0 +1,19 @@ +package specificity + +type Module struct{} + +func (m Module) IsMoreSpecific(than MatchSpecificity) bool { + return isMoreSpecific(m, than) +} + +func (m Module) Equal(to MatchSpecificity) bool { + return equalSpecificity(m, to) +} + +func (Module) class() specificityClass { + return ModuleClass +} + +func (Module) String() string { + return "Module" +} diff --git a/pkg/gci/specificity/specificity.go b/pkg/gci/specificity/specificity.go index 0a7c9f8..6de436c 100644 --- a/pkg/gci/specificity/specificity.go +++ b/pkg/gci/specificity/specificity.go @@ -7,6 +7,7 @@ const ( DefaultClass = 10 StandardPackageClass = 20 MatchClass = 30 + ModuleClass = 40 ) // MatchSpecificity is used to determine which section matches an import best diff --git a/pkg/gci/specificity/specificity_test.go b/pkg/gci/specificity/specificity_test.go index f6a1cb2..f7167c6 100644 --- a/pkg/gci/specificity/specificity_test.go +++ b/pkg/gci/specificity/specificity_test.go @@ -25,5 +25,5 @@ func TestSpecificityEquality(t *testing.T) { } func testCasesInSpecificityOrder() []MatchSpecificity { - return []MatchSpecificity{MisMatch{}, Default{}, StandardPackageMatch{}, Match{0}, Match{1}} + return []MatchSpecificity{MisMatch{}, Default{}, StandardPackageMatch{}, Match{0}, Match{1}, Module{}} }