Skip to content

Commit

Permalink
feat: implement gno mod why (gnolang#1407)
Browse files Browse the repository at this point in the history
`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`?"
  • Loading branch information
harry-hov authored and leohhhn committed Feb 29, 2024
1 parent 715a487 commit 640b3ee
Show file tree
Hide file tree
Showing 4 changed files with 204 additions and 17 deletions.
1 change: 1 addition & 0 deletions docs/concepts/gno-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/reference/go-gno-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
162 changes: 147 additions & 15 deletions gnovm/cmd/gno/mod.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func newModCmd(io commands.IO) *commands.Command {
newModDownloadCmd(io),
newModInitCmd(),
newModTidy(io),
newModWhy(io),
)

return cmd
Expand Down Expand Up @@ -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 <package> [<package>...]",
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,
Expand Down Expand Up @@ -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
}
Expand All @@ -208,15 +247,93 @@ 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:
// - https://github.com/gnolang/gno/issues/1024
// - 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
Expand All @@ -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
}
57 changes: 55 additions & 2 deletions gnovm/cmd/gno/mod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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))
Expand Down

0 comments on commit 640b3ee

Please sign in to comment.