From 640b3ee44ab751b7cc183ac00743808cf00deba4 Mon Sep 17 00:00:00 2001 From: Hariom Verma Date: Thu, 29 Feb 2024 02:22:26 +0530 Subject: [PATCH] feat: implement `gno mod why` (#1407) `gno mod why`: explains why packages or modules are needed Also answers the question "why is this package or module being kept by `gno mod tidy`?" --- docs/concepts/gno-modules.md | 1 + docs/reference/go-gno-compatibility.md | 1 + gnovm/cmd/gno/mod.go | 162 ++++++++++++++++++++++--- gnovm/cmd/gno/mod_test.go | 57 ++++++++- 4 files changed, 204 insertions(+), 17 deletions(-) diff --git a/docs/concepts/gno-modules.md b/docs/concepts/gno-modules.md index 970dd16644c..2122ae94371 100644 --- a/docs/concepts/gno-modules.md +++ b/docs/concepts/gno-modules.md @@ -25,6 +25,7 @@ The gno command-line tool provides several commands to work with the gno.mod fil - **gno mod init**: small helper to initialize a new `gno.mod` file. - **gno mod download**: downloads the dependencies specified in the gno.mod file. This command fetches the required dependencies from chain and ensures they are available for local testing and development. - **gno mod tidy**: removes any unused dependency and adds any required but not yet listed in the file -- most of the maintenance you'll usually need to do! +- **gno mod why**: explains why the specified package or module is being kept by `gno mod tidy`. ## Sample `gno.mod` file diff --git a/docs/reference/go-gno-compatibility.md b/docs/reference/go-gno-compatibility.md index 4b65b6c32ef..0bac44518ce 100644 --- a/docs/reference/go-gno-compatibility.md +++ b/docs/reference/go-gno-compatibility.md @@ -303,6 +303,7 @@ Legend: | + go mod init | gno mod init | same behavior | | + go mod download | gno mod download | same behavior | | + go mod tidy | gno mod tidy | same behavior | +| + go mod why | gno mod why | same intention | | | gno transpile | | | go work | | | | | gno repl | | diff --git a/gnovm/cmd/gno/mod.go b/gnovm/cmd/gno/mod.go index da8db3ed68a..e12fccbad75 100644 --- a/gnovm/cmd/gno/mod.go +++ b/gnovm/cmd/gno/mod.go @@ -36,6 +36,7 @@ func newModCmd(io commands.IO) *commands.Command { newModDownloadCmd(io), newModInitCmd(), newModTidy(io), + newModWhy(io), ) return cmd @@ -85,6 +86,44 @@ func newModTidy(io commands.IO) *commands.Command { ) } +func newModWhy(io commands.IO) *commands.Command { + return commands.NewCommand( + commands.Metadata{ + Name: "why", + ShortUsage: "why [...]", + ShortHelp: "Explains why modules are needed", + LongHelp: `Explains why modules are needed. + +gno mod why shows a list of files where specified packages or modules are +being used, explaining why those specified packages or modules are being +kept by gno mod tidy. + +The output is a sequence of stanzas, one for each module/package name +specified, separated by blank lines. Each stanza begins with a +comment line "# module" giving the target module/package. Subsequent lines +show files that import the specified module/package, one filename per line. +If the package or module is not being used/needed/imported, the stanza +will display a single parenthesized note indicating that fact. + +For example: + + $ gno mod why gno.land/p/demo/avl gno.land/p/demo/users + # gno.land/p/demo/avl + [FILENAME_1.gno] + [FILENAME_2.gno] + + # gno.land/p/demo/users + (module [MODULE_NAME] does not need package gno.land/p/demo/users) + $ +`, + }, + commands.NewEmptyConfig(), + func(_ context.Context, args []string) error { + return execModWhy(args, io) + }, + ) +} + func (c *modDownloadCfg) RegisterFlags(fs *flag.FlagSet) { fs.StringVar( &c.remote, @@ -192,7 +231,7 @@ func execModTidy(args []string, io commands.IO) error { gm.DropRequire(r.Mod.Path) } - imports, err := getGnoImports(wd) + imports, err := getGnoPackageImports(wd) if err != nil { return err } @@ -208,7 +247,85 @@ func execModTidy(args []string, io commands.IO) error { return nil } -// getGnoImports returns the list of gno imports from a given path. +func execModWhy(args []string, io commands.IO) error { + if len(args) < 1 { + return flag.ErrHelp + } + + wd, err := os.Getwd() + if err != nil { + return err + } + fname := filepath.Join(wd, "gno.mod") + gm, err := gnomod.ParseGnoMod(fname) + if err != nil { + return err + } + + importToFilesMap, err := getImportToFilesMap(wd) + if err != nil { + return err + } + + // Format and print `gno mod why` output stanzas + out := formatModWhyStanzas(gm.Module.Mod.Path, args, importToFilesMap) + io.Printf(out) + + return nil +} + +// formatModWhyStanzas returns a formatted output for the go mod why command. +// It takes three parameters: +// - modulePath (the path of the module) +// - args (input arguments) +// - importToFilesMap (a map of import to files). +func formatModWhyStanzas(modulePath string, args []string, importToFilesMap map[string][]string) (out string) { + for i, arg := range args { + out += fmt.Sprintf("# %s\n", arg) + files, ok := importToFilesMap[arg] + if !ok { + out += fmt.Sprintf("(module %s does not need package %s)\n", modulePath, arg) + } else { + for _, file := range files { + out += file + "\n" + } + } + if i < len(args)-1 { // Add a newline if it's not the last stanza + out += "\n" + } + } + return +} + +// getImportToFilesMap returns a map where each key is an import path and its +// value is a list of files importing that package with the specified import path. +func getImportToFilesMap(pkgPath string) (map[string][]string, error) { + entries, err := os.ReadDir(pkgPath) + if err != nil { + return nil, err + } + m := make(map[string][]string) // import -> []file + for _, e := range entries { + filename := e.Name() + if ext := filepath.Ext(filename); ext != ".gno" { + continue + } + if strings.HasSuffix(filename, "_filetest.gno") { + continue + } + imports, err := getGnoFileImports(filepath.Join(pkgPath, filename)) + if err != nil { + return nil, err + } + + for _, imp := range imports { + m[imp] = append(m[imp], filename) + } + } + return m, nil +} + +// getGnoPackageImports returns the list of gno imports from a given path. // Note: It ignores subdirs. Since right now we are still deciding on // how to handle subdirs. // See: @@ -216,7 +333,7 @@ func execModTidy(args []string, io commands.IO) error { // - https://github.com/gnolang/gno/issues/852 // // TODO: move this to better location. -func getGnoImports(path string) ([]string, error) { +func getGnoPackageImports(path string) ([]string, error) { entries, err := os.ReadDir(path) if err != nil { return nil, err @@ -232,28 +349,43 @@ func getGnoImports(path string) ([]string, error) { if strings.HasSuffix(filename, "_filetest.gno") { continue } - data, err := os.ReadFile(filepath.Join(path, filename)) + imports, err := getGnoFileImports(filepath.Join(path, filename)) if err != nil { return nil, err } - fs := token.NewFileSet() - f, err := parser.ParseFile(fs, filename, data, parser.ImportsOnly) - if err != nil { - return nil, err - } - for _, imp := range f.Imports { - importPath := strings.TrimPrefix(strings.TrimSuffix(imp.Path.Value, `"`), `"`) - if !strings.HasPrefix(importPath, "gno.land/") { + for _, im := range imports { + if !strings.HasPrefix(im, "gno.land/") { continue } - if _, ok := seen[importPath]; ok { + if _, ok := seen[im]; ok { continue } - allImports = append(allImports, importPath) - seen[importPath] = struct{}{} + allImports = append(allImports, im) + seen[im] = struct{}{} } } sort.Strings(allImports) return allImports, nil } + +func getGnoFileImports(fname string) ([]string, error) { + if !strings.HasSuffix(fname, ".gno") { + return nil, fmt.Errorf("not a gno file: %q", fname) + } + data, err := os.ReadFile(fname) + if err != nil { + return nil, err + } + fs := token.NewFileSet() + f, err := parser.ParseFile(fs, fname, data, parser.ImportsOnly) + if err != nil { + return nil, err + } + res := make([]string, 0) + for _, im := range f.Imports { + importPath := strings.TrimPrefix(strings.TrimSuffix(im.Path.Value, `"`), `"`) + res = append(res, importPath) + } + return res, nil +} diff --git a/gnovm/cmd/gno/mod_test.go b/gnovm/cmd/gno/mod_test.go index bbf106c8960..789bba61146 100644 --- a/gnovm/cmd/gno/mod_test.go +++ b/gnovm/cmd/gno/mod_test.go @@ -145,7 +145,7 @@ func TestModApp(t *testing.T) { errShouldBe: "create gno.mod file: gno.mod file already exists", }, - // test `gno mod tidy` with module name + // test `gno mod tidy` { args: []string{"mod", "tidy", "arg1"}, testDir: "../../tests/integ/minimalist-gnomod", @@ -185,6 +185,59 @@ func TestModApp(t *testing.T) { simulateExternalRepo: true, errShouldContain: "expected 'package', found packag", }, + + // test `gno mod why` + { + args: []string{"mod", "why"}, + testDir: "../../tests/integ/minimalist-gnomod", + simulateExternalRepo: true, + errShouldContain: "flag: help requested", + }, + { + args: []string{"mod", "why", "std"}, + testDir: "../../tests/integ/empty-dir", + simulateExternalRepo: true, + errShouldContain: "could not read gno.mod file", + }, + { + args: []string{"mod", "why", "std"}, + testDir: "../../tests/integ/invalid-module-version1", + simulateExternalRepo: true, + errShouldContain: "error parsing gno.mod file at", + }, + { + args: []string{"mod", "why", "std"}, + testDir: "../../tests/integ/invalid-gno-file", + simulateExternalRepo: true, + errShouldContain: "expected 'package', found packag", + }, + { + args: []string{"mod", "why", "std"}, + testDir: "../../tests/integ/minimalist-gnomod", + simulateExternalRepo: true, + stdoutShouldBe: `# std +(module minim does not need package std) +`, + }, + { + args: []string{"mod", "why", "std"}, + testDir: "../../tests/integ/require-remote-module", + simulateExternalRepo: true, + stdoutShouldBe: `# std +(module gno.land/tests/importavl does not need package std) +`, + }, + { + args: []string{"mod", "why", "std", "gno.land/p/demo/avl"}, + testDir: "../../tests/integ/valid2", + simulateExternalRepo: true, + stdoutShouldBe: `# std +(module gno.land/p/integ/valid does not need package std) + +# gno.land/p/demo/avl +valid.gno +`, + }, } testMainCaseRun(t, tc) } @@ -297,7 +350,7 @@ func TestGetGnoImports(t *testing.T) { require.NoError(t, err) } - imports, err := getGnoImports(tmpDir) + imports, err := getGnoPackageImports(tmpDir) require.NoError(t, err) require.Equal(t, len(expected), len(imports))