diff --git a/changelog.md b/changelog.md index 658e6c0594..df179f6ffc 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- [#3505](https://github.com/ignite/cli/pull/3505) Auto migrate dependency tools + ### Changes - [#3444](https://github.com/ignite/cli/pull/3444) Add support for ICS chains in ts-client generation diff --git a/ignite/cmd/chain.go b/ignite/cmd/chain.go index 1b022ddc1f..cff8281e7d 100644 --- a/ignite/cmd/chain.go +++ b/ignite/cmd/chain.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "os" + "path/filepath" + "strings" "github.com/manifoldco/promptui" "github.com/spf13/cobra" @@ -13,13 +15,16 @@ import ( "github.com/ignite/cli/ignite/pkg/cliui" "github.com/ignite/cli/ignite/pkg/cliui/colors" "github.com/ignite/cli/ignite/pkg/cliui/icons" + "github.com/ignite/cli/ignite/pkg/cosmosgen" + "github.com/ignite/cli/ignite/pkg/goanalysis" + "github.com/ignite/cli/ignite/pkg/xast" ) const ( msgMigration = "Migrating blockchain config file from v%d to v%d..." - msgMigrationCancel = "Stopping because config version v%d is required to run the command" msgMigrationPrefix = "Your blockchain config version is v%d and the latest is v%d." msgMigrationPrompt = "Would you like to upgrade your config file to v%d" + toolsFile = "tools/tools.go" ) // NewChain returns a command that groups sub commands related to compiling, serving @@ -78,7 +83,7 @@ chain. `, Aliases: []string{"c"}, Args: cobra.ExactArgs(1), - PersistentPreRunE: configMigrationPreRunHandler, + PersistentPreRunE: preRunHandler, } // Add flags required for the configMigrationPreRunHandler @@ -97,10 +102,65 @@ chain. return c } -func configMigrationPreRunHandler(cmd *cobra.Command, _ []string) (err error) { +func preRunHandler(cmd *cobra.Command, _ []string) error { session := cliui.New() defer session.End() + if err := configMigrationPreRunHandler(cmd, session); err != nil { + return err + } + return toolsMigrationPreRunHandler(cmd, session) +} + +func toolsMigrationPreRunHandler(cmd *cobra.Command, session *cliui.Session) (err error) { + session.StartSpinner("Checking missing tools...") + + appPath := flagGetPath(cmd) + toolsFilename := filepath.Join(appPath, toolsFile) + f, _, err := xast.ParseFile(toolsFilename) + if err != nil { + return err + } + + missing := cosmosgen.MissingTools(f) + unused := cosmosgen.UnusedTools(f) + + session.StopSpinner() + if len(missing) > 0 { + question := fmt.Sprintf( + "Some required imports are missing in %s file: %s. Would you like to add them", + toolsFilename, + strings.Join(missing, ", "), + ) + if err := session.AskConfirm(question); err != nil { + missing = []string{} + } + } + + if len(unused) > 0 { + question := fmt.Sprintf( + "File %s contains deprecated imports: %s. Would you like to remove them", + toolsFilename, + strings.Join(unused, ", "), + ) + if err := session.AskConfirm(question); err != nil { + unused = []string{} + } + } + if len(missing) == 0 && len(unused) == 0 { + return nil + } + session.StartSpinner("Migrating tools...") + + var buf bytes.Buffer + if err := goanalysis.UpdateInitImports(f, &buf, missing, unused); err != nil { + return err + } + + return os.WriteFile(toolsFilename, buf.Bytes(), 0o644) +} + +func configMigrationPreRunHandler(cmd *cobra.Command, session *cliui.Session) (err error) { appPath := flagGetPath(cmd) configPath := getConfig(cmd) if configPath == "" { diff --git a/ignite/pkg/cosmosgen/install.go b/ignite/pkg/cosmosgen/install.go index 244ea8bd88..0c459c2ebb 100644 --- a/ignite/pkg/cosmosgen/install.go +++ b/ignite/pkg/cosmosgen/install.go @@ -3,10 +3,13 @@ package cosmosgen import ( "context" "errors" + "go/ast" + "github.com/ignite/cli/ignite/pkg/goanalysis" "github.com/ignite/cli/ignite/pkg/gocmd" ) +// DepTools necessary tools to build and run the chain. func DepTools() []string { return []string{ // the gocosmos plugin. @@ -33,3 +36,42 @@ func InstallDepTools(ctx context.Context, appPath string) error { } return err } + +// MissingTools find missing tools import indo a *ast.File. +func MissingTools(f *ast.File) (missingTools []string) { + imports := make(map[string]string) + for name, imp := range goanalysis.FormatImports(f) { + imports[imp] = name + } + + for _, tool := range DepTools() { + if _, ok := imports[tool]; !ok { + missingTools = append(missingTools, tool) + } + } + return +} + +// UnusedTools find unused tools import indo a *ast.File. +func UnusedTools(f *ast.File) (unusedTools []string) { + unused := []string{ + // regen protoc plugin + "github.com/regen-network/cosmos-proto/protoc-gen-gocosmos", + + // old ignite repo. + "github.com/ignite-hq/cli/ignite/pkg/cmdrunner", + "github.com/ignite-hq/cli/ignite/pkg/cmdrunner/step", + } + + imports := make(map[string]string) + for name, imp := range goanalysis.FormatImports(f) { + imports[imp] = name + } + + for _, tool := range unused { + if _, ok := imports[tool]; ok { + unusedTools = append(unusedTools, tool) + } + } + return +} diff --git a/ignite/pkg/cosmosgen/install_test.go b/ignite/pkg/cosmosgen/install_test.go new file mode 100644 index 0000000000..8158c360c2 --- /dev/null +++ b/ignite/pkg/cosmosgen/install_test.go @@ -0,0 +1,106 @@ +package cosmosgen_test + +import ( + "go/ast" + "go/token" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ignite/cli/ignite/pkg/cosmosgen" +) + +func TestMissingTools(t *testing.T) { + tests := []struct { + name string + astFile *ast.File + want []string + }{ + { + name: "no missing tools", + astFile: createASTFileWithImports(cosmosgen.DepTools()...), + want: nil, + }, + { + name: "some missing tools", + astFile: createASTFileWithImports( + "github.com/golang/protobuf/protoc-gen-go", + "github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway", + "github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger", + ), + want: []string{ + "github.com/cosmos/gogoproto/protoc-gen-gocosmos", + "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2", + }, + }, + { + name: "all tools missing", + astFile: createASTFileWithImports(), + want: cosmosgen.DepTools(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cosmosgen.MissingTools(tt.astFile) + require.EqualValues(t, tt.want, got) + }) + } +} + +func TestUnusedTools(t *testing.T) { + tests := []struct { + name string + astFile *ast.File + want []string + }{ + { + name: "all unused tools", + astFile: createASTFileWithImports( + "fmt", + "github.com/regen-network/cosmos-proto/protoc-gen-gocosmos", + "github.com/ignite-hq/cli/ignite/pkg/cmdrunner", + "github.com/ignite-hq/cli/ignite/pkg/cmdrunner/step", + ), + want: []string{ + "github.com/regen-network/cosmos-proto/protoc-gen-gocosmos", + "github.com/ignite-hq/cli/ignite/pkg/cmdrunner", + "github.com/ignite-hq/cli/ignite/pkg/cmdrunner/step", + }, + }, + { + name: "some unused tools", + astFile: createASTFileWithImports( + "fmt", + "github.com/ignite-hq/cli/ignite/pkg/cmdrunner", + ), + want: []string{"github.com/ignite-hq/cli/ignite/pkg/cmdrunner"}, + }, + { + name: "no tools unused", + astFile: createASTFileWithImports("fmt"), + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cosmosgen.UnusedTools(tt.astFile) + require.EqualValues(t, tt.want, got) + }) + } +} + +// createASTFileWithImports helper function to create an AST file with given imports. +func createASTFileWithImports(imports ...string) *ast.File { + f := &ast.File{Imports: make([]*ast.ImportSpec, len(imports))} + for i, imp := range imports { + f.Imports[i] = &ast.ImportSpec{ + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: strconv.Quote(imp), + }, + } + } + return f +} diff --git a/ignite/pkg/goanalysis/goanalysis.go b/ignite/pkg/goanalysis/goanalysis.go index 18af2fc939..89631f6d3d 100644 --- a/ignite/pkg/goanalysis/goanalysis.go +++ b/ignite/pkg/goanalysis/goanalysis.go @@ -5,16 +5,20 @@ import ( "errors" "fmt" "go/ast" + "go/format" "go/parser" "go/token" + "io" "os" "path/filepath" + "strconv" "strings" ) const ( mainPackage = "main" goFileExtension = ".go" + toolsBuildTag = "//go:build tools\n\n" ) // ErrMultipleMainPackagesFound is returned when multiple main packages found while expecting only one. @@ -199,7 +203,7 @@ func FormatImports(f *ast.File) map[string]string { m := make(map[string]string) // name -> import for _, imp := range f.Imports { var importName string - if imp.Name != nil { + if imp.Name != nil && imp.Name.Name != "_" && imp.Name.Name != "." { importName = imp.Name.Name } else { importParts := strings.Split(imp.Path.Value, "/") @@ -211,3 +215,55 @@ func FormatImports(f *ast.File) map[string]string { } return m } + +// UpdateInitImports helper function to remove and add underscore (init) imports to an *ast.File. +func UpdateInitImports(file *ast.File, writer io.Writer, importsToAdd, importsToRemove []string) error { + // Create a map for faster lookup of items to remove + importMap := make(map[string]bool) + for _, astImport := range file.Imports { + value, err := strconv.Unquote(astImport.Path.Value) + if err != nil { + return err + } + importMap[value] = true + } + for _, removeImport := range importsToRemove { + importMap[removeImport] = false + } + for _, addImport := range importsToAdd { + importMap[addImport] = true + } + + // Add the imports + for _, d := range file.Decls { + if dd, ok := d.(*ast.GenDecl); ok { + if dd.Tok == token.IMPORT { + file.Imports = make([]*ast.ImportSpec, 0) + dd.Specs = make([]ast.Spec, 0) + for imp, exist := range importMap { + if exist { + spec := createUnderscoreImport(imp) + file.Imports = append(file.Imports, spec) + dd.Specs = append(dd.Specs, spec) + } + } + } + } + } + + if _, err := writer.Write([]byte(toolsBuildTag)); err != nil { + return fmt.Errorf("failed to write the build tag: %w", err) + } + return format.Node(writer, token.NewFileSet(), file) +} + +// createUnderscoreImports helper function to create an AST underscore import with given path. +func createUnderscoreImport(imp string) *ast.ImportSpec { + return &ast.ImportSpec{ + Name: ast.NewIdent("_"), + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: strconv.Quote(imp), + }, + } +} diff --git a/ignite/pkg/goanalysis/goanalysis_test.go b/ignite/pkg/goanalysis/goanalysis_test.go index 38462382ac..556ff7fde8 100644 --- a/ignite/pkg/goanalysis/goanalysis_test.go +++ b/ignite/pkg/goanalysis/goanalysis_test.go @@ -1,12 +1,18 @@ package goanalysis_test import ( + "bytes" "errors" + "go/ast" + "go/parser" + "go/token" "os" "path/filepath" + "sort" "testing" "github.com/stretchr/testify/require" + "golang.org/x/tools/go/ast/astutil" "github.com/ignite/cli/ignite/pkg/goanalysis" "github.com/ignite/cli/ignite/pkg/xast" @@ -231,3 +237,245 @@ func TestFuncVarExists(t *testing.T) { }) } } + +func TestFormatImports(t *testing.T) { + tests := []struct { + name string + input *ast.File + want map[string]string + }{ + { + name: "Test one import", + input: &ast.File{ + Imports: []*ast.ImportSpec{ + { + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: "\"fmt\"", + }, + }, + }, + }, + want: map[string]string{ + "fmt": "fmt", + }, + }, + { + name: "Test underscore import", + input: &ast.File{ + Imports: []*ast.ImportSpec{ + { + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: "\"net/http\"", + }, + }, + { + Name: &ast.Ident{ + Name: "_", + }, + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: "\"github.com/example/pkg\"", + }, + }, + }, + }, + want: map[string]string{ + "http": "net/http", + "pkg": "github.com/example/pkg", + }, + }, + { + name: "Test dot import", + input: &ast.File{ + Imports: []*ast.ImportSpec{ + { + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: "\"net/http\"", + }, + }, + { + Name: &ast.Ident{ + Name: ".", + }, + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: "\"github.com/example/pkg\"", + }, + }, + { + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: "\"fmt\"", + }, + }, + }, + }, + want: map[string]string{ + "http": "net/http", + "pkg": "github.com/example/pkg", + "fmt": "fmt", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, goanalysis.FormatImports(tt.input)) + }) + } +} + +func TestUpdateInitImports(t *testing.T) { + type args struct { + fileImports []string + importsToAdd []string + importsToRemove []string + } + tests := []struct { + name string + args args + want []string + err error + }{ + { + name: "test one import to add", + args: args{ + fileImports: []string{"fmt"}, + importsToAdd: []string{"net/http"}, + }, + want: []string{"fmt", "net/http"}, + }, + { + name: "test one import to remove", + args: args{ + fileImports: []string{"fmt", "net/http"}, + importsToRemove: []string{"net/http"}, + }, + want: []string{"fmt"}, + }, + { + name: "test one import to add and remove", + args: args{ + fileImports: []string{"fmt"}, + importsToAdd: []string{"net/http"}, + importsToRemove: []string{"fmt"}, + }, + want: []string{"net/http"}, + }, + { + name: "test many imports", + args: args{ + fileImports: []string{ + "errors", + "github.com/stretchr/testify/require", + "go/ast", + "go/parser", + "go/token", + "os", + "path/filepath", + "testing", + }, + importsToAdd: []string{"net/http", "errors"}, + importsToRemove: []string{"go/parser", "path/filepath", "testing"}, + }, + want: []string{ + "errors", + "net/http", + "os", + "go/ast", + "go/token", + "github.com/stretchr/testify/require", + }, + }, + { + name: "test add and remove same imports already exist", + args: args{ + fileImports: []string{ + "errors", + "go/ast", + }, + importsToAdd: []string{ + "errors", + "go/ast", + }, + importsToRemove: []string{ + "errors", + "go/ast", + }, + }, + want: []string{ + "errors", + "go/ast", + }, + }, + { + name: "test add and remove same imports", + args: args{ + fileImports: []string{}, + importsToAdd: []string{ + "errors", + "go/ast", + }, + importsToRemove: []string{ + "errors", + "go/ast", + }, + }, + want: []string{}, + }, + { + name: "test remove not exist import", + args: args{ + fileImports: []string{ + "errors", + "go/ast", + }, + importsToAdd: []string{}, + importsToRemove: []string{ + "fmt", + }, + }, + want: []string{ + "errors", + "go/ast", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a sample *ast.File + file := &ast.File{ + Name: ast.NewIdent("main"), + Imports: []*ast.ImportSpec{}, + } + fset := token.NewFileSet() + for _, imp := range tt.args.fileImports { + require.Truef(t, astutil.AddImport(fset, file, imp), "import %s cannot be added", imp) + } + + // test method + var buf bytes.Buffer + err := goanalysis.UpdateInitImports(file, &buf, tt.args.importsToAdd, tt.args.importsToRemove) + if tt.err != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.err) + return + } + require.NoError(t, err) + + gotFile, err := parser.ParseFile(token.NewFileSet(), "", buf.Bytes(), parser.ParseComments) + require.NoError(t, err) + + gotImports := make([]string, 0) + for _, imp := range goanalysis.FormatImports(gotFile) { + gotImports = append(gotImports, imp) + } + sort.Strings(tt.want) + sort.Strings(gotImports) + require.EqualValues(t, tt.want, gotImports) + }) + } +}