From d2c383861e01302fb8cee1376d02c1c20fb65eea Mon Sep 17 00:00:00 2001 From: Guilhem Fanton <8671905+gfanton@users.noreply.github.com> Date: Mon, 10 Feb 2025 16:24:31 +0100 Subject: [PATCH] feat(gnodev): lazy loading & staging support (#3237) featuring: - [X] **Lazy reload**: Super fast reload, only loading the needed packages for the current directory package. - [x] **Chain resolver**: You can change the gnodev resolving process so it can resolve packages on-chain and configure a local fallback, allowing you to try your package against the chain before submitting it. - This will probably be updated when #2932 & #3123 will be merged - [x] **Staging Mode**: `gnodev` now starts from the current directory by default. The staging subcommand will reproduce previous behaviors by loading and monitoring the entire example folder.
Contributors' checklist... - [ ] Added new tests, or not needed, or not feasible - [ ] Provided an example (e.g. screenshot) to aid review or the PR is self-explanatory - [ ] Updated the official documentation or not needed - [ ] No breaking changes were made, or a `BREAKING CHANGE: xxx` message was included in the description - [ ] Added references to related issues and PRs - [ ] Provided any useful hints for running manual tests
--------- Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> Co-authored-by: Leon Hudak <33522493+leohhhn@users.noreply.github.com> Co-authored-by: Morgan Co-authored-by: Morgan --- contribs/gnodev/cmd/gnodev/accounts.go | 2 +- contribs/gnodev/cmd/gnodev/app.go | 582 +++++++++++++++++ contribs/gnodev/cmd/gnodev/app_config.go | 237 +++++++ contribs/gnodev/cmd/gnodev/command_local.go | 114 ++++ contribs/gnodev/cmd/gnodev/command_staging.go | 74 +++ contribs/gnodev/cmd/gnodev/logger.go | 54 +- contribs/gnodev/cmd/gnodev/main.go | 586 +----------------- contribs/gnodev/cmd/gnodev/path_manager.go | 45 ++ .../gnodev/cmd/gnodev/setup_address_book.go | 14 +- contribs/gnodev/cmd/gnodev/setup_loader.go | 107 ++++ contribs/gnodev/cmd/gnodev/setup_node.go | 48 +- contribs/gnodev/cmd/gnodev/setup_term.go | 4 +- contribs/gnodev/cmd/gnodev/setup_web.go | 15 +- contribs/gnodev/go.mod | 2 +- .../mock/{ => emitter}/server_emitter.go | 0 contribs/gnodev/pkg/dev/node.go | 297 +++++---- contribs/gnodev/pkg/dev/node_state.go | 8 +- contribs/gnodev/pkg/dev/node_state_test.go | 26 +- contribs/gnodev/pkg/dev/node_test.go | 269 ++++---- contribs/gnodev/pkg/dev/packages.go | 170 ----- contribs/gnodev/pkg/dev/packages_test.go | 103 --- contribs/gnodev/pkg/dev/query_path.go | 58 ++ contribs/gnodev/pkg/dev/query_path_test.go | 132 ++++ contribs/gnodev/pkg/emitter/server.go | 25 +- .../gnodev/pkg/emitter/static/hotreload.js | 37 +- contribs/gnodev/pkg/logger/log_column.go | 24 +- contribs/gnodev/pkg/packages/glob.go | 214 +++++++ contribs/gnodev/pkg/packages/glob_test.go | 93 +++ contribs/gnodev/pkg/packages/loader.go | 12 + contribs/gnodev/pkg/packages/loader_base.go | 104 ++++ contribs/gnodev/pkg/packages/loader_glob.go | 94 +++ contribs/gnodev/pkg/packages/loader_test.go | 83 +++ contribs/gnodev/pkg/packages/package.go | 102 +++ contribs/gnodev/pkg/packages/resolver.go | 234 +++++++ .../gnodev/pkg/packages/resolver_local.go | 39 ++ contribs/gnodev/pkg/packages/resolver_mock.go | 40 ++ .../gnodev/pkg/packages/resolver_remote.go | 94 +++ .../pkg/packages/resolver_remote_test.go | 1 + contribs/gnodev/pkg/packages/resolver_root.go | 30 + contribs/gnodev/pkg/packages/resolver_test.go | 290 +++++++++ .../testdata/abc.xy/nested/aa/file.gno | 1 + .../testdata/abc.xy/nested/aa/gno.mod | 1 + .../testdata/abc.xy/nested/nested/bb/file.gno | 1 + .../testdata/abc.xy/nested/nested/bb/gno.mod | 1 + .../testdata/abc.xy/nested/nested/cc/file.gno | 1 + .../testdata/abc.xy/nested/nested/cc/gno.mod | 1 + .../packages/testdata/abc.xy/pkg/aa/file.gno | 3 + .../packages/testdata/abc.xy/pkg/aa/gno.mod | 1 + .../packages/testdata/abc.xy/pkg/bb/file.gno | 5 + .../packages/testdata/abc.xy/pkg/bb/gno.mod | 1 + .../packages/testdata/abc.xy/pkg/cc/file.gno | 5 + .../packages/testdata/abc.xy/pkg/cc/gno.mod | 1 + contribs/gnodev/pkg/packages/testdata_test.go | 44 ++ contribs/gnodev/pkg/packages/utils.go | 14 + contribs/gnodev/pkg/proxy/path_interceptor.go | 330 ++++++++++ .../gnodev/pkg/proxy/path_interceptor_test.go | 179 ++++++ contribs/gnodev/pkg/rawterm/keypress.go | 14 + contribs/gnodev/pkg/rawterm/rawterm.go | 25 +- contribs/gnodev/pkg/watcher/watch.go | 66 +- gno.land/pkg/gnoweb/handler.go | 1 + gno.land/pkg/integration/node_testing.go | 29 + gno.land/pkg/integration/node_testing_test.go | 75 +++ gno.land/pkg/keyscli/run.go | 3 +- gnovm/memfile.go | 2 +- gnovm/pkg/gnolang/nodes.go | 3 +- tm2/pkg/commands/command.go | 60 +- 66 files changed, 4097 insertions(+), 1233 deletions(-) create mode 100644 contribs/gnodev/cmd/gnodev/app.go create mode 100644 contribs/gnodev/cmd/gnodev/app_config.go create mode 100644 contribs/gnodev/cmd/gnodev/command_local.go create mode 100644 contribs/gnodev/cmd/gnodev/command_staging.go create mode 100644 contribs/gnodev/cmd/gnodev/path_manager.go create mode 100644 contribs/gnodev/cmd/gnodev/setup_loader.go rename contribs/gnodev/internal/mock/{ => emitter}/server_emitter.go (100%) delete mode 100644 contribs/gnodev/pkg/dev/packages.go delete mode 100644 contribs/gnodev/pkg/dev/packages_test.go create mode 100644 contribs/gnodev/pkg/dev/query_path.go create mode 100644 contribs/gnodev/pkg/dev/query_path_test.go create mode 100644 contribs/gnodev/pkg/packages/glob.go create mode 100644 contribs/gnodev/pkg/packages/glob_test.go create mode 100644 contribs/gnodev/pkg/packages/loader.go create mode 100644 contribs/gnodev/pkg/packages/loader_base.go create mode 100644 contribs/gnodev/pkg/packages/loader_glob.go create mode 100644 contribs/gnodev/pkg/packages/loader_test.go create mode 100644 contribs/gnodev/pkg/packages/package.go create mode 100644 contribs/gnodev/pkg/packages/resolver.go create mode 100644 contribs/gnodev/pkg/packages/resolver_local.go create mode 100644 contribs/gnodev/pkg/packages/resolver_mock.go create mode 100644 contribs/gnodev/pkg/packages/resolver_remote.go create mode 100644 contribs/gnodev/pkg/packages/resolver_remote_test.go create mode 100644 contribs/gnodev/pkg/packages/resolver_root.go create mode 100644 contribs/gnodev/pkg/packages/resolver_test.go create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/nested/aa/file.gno create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/nested/aa/gno.mod create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/bb/file.gno create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/bb/gno.mod create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/cc/file.gno create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/cc/gno.mod create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/aa/file.gno create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/aa/gno.mod create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/bb/file.gno create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/bb/gno.mod create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/cc/file.gno create mode 100644 contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/cc/gno.mod create mode 100644 contribs/gnodev/pkg/packages/testdata_test.go create mode 100644 contribs/gnodev/pkg/packages/utils.go create mode 100644 contribs/gnodev/pkg/proxy/path_interceptor.go create mode 100644 contribs/gnodev/pkg/proxy/path_interceptor_test.go create mode 100644 gno.land/pkg/integration/node_testing_test.go diff --git a/contribs/gnodev/cmd/gnodev/accounts.go b/contribs/gnodev/cmd/gnodev/accounts.go index 95c2c3efffc..e148f4827c1 100644 --- a/contribs/gnodev/cmd/gnodev/accounts.go +++ b/contribs/gnodev/cmd/gnodev/accounts.go @@ -49,7 +49,7 @@ func (va varPremineAccounts) String() string { return strings.Join(accs, ",") } -func generateBalances(bk *address.Book, cfg *devCfg) (gnoland.Balances, error) { +func generateBalances(bk *address.Book, cfg *AppConfig) (gnoland.Balances, error) { bls := gnoland.NewBalances() premineBalance := std.Coins{std.NewCoin(ugnot.Denom, 10e12)} diff --git a/contribs/gnodev/cmd/gnodev/app.go b/contribs/gnodev/cmd/gnodev/app.go new file mode 100644 index 00000000000..5744be8d0b4 --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/app.go @@ -0,0 +1,582 @@ +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/gnolang/gno/contribs/gnodev/pkg/address" + gnodev "github.com/gnolang/gno/contribs/gnodev/pkg/dev" + "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" + "github.com/gnolang/gno/contribs/gnodev/pkg/packages" + "github.com/gnolang/gno/contribs/gnodev/pkg/proxy" + "github.com/gnolang/gno/contribs/gnodev/pkg/rawterm" + "github.com/gnolang/gno/contribs/gnodev/pkg/watcher" + "github.com/gnolang/gno/gno.land/pkg/integration" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/crypto" + osm "github.com/gnolang/gno/tm2/pkg/os" +) + +const ( + DefaultDeployerName = integration.DefaultAccount_Name + DefaultDeployerSeed = integration.DefaultAccount_Seed +) + +var defaultDeployerAddress = crypto.MustAddressFromString(integration.DefaultAccount_Address) + +const ( + NodeLogName = "Node" + WebLogName = "GnoWeb" + KeyPressLogName = "KeyPress" + EventServerLogName = "Event" + AccountsLogName = "Accounts" + LoaderLogName = "Loader" + ProxyLogName = "Proxy" +) + +type App struct { + io commands.IO + start time.Time // Time when the server started + cfg *AppConfig + logger *slog.Logger + pathManager *pathManager + // Contains all the deferred functions of the app. + // Will be triggered on close for cleanup. + deferred func() + + webHomePath string + paths []string + devNode *gnodev.Node + emitterServer *emitter.Server + watcher *watcher.PackageWatcher + loader packages.Loader + book *address.Book + exportPath string + proxy *proxy.PathInterceptor + + // XXX: move this + exported uint +} + +func runApp(cfg *AppConfig, cio commands.IO, dirs ...string) (err error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var rt *rawterm.RawTerm + var out io.Writer + if cfg.interactive { + var restore func() error + rt, restore, err = setupRawTerm(cfg, cio) + if err != nil { + return fmt.Errorf("unable to init raw term: %w", err) + } + defer restore() + + osm.TrapSignal(func() { + cancel() + restore() + }) + + out = rt + } else { + osm.TrapSignal(cancel) + out = cio.Out() + } + + logger, err := setuplogger(cfg, out) + if err != nil { + return fmt.Errorf("unable to setup logger: %w", err) + } + + app := NewApp(logger, cfg, cio) + if err := app.Setup(ctx, dirs...); err != nil { + return err + } + defer app.Close() + + if rt != nil { + go func() { + app.RunInteractive(ctx, rt) + cancel() + }() + } + + return app.RunServer(ctx, rt) +} + +func NewApp(logger *slog.Logger, cfg *AppConfig, io commands.IO) *App { + return &App{ + start: time.Now(), + deferred: func() {}, + logger: logger, + cfg: cfg, + io: io, + pathManager: newPathManager(), + } +} + +func (ds *App) Defer(fn func()) { + old := ds.deferred + ds.deferred = func() { + defer old() + fn() + } +} + +func (ds *App) DeferClose(fn func() error) { + ds.Defer(func() { + if err := fn(); err != nil { + ds.logger.Debug("close", "error", err.Error()) + } + }) +} + +func (ds *App) Close() { + ds.deferred() +} + +func (ds *App) Setup(ctx context.Context, dirs ...string) (err error) { + if err := ds.cfg.validateConfigFlags(); err != nil { + return fmt.Errorf("validate error: %w", err) + } + + loggerEvents := ds.logger.WithGroup(EventServerLogName) + ds.emitterServer = emitter.NewServer(loggerEvents) + + // XXX: it would be nice to not have this hardcoded + examplesDir := filepath.Join(ds.cfg.root, "examples") + + // Setup loader and resolver + loaderLogger := ds.logger.WithGroup(LoaderLogName) + resolver, localPaths := setupPackagesResolver(loaderLogger, ds.cfg, dirs...) + ds.loader = packages.NewGlobLoader(examplesDir, resolver) + + // Get user's address book from local keybase + accountLogger := ds.logger.WithGroup(AccountsLogName) + ds.book, err = setupAddressBook(accountLogger, ds.cfg) + if err != nil { + return fmt.Errorf("unable to load keybase: %w", err) + } + + // Generate user's paths using a comma as the delimiter + qpaths := strings.Split(ds.cfg.paths, ",") + + // Set up the packages modifier and extract paths from queries + // XXX: This should probably be moved into the setup node configuration + modifiers, paths, err := resolvePackagesModifier(ds.cfg, ds.book, qpaths) + if err != nil { + return fmt.Errorf("unable to resolve paths %v: %w", paths, err) + } + + // Add the user's paths to the pre-loaded paths + // Modifiers will be added later to the node config bellow + ds.paths = append(paths, localPaths...) + + // Setup default web home realm, fallback on first local path + switch webHome := ds.cfg.webHome; webHome { + case "": + if len(ds.paths) > 0 { + ds.webHomePath = strings.TrimPrefix(ds.paths[0], ds.cfg.chainDomain) + ds.logger.WithGroup(WebLogName).Info("using default package", "path", ds.paths[0]) + } + case "/", ":none:": // skip + default: + ds.webHomePath = webHome + } + + balances, err := generateBalances(ds.book, ds.cfg) + if err != nil { + return fmt.Errorf("unable to generate balances: %w", err) + } + ds.logger.Debug("balances loaded", "list", balances.List()) + + nodeLogger := ds.logger.WithGroup(NodeLogName) + nodeCfg := setupDevNodeConfig(ds.cfg, nodeLogger, ds.emitterServer, balances, ds.loader) + nodeCfg.PackagesModifier = modifiers // add modifiers + + address := resolveUnixOrTCPAddr(nodeCfg.TMConfig.RPC.ListenAddress) + + // Setup lazy proxy + if ds.cfg.lazyLoader { + proxyLogger := ds.logger.WithGroup(ProxyLogName) + ds.proxy, err = proxy.NewPathInterceptor(proxyLogger, address) + if err != nil { + return fmt.Errorf("unable to setup proxy: %w", err) + } + ds.DeferClose(ds.proxy.Close) + + // Override current rpc listener + nodeCfg.TMConfig.RPC.ListenAddress = ds.proxy.ProxyAddress() + proxyLogger.Debug("proxy started", + "proxy_addr", ds.proxy.ProxyAddress(), + "target_addr", ds.proxy.TargetAddress(), + ) + + proxyLogger.Info("lazy loading is enabled. packages will be loaded only upon a request via a query or transaction.", "loader", ds.loader.Name()) + } else { + nodeCfg.TMConfig.RPC.ListenAddress = fmt.Sprintf("%s://%s", address.Network(), address.String()) + } + + ds.devNode, err = setupDevNode(ctx, ds.cfg, nodeCfg, ds.paths...) + if err != nil { + return err + } + ds.DeferClose(ds.devNode.Close) + + ds.watcher, err = watcher.NewPackageWatcher(loggerEvents, ds.emitterServer) + if err != nil { + return fmt.Errorf("unable to setup packages watcher: %w", err) + } + + ds.watcher.UpdatePackagesWatch(ds.devNode.ListPkgs()...) + + return nil +} + +func (ds *App) setupHandlers(ctx context.Context) (http.Handler, error) { + mux := http.NewServeMux() + remote := ds.devNode.GetRemoteAddress() + + if ds.proxy != nil { + proxyLogger := ds.logger.WithGroup(ProxyLogName) + remote = ds.proxy.TargetAddress() // update remote address with proxy target address + + // Generate initial paths + initPaths := map[string]struct{}{} + for _, pkg := range ds.devNode.ListPkgs() { + initPaths[pkg.Path] = struct{}{} + } + + ds.proxy.HandlePath(func(paths ...string) { + newPath := false + for _, path := range paths { + // Check if the path is an initial path. + if _, ok := initPaths[path]; ok { + continue + } + + // Try to resolve the path first. + // If we are unable to resolve it, ignore and continue + + if _, err := ds.loader.Resolve(path); err != nil { + proxyLogger.Debug("unable to resolve path", + "error", err, + "path", path) + continue + } + + // If we already know this path, continue. + if exist := ds.pathManager.Save(path); exist { + continue + } + + proxyLogger.Info("new monitored path", + "path", path) + + newPath = true + } + + if !newPath { + return + } + + ds.emitterServer.LockEmit() + defer ds.emitterServer.UnlockEmit() + + ds.devNode.SetPackagePaths(ds.paths...) + ds.devNode.AddPackagePaths(ds.pathManager.List()...) + + // Check if the node needs to be reloaded + // XXX: This part can likely be optimized if we believe + // it significantly impacts performance. + for _, path := range paths { + if ds.devNode.HasPackageLoaded(path) { + continue + } + + ds.logger.WithGroup(NodeLogName).Debug("some paths aren't loaded yet", "path", path) + + // If the package isn't loaded, attempt to reload the node + if err := ds.devNode.Reload(ctx); err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to reload node", "err", err) + } + + // Update the watcher list with the currently loaded packages + ds.watcher.UpdatePackagesWatch(ds.devNode.ListPkgs()...) + + // Reloading the node once is sufficient, so exit the loop + return + } + + ds.logger.WithGroup(NodeLogName).Debug("paths already loaded, skipping reload", "paths", paths) + }) + } + + // Setup gnoweb + webhandler, err := setupGnoWebServer(ds.logger.WithGroup(WebLogName), ds.cfg, remote) + if err != nil { + return nil, fmt.Errorf("unable to setup gnoweb server: %w", err) + } + + if ds.webHomePath != "" { + serveWeb := webhandler.ServeHTTP + webhandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "" || r.URL.Path == "/" { + http.Redirect(w, r, ds.webHomePath, http.StatusFound) + } else { + serveWeb(w, r) + } + }) + } + + // Setup unsafe API + if ds.cfg.unsafeAPI { + mux.HandleFunc("/reset", func(res http.ResponseWriter, req *http.Request) { + if err := ds.devNode.Reset(req.Context()); err != nil { + ds.logger.Error("failed to reset", slog.Any("err", err)) + res.WriteHeader(http.StatusInternalServerError) + } + }) + + mux.HandleFunc("/reload", func(res http.ResponseWriter, req *http.Request) { + if err := ds.devNode.Reload(req.Context()); err != nil { + ds.logger.Error("failed to reload", slog.Any("err", err)) + res.WriteHeader(http.StatusInternalServerError) + } + }) + } + + if !ds.cfg.noWatch { + evtstarget := fmt.Sprintf("%s/_events", ds.cfg.webListenerAddr) + mux.Handle("/_events", ds.emitterServer) + mux.Handle("/", emitter.NewMiddleware(evtstarget, webhandler)) + } else { + mux.Handle("/", webhandler) + } + + return mux, nil +} + +func (ds *App) RunServer(ctx context.Context, term *rawterm.RawTerm) error { + ctx, cancelWith := context.WithCancelCause(ctx) + defer cancelWith(nil) + + addr := ds.cfg.webListenerAddr + handlers, err := ds.setupHandlers(ctx) + if err != nil { + return fmt.Errorf("unable to setup handlers: %w", err) + } + + server := &http.Server{ + Handler: handlers, + Addr: addr, + ReadHeaderTimeout: 60 * time.Second, + } + + // Serve gnoweb + if !ds.cfg.noWeb { + go func() { + err := server.ListenAndServe() + cancelWith(err) + }() + + ds.logger.WithGroup(WebLogName).Info("gnoweb started", + "lisn", fmt.Sprintf("http://%s", addr)) + } + + if ds.cfg.interactive { + ds.logger.WithGroup("--- READY").Info("for commands and help, press `h`", "took", time.Since(ds.start)) + } else { + ds.logger.Info("node is ready", "took", time.Since(ds.start)) + } + + for { + select { + case <-ctx.Done(): + return context.Cause(ctx) + case _, ok := <-ds.watcher.PackagesUpdate: + if !ok { + return nil + } + + ds.logger.WithGroup(NodeLogName).Info("reloading...") + if err := ds.devNode.Reload(ctx); err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to reload node", "err", err) + } + ds.watcher.UpdatePackagesWatch(ds.devNode.ListPkgs()...) + } + } +} + +func (ds *App) RunInteractive(ctx context.Context, term *rawterm.RawTerm) { + ds.logger.WithGroup(KeyPressLogName).Debug("starting interactive mode") + var keyPressCh <-chan rawterm.KeyPress + if ds.cfg.interactive { + keyPressCh = listenForKeyPress(ds.logger.WithGroup(KeyPressLogName), term) + } + + for { + select { + case <-ctx.Done(): + return + case key, ok := <-keyPressCh: + ds.logger.WithGroup(KeyPressLogName).Debug("pressed", "key", key.String()) + if !ok { + return + } + + if key == rawterm.KeyCtrlC { + return + } + + ds.handleKeyPress(ctx, key) + keyPressCh = listenForKeyPress(ds.logger.WithGroup(KeyPressLogName), term) + } + } +} + +var helper string = `For more in-depth documentation, visit the GNO Tooling CLI documentation: +https://docs.gno.land/gno-tooling/cli/gno-tooling-gnodev + +P Previous TX - Go to the previous tx +N Next TX - Go to the next tx +E Export - Export the current state as genesis doc +A Accounts - Display known accounts and balances +H Help - Display this message +R Reload - Reload all packages to take change into account. +Ctrl+S Save State - Save the current state +Ctrl+R Reset - Reset application to it's initial/save state. +Ctrl+C Exit - Exit the application +` + +func (ds *App) handleKeyPress(ctx context.Context, key rawterm.KeyPress) { + var err error + + switch key.Upper() { + case rawterm.KeyH: // Helper + ds.logger.Info("Gno Dev Helper", "helper", helper) + + case rawterm.KeyA: // Accounts + logAccounts(ds.logger.WithGroup(AccountsLogName), ds.book, ds.devNode) + + case rawterm.KeyR: // Reload + ds.logger.WithGroup(NodeLogName).Info("reloading...") + if err = ds.devNode.ReloadAll(ctx); err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to reload node", "err", err) + } + + case rawterm.KeyCtrlR: // Reset + ds.logger.WithGroup(NodeLogName).Info("resetting node state...") + // Reset paths + ds.pathManager.Reset() + ds.devNode.SetPackagePaths(ds.paths...) + // Reset the node + if err = ds.devNode.Reset(ctx); err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to reset node state", "err", err) + } + + case rawterm.KeyCtrlS: // Save + ds.logger.WithGroup(NodeLogName).Info("saving state...") + if err := ds.devNode.SaveCurrentState(ctx); err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to save node state", "err", err) + } + + case rawterm.KeyE: // Export + // Create a temporary export dir + if ds.exported == 0 { + ds.exportPath, err = os.MkdirTemp("", "gnodev-export") + if err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to create `export` directory", "err", err) + return + } + } + ds.exported++ + + ds.logger.WithGroup(NodeLogName).Info("exporting state...") + doc, err := ds.devNode.ExportStateAsGenesis(ctx) + if err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to export node state", "err", err) + return + } + + docfile := filepath.Join(ds.exportPath, fmt.Sprintf("export_%d.jsonl", ds.exported)) + if err := doc.SaveAs(docfile); err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to save genesis", "err", err) + } + + ds.logger.WithGroup(NodeLogName).Info("node state exported", "file", docfile) + + case rawterm.KeyN: // Next tx + ds.logger.Info("moving forward...") + if err := ds.devNode.MoveToNextTX(ctx); err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to move forward", "err", err) + } + + case rawterm.KeyP: // Previous tx + ds.logger.Info("moving backward...") + if err := ds.devNode.MoveToPreviousTX(ctx); err != nil { + ds.logger.WithGroup(NodeLogName).Error("unable to move backward", "err", err) + } + default: + } +} + +// XXX: packages modifier does not support glob yet +func resolvePackagesModifier(cfg *AppConfig, bk *address.Book, qpaths []string) ([]gnodev.QueryPath, []string, error) { + if cfg.deployKey == "" { + return nil, nil, fmt.Errorf("default deploy key cannot be empty") + } + + defaultKey, _, ok := bk.GetFromNameOrAddress(cfg.deployKey) + if !ok { + return nil, nil, fmt.Errorf("unable to get deploy key %q", cfg.deployKey) + } + + modifiers := make([]gnodev.QueryPath, 0, len(qpaths)) + paths := make([]string, 0, len(qpaths)) + + for _, path := range qpaths { + if path == "" { + continue + } + + qpath, err := gnodev.ResolveQueryPath(bk, path) + if err != nil { + return nil, nil, fmt.Errorf("invalid package path/query %q: %w", path, err) + } + + // Assign a default creator if user haven't specified it. + if qpath.Creator.IsZero() { + qpath.Creator = defaultKey + } + + modifiers = append(modifiers, qpath) + paths = append(paths, qpath.Path) + } + + return slices.Clip(modifiers), slices.Clip(paths), nil +} + +func listenForKeyPress(logger *slog.Logger, rt *rawterm.RawTerm) <-chan rawterm.KeyPress { + cc := make(chan rawterm.KeyPress, 1) + go func() { + defer close(cc) + key, err := rt.ReadKeyPress() + if err != nil { + logger.Error("unable to read keypress", "err", err) + return + } + + cc <- key + }() + + return cc +} diff --git a/contribs/gnodev/cmd/gnodev/app_config.go b/contribs/gnodev/cmd/gnodev/app_config.go new file mode 100644 index 00000000000..07231f24f9b --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/app_config.go @@ -0,0 +1,237 @@ +package main + +import "flag" + +type AppConfig struct { + // Listeners + nodeRPCListenerAddr string + nodeP2PListenerAddr string + nodeProxyAppListenerAddr string + + // Users default + deployKey string + home string + root string + premineAccounts varPremineAccounts + + // Files + balancesFile string + genesisFile string + txsFile string + + // Web Configuration + noWeb bool + webHTML bool + webListenerAddr string + webRemoteHelperAddr string + webWithHTML bool + webHome string + + // Resolver + resolvers varResolver + + // Node Configuration + logFormat string + lazyLoader bool + verbose bool + noWatch bool + noReplay bool + maxGas int64 + chainId string + chainDomain string + unsafeAPI bool + interactive bool + paths string +} + +func (c *AppConfig) RegisterFlagsWith(fs *flag.FlagSet, defaultCfg AppConfig) { + *c = defaultCfg // Copy default config + + fs.StringVar( + &c.home, + "home", + defaultCfg.home, + "user's local directory for keys", + ) + + fs.BoolVar( + &c.interactive, + "interactive", + defaultCfg.interactive, + "enable gnodev interactive mode", + ) + + fs.StringVar( + &c.root, + "root", + defaultCfg.root, + "gno root directory", + ) + + fs.BoolVar( + &c.noWeb, + "no-web", + defaultLocalAppConfig.noWeb, + "disable gnoweb", + ) + + fs.BoolVar( + &c.webHTML, + "web-html", + defaultLocalAppConfig.webHTML, + "gnoweb: enable unsafe HTML parsing in markdown rendering", + ) + + fs.StringVar( + &c.webListenerAddr, + "web-listener", + defaultCfg.webListenerAddr, + "gnoweb: web server listener address", + ) + + fs.StringVar( + &c.webRemoteHelperAddr, + "web-help-remote", + defaultCfg.webRemoteHelperAddr, + "gnoweb: web server help page's remote addr (default to )", + ) + + fs.BoolVar( + &c.webWithHTML, + "web-with-html", + defaultCfg.webWithHTML, + "gnoweb: enable HTML parsing in markdown rendering", + ) + + fs.StringVar( + &c.webHome, + "web-home", + defaultCfg.webHome, + "gnoweb: set default home page, use `/` or `:none:` to use default web home redirect", + ) + + fs.Var( + &c.resolvers, + "resolver", + "list of additional resolvers (`root`, `local` or `remote`), will be executed in the given order", + ) + + fs.StringVar( + &c.nodeRPCListenerAddr, + "node-rpc-listener", + defaultCfg.nodeRPCListenerAddr, + "listening address for GnoLand RPC node", + ) + + fs.Var( + &c.premineAccounts, + "add-account", + "add (or set) a premine account in the form `[=]`, can be used multiple time", + ) + + fs.StringVar( + &c.balancesFile, + "balance-file", + defaultCfg.balancesFile, + "load the provided balance file (refer to the documentation for format)", + ) + + fs.StringVar( + &c.txsFile, + "txs-file", + defaultCfg.txsFile, + "load the provided transactions file (refer to the documentation for format)", + ) + + fs.StringVar( + &c.genesisFile, + "genesis", + defaultCfg.genesisFile, + "load the given genesis file", + ) + + fs.StringVar( + &c.deployKey, + "deploy-key", + defaultCfg.deployKey, + "default key name or Bech32 address for deploying packages", + ) + + fs.StringVar( + &c.chainId, + "chain-id", + defaultCfg.chainId, + "set node ChainID", + ) + + fs.StringVar( + &c.chainDomain, + "chain-domain", + defaultCfg.chainDomain, + "set node ChainDomain", + ) + + fs.BoolVar( + &c.noWatch, + "no-watch", + defaultCfg.noWatch, + "do not watch for file changes", + ) + + fs.BoolVar( + &c.noReplay, + "no-replay", + defaultCfg.noReplay, + "do not replay previous transactions upon reload", + ) + + fs.BoolVar( + &c.lazyLoader, + "lazy-loader", + defaultCfg.lazyLoader, + "enable lazy loader", + ) + + fs.Int64Var( + &c.maxGas, + "max-gas", + defaultCfg.maxGas, + "set the maximum gas per block", + ) + + fs.BoolVar( + &c.unsafeAPI, + "unsafe-api", + defaultCfg.unsafeAPI, + "enable /reset and /reload endpoints which are not safe to expose publicly", + ) + + fs.StringVar( + &c.logFormat, + "log-format", + defaultCfg.logFormat, + "log output format, can be `json` or `console`", + ) + + fs.StringVar( + &c.paths, + "paths", + defaultCfg.paths, + "additional path(s) to load, separated by comma", + ) + + fs.BoolVar( + &c.verbose, + "v", + defaultCfg.verbose, + "enable verbose output for development", + ) +} + +func (c *AppConfig) validateConfigFlags() error { + if (c.balancesFile != "" || c.txsFile != "") && c.genesisFile != "" { + return ErrConflictingFileArgs + } + + return nil +} diff --git a/contribs/gnodev/cmd/gnodev/command_local.go b/contribs/gnodev/cmd/gnodev/command_local.go new file mode 100644 index 00000000000..2a1ccfa063d --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/command_local.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/gnolang/gno/contribs/gnodev/pkg/packages" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/mattn/go-isatty" +) + +const DefaultDomain = "gno.land" + +var ErrConflictingFileArgs = errors.New("cannot specify `balances-file` or `txs-file` along with `genesis-file`") + +type LocalAppConfig struct { + AppConfig + + chdir string // directory context +} + +var defaultLocalAppConfig = AppConfig{ + chainId: "dev", + logFormat: "console", + chainDomain: DefaultDomain, + maxGas: 10_000_000_000, + webListenerAddr: "127.0.0.1:8888", + nodeRPCListenerAddr: "127.0.0.1:26657", + deployKey: defaultDeployerAddress.String(), + home: gnoenv.HomeDir(), + root: gnoenv.RootDir(), + interactive: isatty.IsTerminal(os.Stdout.Fd()), + unsafeAPI: true, + lazyLoader: true, + + // As we have no reason to configure this yet, set this to random port + // to avoid potential conflict with other app + nodeP2PListenerAddr: "tcp://127.0.0.1:0", + nodeProxyAppListenerAddr: "tcp://127.0.0.1:0", +} + +func NewLocalCmd(io commands.IO) *commands.Command { + var cfg LocalAppConfig + + return commands.NewCommand( + commands.Metadata{ + Name: "local", + ShortUsage: "gnodev local [flags] [package_dir...]", + ShortHelp: "Start gnodev in local development mode (default)", + LongHelp: "LOCAL: Local mode configure the node for local development usage", + NoParentFlags: true, + }, + &cfg, + func(_ context.Context, args []string) error { + return execLocalApp(&cfg, args, io) + }, + ) +} + +func (c *LocalAppConfig) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.chdir, + "C", + c.chdir, + "change directory context before running gnodev", + ) + + c.AppConfig.RegisterFlagsWith(fs, defaultLocalAppConfig) +} + +func execLocalApp(cfg *LocalAppConfig, args []string, cio commands.IO) error { + if cfg.chdir != "" { + if err := os.Chdir(cfg.chdir); err != nil { + return fmt.Errorf("unable to change directory: %w", err) + } + } + + dir, err := os.Getwd() + if err != nil { + return fmt.Errorf("unable to guess current dir: %w", err) + } + + // If no resolvers is defined, use gno example as root resolver + var baseResolvers []packages.Resolver + if len(cfg.resolvers) == 0 { + gnoroot, err := gnoenv.GuessRootDir() + if err != nil { + return err + } + + exampleRoot := filepath.Join(gnoroot, "examples") + baseResolvers = append(baseResolvers, packages.NewRootResolver(exampleRoot)) + } + + // Check if current directory is a valid gno package + path := guessPath(&cfg.AppConfig, dir) + resolver := packages.NewLocalResolver(path, dir) + if resolver.IsValid() { + // Add current directory as local resolver + baseResolvers = append([]packages.Resolver{resolver}, baseResolvers...) + if len(cfg.paths) > 0 { + cfg.paths += "," + } + cfg.paths += resolver.Path + } + cfg.resolvers = append(baseResolvers, cfg.resolvers...) + + return runApp(&cfg.AppConfig, cio) // else run app without any dir +} diff --git a/contribs/gnodev/cmd/gnodev/command_staging.go b/contribs/gnodev/cmd/gnodev/command_staging.go new file mode 100644 index 00000000000..7b1a0ab3f5a --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/command_staging.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "flag" + "path/filepath" + + "github.com/gnolang/gno/contribs/gnodev/pkg/packages" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + "github.com/gnolang/gno/tm2/pkg/commands" +) + +type StagingAppConfig struct { + AppConfig +} + +var defaultStagingOptions = AppConfig{ + chainId: "dev", + chainDomain: DefaultDomain, + logFormat: "json", + maxGas: 10_000_000_000, + webHome: ":none:", + webListenerAddr: "127.0.0.1:8888", + nodeRPCListenerAddr: "127.0.0.1:26657", + deployKey: defaultDeployerAddress.String(), + home: gnoenv.HomeDir(), + root: gnoenv.RootDir(), + interactive: false, + unsafeAPI: false, + lazyLoader: false, + paths: filepath.Join(DefaultDomain, "/**"), // Load every package under the main domain}, + + // As we have no reason to configure this yet, set this to random port + // to avoid potential conflict with other app + nodeP2PListenerAddr: "tcp://127.0.0.1:0", + nodeProxyAppListenerAddr: "tcp://127.0.0.1:0", +} + +func NewStagingCmd(io commands.IO) *commands.Command { + var cfg StagingAppConfig + + return commands.NewCommand( + commands.Metadata{ + Name: "staging", + ShortUsage: "gnodev staging [flags] [package_dir...]", + ShortHelp: "Start gnodev in staging mode", + LongHelp: "STAGING: Staging mode configure the node for server usage", + NoParentFlags: true, + }, + &cfg, + func(_ context.Context, args []string) error { + return execStagingCmd(&cfg, args, io) + }, + ) +} + +func (c *StagingAppConfig) RegisterFlags(fs *flag.FlagSet) { + c.AppConfig.RegisterFlagsWith(fs, defaultStagingOptions) +} + +func execStagingCmd(cfg *StagingAppConfig, args []string, io commands.IO) error { + // If no resolvers is defined, use gno example as root resolver + if len(cfg.AppConfig.resolvers) == 0 { + gnoroot, err := gnoenv.GuessRootDir() + if err != nil { + return err + } + + exampleRoot := filepath.Join(gnoroot, "examples") + cfg.AppConfig.resolvers = append(cfg.AppConfig.resolvers, packages.NewRootResolver(exampleRoot)) + } + + return runApp(&cfg.AppConfig, io, args...) +} diff --git a/contribs/gnodev/cmd/gnodev/logger.go b/contribs/gnodev/cmd/gnodev/logger.go index 9e69654f478..1fbcd95e953 100644 --- a/contribs/gnodev/cmd/gnodev/logger.go +++ b/contribs/gnodev/cmd/gnodev/logger.go @@ -1,35 +1,59 @@ package main import ( + "fmt" "io" "log/slog" "github.com/charmbracelet/lipgloss" "github.com/gnolang/gno/contribs/gnodev/pkg/logger" - gnolog "github.com/gnolang/gno/gno.land/pkg/log" + "github.com/gnolang/gno/gno.land/pkg/log" "github.com/muesli/termenv" + "go.uber.org/zap/zapcore" ) -func setuplogger(cfg *devCfg, out io.Writer) *slog.Logger { +func setuplogger(cfg *AppConfig, out io.Writer) (*slog.Logger, error) { level := slog.LevelInfo if cfg.verbose { level = slog.LevelDebug } - if cfg.serverMode { - zaplogger := logger.NewZapLogger(out, level) - return gnolog.ZapLoggerToSlog(zaplogger) - } + // Set up the logger + switch cfg.logFormat { + case "json": + return newJSONLogger(out, level), nil + case "console", "": + // Detect term color profile + colorProfile := termenv.DefaultOutput().Profile + + clogger := logger.NewColumnLogger(out, level, colorProfile) + + // Register well known group color with system colors + clogger.RegisterGroupColor(NodeLogName, lipgloss.Color("3")) + clogger.RegisterGroupColor(WebLogName, lipgloss.Color("4")) + clogger.RegisterGroupColor(KeyPressLogName, lipgloss.Color("5")) + clogger.RegisterGroupColor(EventServerLogName, lipgloss.Color("6")) - // Detect term color profile - colorProfile := termenv.DefaultOutput().Profile - clogger := logger.NewColumnLogger(out, level, colorProfile) + return slog.New(clogger), nil + default: + return nil, fmt.Errorf("invalid log format %q", cfg.logFormat) + } +} - // Register well known group color with system colors - clogger.RegisterGroupColor(NodeLogName, lipgloss.Color("3")) - clogger.RegisterGroupColor(WebLogName, lipgloss.Color("4")) - clogger.RegisterGroupColor(KeyPressLogName, lipgloss.Color("5")) - clogger.RegisterGroupColor(EventServerLogName, lipgloss.Color("6")) +func newJSONLogger(w io.Writer, level slog.Level) *slog.Logger { + var zaplevel zapcore.Level + switch level { + case slog.LevelDebug: + zaplevel = zapcore.DebugLevel + case slog.LevelInfo: + zaplevel = zapcore.InfoLevel + case slog.LevelWarn: + zaplevel = zapcore.WarnLevel + case slog.LevelError: + zaplevel = zapcore.ErrorLevel + default: + panic("unknown slog level: " + level.String()) + } - return slog.New(clogger) + return log.ZapLoggerToSlog(log.NewZapJSONLogger(w, zaplevel)) } diff --git a/contribs/gnodev/cmd/gnodev/main.go b/contribs/gnodev/cmd/gnodev/main.go index 95f1d95e0a6..a14f76e9d81 100644 --- a/contribs/gnodev/cmd/gnodev/main.go +++ b/contribs/gnodev/cmd/gnodev/main.go @@ -1,586 +1,60 @@ package main import ( + "bytes" "context" "errors" "flag" "fmt" - "log/slog" - "net/http" "os" - "path/filepath" - "time" - "github.com/gnolang/gno/contribs/gnodev/pkg/address" - gnodev "github.com/gnolang/gno/contribs/gnodev/pkg/dev" - "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" - "github.com/gnolang/gno/contribs/gnodev/pkg/rawterm" - "github.com/gnolang/gno/contribs/gnodev/pkg/watcher" - "github.com/gnolang/gno/gno.land/pkg/integration" - "github.com/gnolang/gno/gnovm/pkg/gnoenv" "github.com/gnolang/gno/tm2/pkg/commands" - "github.com/gnolang/gno/tm2/pkg/crypto" - osm "github.com/gnolang/gno/tm2/pkg/os" ) -const ( - NodeLogName = "Node" - WebLogName = "GnoWeb" - KeyPressLogName = "KeyPress" - EventServerLogName = "Event" - AccountsLogName = "Accounts" -) - -var ErrConflictingFileArgs = errors.New("cannot specify `balances-file` or `txs-file` along with `genesis-file`") - -var ( - DefaultDeployerName = integration.DefaultAccount_Name - DefaultDeployerAddress = crypto.MustAddressFromString(integration.DefaultAccount_Address) - DefaultDeployerSeed = integration.DefaultAccount_Seed -) - -type devCfg struct { - // Listeners - nodeRPCListenerAddr string - nodeP2PListenerAddr string - nodeProxyAppListenerAddr string - - // Users default - deployKey string - home string - root string - premineAccounts varPremineAccounts - - // Files - balancesFile string - genesisFile string - txsFile string - - // Web Configuration - noWeb bool - webHTML bool - webListenerAddr string - webRemoteHelperAddr string - - // Node Configuration - minimal bool - verbose bool - noWatch bool - noReplay bool - maxGas int64 - chainId string - chainDomain string - serverMode bool - unsafeAPI bool -} - -var defaultDevOptions = &devCfg{ - chainId: "dev", - chainDomain: "gno.land", - maxGas: 10_000_000_000, - webListenerAddr: "127.0.0.1:8888", - nodeRPCListenerAddr: "127.0.0.1:26657", - deployKey: DefaultDeployerAddress.String(), - home: gnoenv.HomeDir(), - root: gnoenv.RootDir(), - - // As we have no reason to configure this yet, set this to random port - // to avoid potential conflict with other app - nodeP2PListenerAddr: "tcp://127.0.0.1:0", - nodeProxyAppListenerAddr: "tcp://127.0.0.1:0", -} - func main() { - cfg := &devCfg{} - stdio := commands.NewDefaultIO() + + localcmd := NewLocalCmd(stdio) // default + cmd := commands.NewCommand( commands.Metadata{ Name: "gnodev", - ShortUsage: "gnodev [flags] [path ...]", - ShortHelp: "runs an in-memory node and gno.land web server for development purposes.", - LongHelp: `The gnodev command starts an in-memory node and a gno.land web interface primarily for realm package development. It automatically loads the 'examples' directory and any additional specified paths.`, - }, - cfg, - func(_ context.Context, args []string) error { - return execDev(cfg, args, stdio) - }) - - cmd.Execute(context.Background(), os.Args[1:]) -} - -func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { - fs.StringVar( - &c.home, - "home", - defaultDevOptions.home, - "user's local directory for keys", - ) - - fs.StringVar( - &c.root, - "root", - defaultDevOptions.root, - "gno root directory", - ) - - fs.BoolVar( - &c.noWeb, - "no-web", - defaultDevOptions.noWeb, - "disable gnoweb", - ) - - fs.BoolVar( - &c.webHTML, - "web-html", - defaultDevOptions.webHTML, - "gnoweb: enable unsafe HTML parsing in markdown rendering", - ) - - fs.StringVar( - &c.webListenerAddr, - "web-listener", - defaultDevOptions.webListenerAddr, - "gnoweb: web server listener address", - ) - - fs.StringVar( - &c.webRemoteHelperAddr, - "web-help-remote", - defaultDevOptions.webRemoteHelperAddr, - "gnoweb: web server help page's remote addr (default to )", - ) - - fs.StringVar( - &c.nodeRPCListenerAddr, - "node-rpc-listener", - defaultDevOptions.nodeRPCListenerAddr, - "listening address for GnoLand RPC node", - ) - - fs.Var( - &c.premineAccounts, - "add-account", - "add (or set) a premine account in the form `[=]`, can be used multiple time", - ) - - fs.StringVar( - &c.balancesFile, - "balance-file", - defaultDevOptions.balancesFile, - "load the provided balance file (refer to the documentation for format)", - ) - - fs.StringVar( - &c.txsFile, - "txs-file", - defaultDevOptions.txsFile, - "load the provided transactions file (refer to the documentation for format)", - ) - - fs.StringVar( - &c.genesisFile, - "genesis", - defaultDevOptions.genesisFile, - "load the given genesis file", - ) - - fs.StringVar( - &c.deployKey, - "deploy-key", - defaultDevOptions.deployKey, - "default key name or Bech32 address for deploying packages", - ) + ShortUsage: "gnodev [flags] ", + ShortHelp: "Runs an in-memory node and gno.land web server for development purposes.", + LongHelp: `The gnodev command starts an in-memory node and a gno.land web interface, primarily for realm package development. - fs.BoolVar( - &c.minimal, - "minimal", - defaultDevOptions.minimal, - "do not load packages from the examples directory", - ) - - fs.BoolVar( - &c.serverMode, - "server-mode", - defaultDevOptions.serverMode, - "disable interaction, and adjust logging for server use.", - ) - - fs.BoolVar( - &c.verbose, - "v", - defaultDevOptions.verbose, - "enable verbose output for development", - ) - - fs.StringVar( - &c.chainId, - "chain-id", - defaultDevOptions.chainId, - "set node ChainID", - ) - - fs.StringVar( - &c.chainDomain, - "chain-domain", - defaultDevOptions.chainDomain, - "set node ChainDomain", - ) - - fs.BoolVar( - &c.noWatch, - "no-watch", - defaultDevOptions.noWatch, - "do not watch for file changes", - ) - - fs.BoolVar( - &c.noReplay, - "no-replay", - defaultDevOptions.noReplay, - "do not replay previous transactions upon reload", - ) - - fs.Int64Var( - &c.maxGas, - "max-gas", - defaultDevOptions.maxGas, - "set the maximum gas per block", - ) - - fs.BoolVar( - &c.unsafeAPI, - "unsafe-api", - defaultDevOptions.unsafeAPI, - "enable /reset and /reload endpoints which are not safe to expose publicly", +If no command is provided, gnodev will automatically start in mode. +For more information and flags usage description, use 'gnodev local -h'.`, + }, + nil, + func(ctx context.Context, _ []string) error { + localcmd.Execute(ctx, os.Args[1:]) + return nil + }, ) -} - -func (c *devCfg) validateConfigFlags() error { - if (c.balancesFile != "" || c.txsFile != "") && c.genesisFile != "" { - return ErrConflictingFileArgs - } - - return nil -} - -func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { - ctx, cancel := context.WithCancelCause(context.Background()) - defer cancel(nil) - - if err := cfg.validateConfigFlags(); err != nil { - return fmt.Errorf("validate error: %w", err) - } - // Setup Raw Terminal - rt, restore, err := setupRawTerm(cfg, io) - if err != nil { - return fmt.Errorf("unable to init raw term: %w", err) - } - defer restore() - - // Setup trap signal - osm.TrapSignal(func() { - cancel(nil) - restore() - }) - - logger := setuplogger(cfg, rt) - loggerEvents := logger.WithGroup(EventServerLogName) - emitterServer := emitter.NewServer(loggerEvents) - - // load keybase - book, err := setupAddressBook(logger.WithGroup(AccountsLogName), cfg) - if err != nil { - return fmt.Errorf("unable to load keybase: %w", err) - } - - // Check and Parse packages - pkgpaths, err := resolvePackagesPathFromArgs(cfg, book, args) - if err != nil { - return fmt.Errorf("unable to parse package paths: %w", err) - } - - // generate balances - balances, err := generateBalances(book, cfg) - if err != nil { - return fmt.Errorf("unable to generate balances: %w", err) - } - logger.Debug("balances loaded", "list", balances.List()) - - // Setup Dev Node - // XXX: find a good way to export or display node logs - nodeLogger := logger.WithGroup(NodeLogName) - nodeCfg := setupDevNodeConfig(cfg, logger, emitterServer, balances, pkgpaths) - devNode, err := setupDevNode(ctx, cfg, nodeCfg) - if err != nil { - return err - } - defer devNode.Close() - - nodeLogger.Info("node started", "lisn", devNode.GetRemoteAddress(), "chainID", cfg.chainId) - - // Create server - mux := http.NewServeMux() - server := http.Server{ - Handler: mux, - Addr: cfg.webListenerAddr, - ReadHeaderTimeout: time.Second * 60, - } - defer server.Close() - - // Setup gnoweb - webhandler, err := setupGnoWebServer(logger.WithGroup(WebLogName), cfg, devNode) - if err != nil { - return fmt.Errorf("unable to setup gnoweb server: %w", err) - } - - // Setup unsafe APIs if enabled - if cfg.unsafeAPI { - mux.HandleFunc("/reset", func(res http.ResponseWriter, req *http.Request) { - if err := devNode.Reset(req.Context()); err != nil { - logger.Error("failed to reset", slog.Any("err", err)) - res.WriteHeader(http.StatusInternalServerError) - } - }) - - mux.HandleFunc("/reload", func(res http.ResponseWriter, req *http.Request) { - if err := devNode.Reload(req.Context()); err != nil { - logger.Error("failed to reload", slog.Any("err", err)) - res.WriteHeader(http.StatusInternalServerError) - } - }) - } - - // Setup HotReload if needed - if !cfg.noWatch { - evtstarget := fmt.Sprintf("%s/_events", server.Addr) - mux.Handle("/_events", emitterServer) - mux.Handle("/", emitter.NewMiddleware(evtstarget, webhandler)) - } else { - mux.Handle("/", webhandler) - } - - // Serve gnoweb - if !cfg.noWeb { - go func() { - err := server.ListenAndServe() - cancel(err) - }() - - logger.WithGroup(WebLogName). - Info("gnoweb started", - "lisn", fmt.Sprintf("http://%s", server.Addr)) - } - - watcher, err := watcher.NewPackageWatcher(loggerEvents, emitterServer) - if err != nil { - return fmt.Errorf("unable to setup packages watcher: %w", err) - } - defer watcher.Stop() - - // Add node pkgs to watcher - watcher.AddPackages(devNode.ListPkgs()...) - - if !cfg.serverMode { - logger.WithGroup("--- READY").Info("for commands and help, press `h`") - } - - // Run the main event loop - return runEventLoop(ctx, logger, book, rt, devNode, watcher) -} - -var helper string = `For more in-depth documentation, visit the GNO Tooling CLI documentation: -https://docs.gno.land/gno-tooling/cli/gno-tooling-gnodev - -P Previous TX - Go to the previous tx -N Next TX - Go to the next tx -E Export - Export the current state as genesis doc -A Accounts - Display known accounts and balances -H Help - Display this message -R Reload - Reload all packages to take change into account. -Ctrl+S Save State - Save the current state -Ctrl+R Reset - Reset application to it's initial/save state. -Ctrl+C Exit - Exit the application -` - -func runEventLoop( - ctx context.Context, - logger *slog.Logger, - bk *address.Book, - rt *rawterm.RawTerm, - dnode *gnodev.Node, - watch *watcher.PackageWatcher, -) error { - // XXX: move this in above, but we need to have a proper struct first - // XXX: make this configurable - var exported uint - path, err := os.MkdirTemp("", "gnodev-export") - if err != nil { - return fmt.Errorf("unable to create `export` directory: %w", err) - } + cmd.AddSubCommands(localcmd) + cmd.AddSubCommands(NewStagingCmd(stdio)) - defer func() { - if exported == 0 { - _ = os.RemoveAll(path) - } - }() - - keyPressCh := listenForKeyPress(logger.WithGroup(KeyPressLogName), rt) - for { - var err error - - select { - case <-ctx.Done(): - return context.Cause(ctx) - case pkgs, ok := <-watch.PackagesUpdate: - if !ok { - return nil - } - - // fmt.Fprintln(nodeOut, "Loading package updates...") - if err = dnode.UpdatePackages(pkgs.PackagesPath()...); err != nil { - return fmt.Errorf("unable to update packages: %w", err) - } - - logger.WithGroup(NodeLogName).Info("reloading...") - if err = dnode.Reload(ctx); err != nil { - logger.WithGroup(NodeLogName). - Error("unable to reload node", "err", err) - } - - case key, ok := <-keyPressCh: - if !ok { - return nil - } - - logger.WithGroup(KeyPressLogName).Debug( - fmt.Sprintf("<%s>", key.String()), - ) - - switch key.Upper() { - case rawterm.KeyH: // Helper - logger.Info("Gno Dev Helper", "helper", helper) - - case rawterm.KeyA: // Accounts - logAccounts(logger.WithGroup(AccountsLogName), bk, dnode) - - case rawterm.KeyR: // Reload - logger.WithGroup(NodeLogName).Info("reloading...") - if err = dnode.ReloadAll(ctx); err != nil { - logger.WithGroup(NodeLogName). - Error("unable to reload node", "err", err) - } - - case rawterm.KeyCtrlR: // Reset - logger.WithGroup(NodeLogName).Info("reseting node state...") - if err = dnode.Reset(ctx); err != nil { - logger.WithGroup(NodeLogName). - Error("unable to reset node state", "err", err) - } - - case rawterm.KeyCtrlS: // Save - logger.WithGroup(NodeLogName).Info("saving state...") - if err := dnode.SaveCurrentState(ctx); err != nil { - logger.WithGroup(NodeLogName). - Error("unable to save node state", "err", err) - } - - case rawterm.KeyE: - logger.WithGroup(NodeLogName).Info("exporting state...") - doc, err := dnode.ExportStateAsGenesis(ctx) - if err != nil { - logger.WithGroup(NodeLogName). - Error("unable to export node state", "err", err) - continue - } - - docfile := filepath.Join(path, fmt.Sprintf("export_%d.jsonl", exported)) - if err := doc.SaveAs(docfile); err != nil { - logger.WithGroup(NodeLogName). - Error("unable to save genesis", "err", err) - } - exported++ - - logger.WithGroup(NodeLogName).Info("node state exported", "file", docfile) - - case rawterm.KeyN: // Next tx - logger.Info("moving forward...") - if err := dnode.MoveToNextTX(ctx); err != nil { - logger.WithGroup(NodeLogName). - Error("unable to move forward", "err", err) - } - - case rawterm.KeyP: // Next tx - logger.Info("moving backward...") - if err := dnode.MoveToPreviousTX(ctx); err != nil { - logger.WithGroup(NodeLogName). - Error("unable to move backward", "err", err) - } - - case rawterm.KeyCtrlC: // Exit - return nil - - default: - } - - // Reset listen for the next keypress - keyPressCh = listenForKeyPress(logger.WithGroup(KeyPressLogName), rt) - } - } -} - -func listenForKeyPress(logger *slog.Logger, rt *rawterm.RawTerm) <-chan rawterm.KeyPress { - cc := make(chan rawterm.KeyPress, 1) - go func() { - defer close(cc) - key, err := rt.ReadKeyPress() - if err != nil { - logger.Error("unable to read keypress", "err", err) + // XXX: This part is a bit hacky; it mostly configures the command to + // use the local command as default, but still falls back on gnodev root + // help if asked. + var buff bytes.Buffer + cmd.SetOutput(&buff) + if err := cmd.Parse(os.Args[1:]); err != nil { + if !errors.Is(err, flag.ErrHelp) { + localcmd.Execute(context.Background(), os.Args[1:]) return } - cc <- key - }() - - return cc -} - -func resolvePackagesPathFromArgs(cfg *devCfg, bk *address.Book, args []string) ([]gnodev.PackagePath, error) { - paths := make([]gnodev.PackagePath, 0, len(args)) - - if cfg.deployKey == "" { - return nil, fmt.Errorf("default deploy key cannot be empty") - } - - defaultKey, _, ok := bk.GetFromNameOrAddress(cfg.deployKey) - if !ok { - return nil, fmt.Errorf("unable to get deploy key %q", cfg.deployKey) - } - - for _, arg := range args { - path, err := gnodev.ResolvePackagePathQuery(bk, arg) - if err != nil { - return nil, fmt.Errorf("invalid package path/query %q: %w", arg, err) - } - - // Assign a default creator if user haven't specified it. - if path.Creator.IsZero() { - path.Creator = defaultKey + if buff.Len() > 0 { + fmt.Fprint(stdio.Err(), buff.String()) } - paths = append(paths, path) + return } - // Add examples folder if minimal is set to false - if !cfg.minimal { - paths = append(paths, gnodev.PackagePath{ - Path: filepath.Join(cfg.root, "examples"), - Creator: defaultKey, - Deposit: nil, - }) + if err := cmd.Run(context.Background()); err != nil { + stdio.ErrPrintfln(err.Error()) } - - return paths, nil } diff --git a/contribs/gnodev/cmd/gnodev/path_manager.go b/contribs/gnodev/cmd/gnodev/path_manager.go new file mode 100644 index 00000000000..705e90fe2c4 --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/path_manager.go @@ -0,0 +1,45 @@ +package main + +import ( + "sync" +) + +// pathManager manages a set of unique paths. +type pathManager struct { + paths map[string]struct{} + mu sync.RWMutex +} + +func newPathManager() *pathManager { + return &pathManager{ + paths: make(map[string]struct{}), + } +} + +// Save add one path to the PathManager. If a path already exists, it is not added again. +func (p *pathManager) Save(path string) (exist bool) { + p.mu.Lock() + defer p.mu.Unlock() + if _, exist = p.paths[path]; !exist { + p.paths[path] = struct{}{} + } + return exist +} + +func (p *pathManager) List() []string { + p.mu.RLock() + defer p.mu.RUnlock() + + paths := make([]string, 0, len(p.paths)) + for path := range p.paths { + paths = append(paths, path) + } + + return paths +} + +func (p *pathManager) Reset() { + p.mu.Lock() + defer p.mu.Unlock() + p.paths = make(map[string]struct{}) +} diff --git a/contribs/gnodev/cmd/gnodev/setup_address_book.go b/contribs/gnodev/cmd/gnodev/setup_address_book.go index a1a1c8f58ac..5d10b748a22 100644 --- a/contribs/gnodev/cmd/gnodev/setup_address_book.go +++ b/contribs/gnodev/cmd/gnodev/setup_address_book.go @@ -9,7 +9,7 @@ import ( osm "github.com/gnolang/gno/tm2/pkg/os" ) -func setupAddressBook(logger *slog.Logger, cfg *devCfg) (*address.Book, error) { +func setupAddressBook(logger *slog.Logger, cfg *AppConfig) (*address.Book, error) { book := address.NewBook() // Check for home folder @@ -40,24 +40,24 @@ func setupAddressBook(logger *slog.Logger, cfg *devCfg) (*address.Book, error) { } // Ensure that we have a default address - names, ok := book.GetByAddress(DefaultDeployerAddress) + names, ok := book.GetByAddress(defaultDeployerAddress) if ok { // Account already exist in the keybase if len(names) > 0 && names[0] != "" { - logger.Info("default address imported", "name", names[0], "addr", DefaultDeployerAddress.String()) + logger.Info("default address imported", "name", names[0], "addr", defaultDeployerAddress.String()) } else { - logger.Info("default address imported", "addr", DefaultDeployerAddress.String()) + logger.Info("default address imported", "addr", defaultDeployerAddress.String()) } return book, nil } // If the key isn't found, create a default one - creatorName := fmt.Sprintf("_default#%.6s", DefaultDeployerAddress.String()) - book.Add(DefaultDeployerAddress, creatorName) + creatorName := fmt.Sprintf("_default#%.6s", defaultDeployerAddress.String()) + book.Add(defaultDeployerAddress, creatorName) logger.Warn("default address created", "name", creatorName, - "addr", DefaultDeployerAddress.String(), + "addr", defaultDeployerAddress.String(), "mnemonic", DefaultDeployerSeed, ) diff --git a/contribs/gnodev/cmd/gnodev/setup_loader.go b/contribs/gnodev/cmd/gnodev/setup_loader.go new file mode 100644 index 00000000000..8f10a6a5a76 --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/setup_loader.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + "log/slog" + "path/filepath" + "regexp" + "strings" + + "github.com/gnolang/gno/contribs/gnodev/pkg/packages" + "github.com/gnolang/gno/gnovm/pkg/gnomod" + "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" +) + +type varResolver []packages.Resolver + +func (va varResolver) String() string { + resolvers := packages.ChainedResolver(va) + return resolvers.Name() +} + +func (va *varResolver) Set(value string) error { + name, location, found := strings.Cut(value, "=") + if !found { + return fmt.Errorf("invalid resolver format %q, should be `=`", value) + } + + var res packages.Resolver + switch name { + case "remote": + rpc, err := client.NewHTTPClient(location) + if err != nil { + return fmt.Errorf("invalid resolver remote: %q", location) + } + + res = packages.NewRemoteResolver(location, rpc) + case "root": // process everything from a root directory + res = packages.NewRootResolver(location) + case "local": // process a single directory + path, ok := guessPathGnoMod(location) + if !ok { + return fmt.Errorf("unable to read module path from gno.mod in %q", location) + } + + res = packages.NewLocalResolver(path, location) + default: + return fmt.Errorf("invalid resolver name: %q", name) + } + + *va = append(*va, res) + return nil +} + +func setupPackagesResolver(logger *slog.Logger, cfg *AppConfig, dirs ...string) (packages.Resolver, []string) { + // Add root resolvers + localResolvers := make([]packages.Resolver, len(dirs)) + + var paths []string + for i, dir := range dirs { + path := guessPath(cfg, dir) + resolver := packages.NewLocalResolver(path, dir) + + if resolver.IsValid() { + logger.Info("guessing directory path", "path", path, "dir", dir) + paths = append(paths, path) // append local path + } else { + logger.Warn("no gno package found", "dir", dir) + } + + localResolvers[i] = resolver + } + + resolver := packages.ChainResolvers( + packages.ChainResolvers(localResolvers...), // Resolve local directories + packages.ChainResolvers(cfg.resolvers...), // Use user's custom resolvers + ) + + // Enrich resolver with middleware + return packages.MiddlewareResolver(resolver, + packages.CacheMiddleware(func(pkg *packages.Package) bool { + return pkg.Kind == packages.PackageKindRemote // Only cache remote package + }), + packages.FilterStdlibs, // Filter stdlib package from resolving + packages.PackageCheckerMiddleware(logger), // Pre-check syntax to avoid bothering the node reloading on invalid files + packages.LogMiddleware(logger), // Log request + ), paths +} + +func guessPathGnoMod(dir string) (path string, ok bool) { + modfile, err := gnomod.ParseAt(dir) + if err == nil { + return modfile.Module.Mod.Path, true + } + + return "", false +} + +var reInvalidChar = regexp.MustCompile(`[^\w_-]`) + +func guessPath(cfg *AppConfig, dir string) (path string) { + if path, ok := guessPathGnoMod(dir); ok { + return path + } + + rname := reInvalidChar.ReplaceAllString(filepath.Base(dir), "-") + return filepath.Join(cfg.chainDomain, "/r/dev/", rname) +} diff --git a/contribs/gnodev/cmd/gnodev/setup_node.go b/contribs/gnodev/cmd/gnodev/setup_node.go index eaeb89b7e95..761bdef0aef 100644 --- a/contribs/gnodev/cmd/gnodev/setup_node.go +++ b/contribs/gnodev/cmd/gnodev/setup_node.go @@ -9,28 +9,25 @@ import ( gnodev "github.com/gnolang/gno/contribs/gnodev/pkg/dev" "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" + "github.com/gnolang/gno/contribs/gnodev/pkg/packages" "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/tm2/pkg/bft/types" ) // setupDevNode initializes and returns a new DevNode. -func setupDevNode( - ctx context.Context, - devCfg *devCfg, - nodeConfig *gnodev.NodeConfig, -) (*gnodev.Node, error) { +func setupDevNode(ctx context.Context, cfg *AppConfig, nodeConfig *gnodev.NodeConfig, paths ...string) (*gnodev.Node, error) { logger := nodeConfig.Logger - if devCfg.txsFile != "" { // Load txs files + if cfg.txsFile != "" { // Load txs files var err error - nodeConfig.InitialTxs, err = gnoland.ReadGenesisTxs(ctx, devCfg.txsFile) + nodeConfig.InitialTxs, err = gnoland.ReadGenesisTxs(ctx, cfg.txsFile) if err != nil { return nil, fmt.Errorf("unable to load transactions: %w", err) } - } else if devCfg.genesisFile != "" { // Load genesis file - state, err := extractAppStateFromGenesisFile(devCfg.genesisFile) + } else if cfg.genesisFile != "" { // Load genesis file + state, err := extractAppStateFromGenesisFile(cfg.genesisFile) if err != nil { - return nil, fmt.Errorf("unable to load genesis file %q: %w", devCfg.genesisFile, err) + return nil, fmt.Errorf("unable to load genesis file %q: %w", cfg.genesisFile, err) } // Override balances and txs @@ -43,34 +40,40 @@ func setupDevNode( nodeConfig.InitialTxs[index] = nodeTx } - logger.Info("genesis file loaded", "path", devCfg.genesisFile, "txs", len(stateTxs)) + logger.Info("genesis file loaded", "path", cfg.genesisFile, "txs", len(stateTxs)) } - return gnodev.NewDevNode(ctx, nodeConfig) + if len(paths) > 0 { + logger.Info("packages", "paths", paths) + } else { + logger.Debug("no path(s) provided") + } + + return gnodev.NewDevNode(ctx, nodeConfig, paths...) } // setupDevNodeConfig creates and returns a new dev.NodeConfig. func setupDevNodeConfig( - cfg *devCfg, + cfg *AppConfig, logger *slog.Logger, emitter emitter.Emitter, balances gnoland.Balances, - pkgspath []gnodev.PackagePath, + loader packages.Loader, ) *gnodev.NodeConfig { config := gnodev.DefaultNodeConfig(cfg.root, cfg.chainDomain) + config.Loader = loader config.Logger = logger config.Emitter = emitter config.BalancesList = balances.List() - config.PackagesPathList = pkgspath - config.TMConfig.RPC.ListenAddress = resolveUnixOrTCPAddr(cfg.nodeRPCListenerAddr) + config.TMConfig.RPC.ListenAddress = cfg.nodeRPCListenerAddr config.NoReplay = cfg.noReplay config.MaxGasPerBlock = cfg.maxGas config.ChainID = cfg.chainId // other listeners - config.TMConfig.P2P.ListenAddress = defaultDevOptions.nodeP2PListenerAddr - config.TMConfig.ProxyApp = defaultDevOptions.nodeProxyAppListenerAddr + config.TMConfig.P2P.ListenAddress = defaultLocalAppConfig.nodeP2PListenerAddr + config.TMConfig.ProxyApp = defaultLocalAppConfig.nodeProxyAppListenerAddr return config } @@ -89,21 +92,20 @@ func extractAppStateFromGenesisFile(path string) (*gnoland.GnoGenesisState, erro return &state, nil } -func resolveUnixOrTCPAddr(in string) (out string) { +func resolveUnixOrTCPAddr(in string) (addr net.Addr) { var err error - var addr net.Addr if strings.HasPrefix(in, "unix://") { in = strings.TrimPrefix(in, "unix://") - if addr, err := net.ResolveUnixAddr("unix", in); err == nil { - return fmt.Sprintf("%s://%s", addr.Network(), addr.String()) + if addr, err = net.ResolveUnixAddr("unix", in); err == nil { + return addr } err = fmt.Errorf("unable to resolve unix address `unix://%s`: %w", in, err) } else { // don't bother to checking prefix in = strings.TrimPrefix(in, "tcp://") if addr, err = net.ResolveTCPAddr("tcp", in); err == nil { - return fmt.Sprintf("%s://%s", addr.Network(), addr.String()) + return addr } err = fmt.Errorf("unable to resolve tcp address `tcp://%s`: %w", in, err) diff --git a/contribs/gnodev/cmd/gnodev/setup_term.go b/contribs/gnodev/cmd/gnodev/setup_term.go index 1f8f3046969..fb8b5593abf 100644 --- a/contribs/gnodev/cmd/gnodev/setup_term.go +++ b/contribs/gnodev/cmd/gnodev/setup_term.go @@ -7,10 +7,10 @@ import ( var noopRestore = func() error { return nil } -func setupRawTerm(cfg *devCfg, io commands.IO) (*rawterm.RawTerm, func() error, error) { +func setupRawTerm(cfg *AppConfig, io commands.IO) (*rawterm.RawTerm, func() error, error) { rt := rawterm.NewRawTerm() restore := noopRestore - if !cfg.serverMode { + if cfg.interactive { var err error restore, err = rt.Init() if err != nil { diff --git a/contribs/gnodev/cmd/gnodev/setup_web.go b/contribs/gnodev/cmd/gnodev/setup_web.go index e509768d2a1..09df8ce009c 100644 --- a/contribs/gnodev/cmd/gnodev/setup_web.go +++ b/contribs/gnodev/cmd/gnodev/setup_web.go @@ -5,24 +5,23 @@ import ( "log/slog" "net/http" - gnodev "github.com/gnolang/gno/contribs/gnodev/pkg/dev" "github.com/gnolang/gno/gno.land/pkg/gnoweb" ) // setupGnowebServer initializes and starts the Gnoweb server. -func setupGnoWebServer(logger *slog.Logger, cfg *devCfg, dnode *gnodev.Node) (http.Handler, error) { +func setupGnoWebServer(logger *slog.Logger, cfg *AppConfig, remoteAddr string) (http.Handler, error) { if cfg.noWeb { return http.HandlerFunc(http.NotFound), nil } - remote := dnode.GetRemoteAddress() - appcfg := gnoweb.NewDefaultAppConfig() appcfg.UnsafeHTML = cfg.webHTML - appcfg.NodeRemote = remote + appcfg.NodeRemote = remoteAddr appcfg.ChainID = cfg.chainId if cfg.webRemoteHelperAddr != "" { appcfg.RemoteHelp = cfg.webRemoteHelperAddr + } else { + appcfg.RemoteHelp = remoteAddr } router, err := gnoweb.NewRouter(logger, appcfg) @@ -30,5 +29,11 @@ func setupGnoWebServer(logger *slog.Logger, cfg *devCfg, dnode *gnodev.Node) (ht return nil, fmt.Errorf("unable to create router app: %w", err) } + logger.Debug("gnoweb router created", + "remote", appcfg.NodeRemote, + "helper_remote", appcfg.RemoteHelp, + "html", appcfg.UnsafeHTML, + "chain_id", cfg.chainId, + ) return router, nil } diff --git a/contribs/gnodev/go.mod b/contribs/gnodev/go.mod index 0ad16ba9bb3..9aea08c4a30 100644 --- a/contribs/gnodev/go.mod +++ b/contribs/gnodev/go.mod @@ -18,6 +18,7 @@ require ( github.com/gnolang/gno v0.0.0-00010101000000-000000000000 github.com/gorilla/websocket v1.5.3 github.com/lrstanley/bubblezone v0.0.0-20240624011428-67235275f80c + github.com/mattn/go-isatty v0.0.20 github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.15.2 github.com/sahilm/fuzzy v0.1.1 @@ -62,7 +63,6 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.25 // indirect diff --git a/contribs/gnodev/internal/mock/server_emitter.go b/contribs/gnodev/internal/mock/emitter/server_emitter.go similarity index 100% rename from contribs/gnodev/internal/mock/server_emitter.go rename to contribs/gnodev/internal/mock/emitter/server_emitter.go diff --git a/contribs/gnodev/pkg/dev/node.go b/contribs/gnodev/pkg/dev/node.go index 12a88490515..5fa9dc4e4d4 100644 --- a/contribs/gnodev/pkg/dev/node.go +++ b/contribs/gnodev/pkg/dev/node.go @@ -13,10 +13,12 @@ import ( "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" "github.com/gnolang/gno/contribs/gnodev/pkg/events" + "github.com/gnolang/gno/contribs/gnodev/pkg/packages" "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" "github.com/gnolang/gno/gno.land/pkg/integration" - "github.com/gnolang/gno/gnovm/pkg/gnomod" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" "github.com/gnolang/gno/tm2/pkg/amino" tmcfg "github.com/gnolang/gno/tm2/pkg/bft/config" "github.com/gnolang/gno/tm2/pkg/bft/node" @@ -32,18 +34,52 @@ import ( ) type NodeConfig struct { - Logger *slog.Logger - DefaultDeployer crypto.Address - BalancesList []gnoland.Balance - PackagesPathList []PackagePath - Emitter emitter.Emitter - InitialTxs []gnoland.TxWithMetadata - TMConfig *tmcfg.Config + // Logger is used for logging node activities. It can be set to a custom logger or a noop logger for + // silent operation. + Logger *slog.Logger + + // Loader is responsible for loading packages. It abstracts the mechanism for retrieving and managing + // package data. + Loader packages.Loader + + // DefaultCreator specifies the default address used for creating packages and transactions. + DefaultCreator crypto.Address + + // DefaultDeposit is the default amount of coins deposited when creating a package. + DefaultDeposit std.Coins + + // BalancesList defines the initial balance of accounts in the genesis state. + BalancesList []gnoland.Balance + + // PackagesModifier allows modifications to be applied to packages during initialization. + PackagesModifier []QueryPath + + // Emitter is used to emit events for various node operations. It can be set to a noop emitter if no + // event emission is required. + Emitter emitter.Emitter + + // InitialTxs contains the transactions that are included in the genesis state. + InitialTxs []gnoland.TxWithMetadata + + // TMConfig holds the Tendermint configuration settings. + TMConfig *tmcfg.Config + + // SkipFailingGenesisTxs indicates whether to skip failing transactions during the genesis + // initialization. SkipFailingGenesisTxs bool - NoReplay bool - MaxGasPerBlock int64 - ChainID string - ChainDomain string + + // NoReplay, if set to true, prevents replaying of transactions from the block store during node + // initialization. + NoReplay bool + + // MaxGasPerBlock sets the maximum amount of gas that can be used in a single block. + MaxGasPerBlock int64 + + // ChainID is the unique identifier for the blockchain. + ChainID string + + // ChainDomain specifies the domain name associated with the blockchain network. + ChainDomain string } func DefaultNodeConfig(rootdir, domain string) *NodeConfig { @@ -60,10 +96,15 @@ func DefaultNodeConfig(rootdir, domain string) *NodeConfig { }, } + exampleFolder := filepath.Join(gnoenv.RootDir(), "example") // XXX: we should avoid having to hardcoding this here + defaultLoader := packages.NewLoader(packages.NewRootResolver(exampleFolder)) + return &NodeConfig{ Logger: log.NewNoopLogger(), Emitter: &emitter.NoopServer{}, - DefaultDeployer: defaultDeployer, + Loader: defaultLoader, + DefaultCreator: defaultDeployer, + DefaultDeposit: nil, BalancesList: balances, ChainID: tmc.ChainID(), ChainDomain: domain, @@ -78,11 +119,14 @@ type Node struct { *node.Node muNode sync.RWMutex - config *NodeConfig - emitter emitter.Emitter - client client.Client - logger *slog.Logger - pkgs PackagesMap // path -> pkg + config *NodeConfig + emitter emitter.Emitter + client client.Client + logger *slog.Logger + loader packages.Loader + pkgs []packages.Package + pkgsModifier map[string]QueryPath // path -> QueryPath + paths []string // keep track of number of loaded package to be able to skip them on restore loadedPackages int @@ -97,36 +141,30 @@ type Node struct { var DefaultFee = std.NewFee(50000, std.MustParseCoin(ugnot.ValueString(1000000))) -func NewDevNode(ctx context.Context, cfg *NodeConfig) (*Node, error) { - mpkgs, err := NewPackagesMap(cfg.PackagesPathList) - if err != nil { - return nil, fmt.Errorf("unable map pkgs list: %w", err) - } - +func NewDevNode(ctx context.Context, cfg *NodeConfig, pkgpaths ...string) (*Node, error) { startTime := time.Now() - pkgsTxs, err := mpkgs.Load(DefaultFee, startTime) - if err != nil { - return nil, fmt.Errorf("unable to load genesis packages: %w", err) + + pkgsModifier := make(map[string]QueryPath, len(cfg.PackagesModifier)) + for _, qpath := range cfg.PackagesModifier { + pkgsModifier[qpath.Path] = qpath } - cfg.Logger.Info("pkgs loaded", "path", cfg.PackagesPathList) devnode := &Node{ + loader: cfg.Loader, config: cfg, client: client.NewLocal(), emitter: cfg.Emitter, - pkgs: mpkgs, logger: cfg.Logger, - loadedPackages: len(pkgsTxs), startTime: startTime, state: cfg.InitialTxs, initialState: cfg.InitialTxs, currentStateIndex: len(cfg.InitialTxs), + paths: pkgpaths, + pkgsModifier: pkgsModifier, } - genesis := gnoland.DefaultGenState() - genesis.Balances = cfg.BalancesList - genesis.Txs = append(pkgsTxs, cfg.InitialTxs...) - if err := devnode.rebuildNode(ctx, genesis); err != nil { + // XXX: MOVE THIS, passing context here can be confusing + if err := devnode.Reset(ctx); err != nil { return nil, fmt.Errorf("unable to initialize the node: %w", err) } @@ -140,11 +178,11 @@ func (n *Node) Close() error { return n.Node.Stop() } -func (n *Node) ListPkgs() []gnomod.Pkg { +func (n *Node) ListPkgs() []packages.Package { n.muNode.RLock() defer n.muNode.RUnlock() - return n.pkgs.toList() + return n.pkgs } func (n *Node) Client() client.Client { @@ -158,8 +196,38 @@ func (n *Node) GetRemoteAddress() string { return n.Node.Config().RPC.ListenAddress } +// AddPackagePaths to load +func (n *Node) AddPackagePaths(paths ...string) { + n.muNode.Lock() + defer n.muNode.Unlock() + + n.paths = append(n.paths, paths...) +} + +func (n *Node) SetPackagePaths(paths ...string) { + n.muNode.Lock() + defer n.muNode.Unlock() + + n.paths = paths +} + +// HasPackageLoaded returns true if the specified package has already been loaded. +// NOTE: This only checks if the package was loaded at the genesis level. +func (n *Node) HasPackageLoaded(path string) bool { + n.muNode.RLock() + defer n.muNode.RUnlock() + + for _, pkg := range n.pkgs { + if pkg.MemPackage.Path == path { + return true + } + } + + return false +} + // GetBlockTransactions returns the transactions contained -// within the specified block, if any +// within the specified block, if any. func (n *Node) GetBlockTransactions(blockNum uint64) ([]gnoland.TxWithMetadata, error) { n.muNode.RLock() defer n.muNode.RUnlock() @@ -168,7 +236,7 @@ func (n *Node) GetBlockTransactions(blockNum uint64) ([]gnoland.TxWithMetadata, } // GetBlockTransactions returns the transactions contained -// within the specified block, if any +// within the specified block, if any. func (n *Node) getBlockTransactions(blockNum uint64) ([]gnoland.TxWithMetadata, error) { int64BlockNum := int64(blockNum) b, err := n.client.Block(&int64BlockNum) @@ -196,8 +264,8 @@ func (n *Node) getBlockTransactions(blockNum uint64) ([]gnoland.TxWithMetadata, } // GetBlockTransactions returns the transactions contained -// within the specified block, if any -// GetLatestBlockNumber returns the latest block height from the chain +// within the specified block, if any. +// GetLatestBlockNumber returns the latest block height from the chain. func (n *Node) GetLatestBlockNumber() (uint64, error) { n.muNode.RLock() defer n.muNode.RUnlock() @@ -209,82 +277,25 @@ func (n *Node) getLatestBlockNumber() uint64 { return uint64(n.Node.BlockStore().Height()) } -// UpdatePackages updates the currently known packages. It will be taken into -// consideration in the next reload of the node. -func (n *Node) UpdatePackages(paths ...string) error { - n.muNode.Lock() - defer n.muNode.Unlock() - - return n.updatePackages(paths...) -} - -func (n *Node) updatePackages(paths ...string) error { - var pkgsUpdated int - for _, path := range paths { - abspath, err := filepath.Abs(path) - if err != nil { - return fmt.Errorf("unable to resolve abs path of %q: %w", path, err) - } - - // Check if we already know the path (or its parent) and set - // associated deployer and deposit - deployer := n.config.DefaultDeployer - var deposit std.Coins - for _, ppath := range n.config.PackagesPathList { - if !strings.HasPrefix(abspath, ppath.Path) { - continue - } - - deployer = ppath.Creator - deposit = ppath.Deposit - } - - // List all packages from target path - pkgslist, err := gnomod.ListPkgs(abspath) - if err != nil { - return fmt.Errorf("failed to list gno packages for %q: %w", path, err) - } - - // Update or add package in the current known list. - for _, pkg := range pkgslist { - n.pkgs[pkg.Dir] = Package{ - Pkg: pkg, - Creator: deployer, - Deposit: deposit, - } - - n.logger.Debug("pkgs update", "name", pkg.Name, "path", pkg.Dir) - } - - pkgsUpdated += len(pkgslist) - } - - n.logger.Info(fmt.Sprintf("updated %d packages", pkgsUpdated)) - return nil -} - // Reset stops the node, if running, and reloads it with a new genesis state, // effectively ignoring the current state. func (n *Node) Reset(ctx context.Context) error { n.muNode.Lock() defer n.muNode.Unlock() - // Stop the node if it's currently running. - if err := n.stopIfRunning(); err != nil { - return fmt.Errorf("unable to stop the node: %w", err) - } - // Reset starting time startTime := time.Now() // Generate a new genesis state based on the current packages - pkgsTxs, err := n.pkgs.Load(DefaultFee, startTime) + pkgs, err := n.loader.Load(n.paths...) if err != nil { return fmt.Errorf("unable to load pkgs: %w", err) } // Append initialTxs + pkgsTxs := n.generateTxs(DefaultFee, pkgs) txs := append(pkgsTxs, n.initialState...) + genesis := gnoland.DefaultGenState() genesis.Balances = n.config.BalancesList genesis.Txs = txs @@ -295,6 +306,7 @@ func (n *Node) Reset(ctx context.Context) error { return fmt.Errorf("unable to initialize a new node: %w", err) } + n.pkgs = pkgs n.loadedPackages = len(pkgsTxs) n.currentStateIndex = len(n.initialState) n.startTime = startTime @@ -308,16 +320,6 @@ func (n *Node) ReloadAll(ctx context.Context) error { n.muNode.Lock() defer n.muNode.Unlock() - pkgs := n.pkgs.toList() - paths := make([]string, len(pkgs)) - for i, pkg := range pkgs { - paths[i] = pkg.Dir - } - - if err := n.updatePackages(paths...); err != nil { - return fmt.Errorf("unable to reload packages: %w", err) - } - return n.rebuildNodeFromState(ctx) } @@ -386,10 +388,51 @@ func (n *Node) getBlockStoreState(ctx context.Context) ([]gnoland.TxWithMetadata state = append(state, txs...) } - // override current state return state, nil } +func (n *Node) generateTxs(fee std.Fee, pkgs []packages.Package) []gnoland.TxWithMetadata { + metatxs := make([]gnoland.TxWithMetadata, 0, len(pkgs)) + for _, pkg := range pkgs { + msg := vm.MsgAddPackage{ + Creator: n.config.DefaultCreator, + Deposit: n.config.DefaultDeposit, + Package: &pkg.MemPackage, + } + + if m, ok := n.pkgsModifier[pkg.Path]; ok { + if !m.Creator.IsZero() { + msg.Creator = m.Creator + } + + if m.Deposit != nil { + msg.Deposit = m.Deposit + } + + n.logger.Debug("applying pkgs modifier", + "path", pkg.Path, + "creator", msg.Creator, + "deposit", msg.Deposit, + ) + } + + // Create transaction + tx := std.Tx{Fee: fee, Msgs: []std.Msg{msg}} + tx.Signatures = make([]std.Signature, len(tx.GetSigners())) + + // Wrap it with metadata + metatx := gnoland.TxWithMetadata{ + Tx: tx, + Metadata: &gnoland.GnoTxMetadata{ + Timestamp: n.startTime.Unix(), + }, + } + metatxs = append(metatxs, metatx) + } + + return metatxs +} + func (n *Node) stopIfRunning() error { if n.Node != nil && n.Node.IsRunning() { if err := n.Node.Stop(); err != nil { @@ -401,17 +444,20 @@ func (n *Node) stopIfRunning() error { } func (n *Node) rebuildNodeFromState(ctx context.Context) error { + start := time.Now() + if n.config.NoReplay { // If NoReplay is true, simply reset the node to its initial state n.logger.Warn("replay disabled") - txs, err := n.pkgs.Load(DefaultFee, n.startTime) + pkgs, err := n.loader.Load(n.paths...) if err != nil { return fmt.Errorf("unable to load pkgs: %w", err) } + genesis := gnoland.DefaultGenState() genesis.Balances = n.config.BalancesList - genesis.Txs = txs + genesis.Txs = n.generateTxs(DefaultFee, pkgs) return n.rebuildNode(ctx, genesis) } @@ -421,7 +467,7 @@ func (n *Node) rebuildNodeFromState(ctx context.Context) error { } // Load genesis packages - pkgsTxs, err := n.pkgs.Load(DefaultFee, n.startTime) + pkgs, err := n.loader.Load(n.paths...) if err != nil { return fmt.Errorf("unable to load pkgs: %w", err) } @@ -429,15 +475,24 @@ func (n *Node) rebuildNodeFromState(ctx context.Context) error { // Create genesis with loaded pkgs + previous state genesis := gnoland.DefaultGenState() genesis.Balances = n.config.BalancesList + + // Generate txs + pkgsTxs := n.generateTxs(DefaultFee, pkgs) genesis.Txs = append(pkgsTxs, state...) // Reset the node with the new genesis state. err = n.rebuildNode(ctx, genesis) - n.logger.Info("reload done", "pkgs", len(pkgsTxs), "state applied", len(state)) + n.logger.Info("reload done", + "pkgs", len(pkgsTxs), + "state applied", len(state), + "took", time.Since(start), + ) // Update node infos + n.pkgs = pkgs n.loadedPackages = len(pkgsTxs) + // Emit reload event n.emitter.Emit(&events.Reload{}) return nil } @@ -534,13 +589,23 @@ func (n *Node) rebuildNode(ctx context.Context, genesis gnoland.GnoGenesisState) func (n *Node) genesisTxResultHandler(ctx sdk.Context, tx std.Tx, res sdk.Result) { if !res.IsErr() { + for _, msg := range tx.Msgs { + if addpkg, ok := msg.(vm.MsgAddPackage); ok && addpkg.Package != nil { + n.logger.Debug("add package", + "path", addpkg.Package.Path, + "files", len(addpkg.Package.Files), + "creator", addpkg.Creator.String(), + ) + } + } + return } // XXX: for now, this is only way to catch the error before, after, found := strings.Cut(res.Log, "\n") if !found { - n.logger.Error("unable to send tx", "err", res.Error, "log", res.Log) + n.logger.Error("unable to send tx", "log", res.Log) return } diff --git a/contribs/gnodev/pkg/dev/node_state.go b/contribs/gnodev/pkg/dev/node_state.go index 3f996bc7716..557565ea0b1 100644 --- a/contribs/gnodev/pkg/dev/node_state.go +++ b/contribs/gnodev/pkg/dev/node_state.go @@ -84,14 +84,10 @@ func (n *Node) MoveBy(ctx context.Context, x int) error { } // Load genesis packages - pkgsTxs, err := n.pkgs.Load(DefaultFee, n.startTime) - if err != nil { - return fmt.Errorf("unable to load pkgs: %w", err) - } - - newState := n.state[:newIndex] + pkgsTxs := n.generateTxs(DefaultFee, n.pkgs) // Create genesis with loaded pkgs + previous state + newState := n.state[:newIndex] genesis := gnoland.DefaultGenState() genesis.Balances = n.config.BalancesList genesis.Txs = append(pkgsTxs, newState...) diff --git a/contribs/gnodev/pkg/dev/node_state_test.go b/contribs/gnodev/pkg/dev/node_state_test.go index efaeb979693..32800fd0db6 100644 --- a/contribs/gnodev/pkg/dev/node_state_test.go +++ b/contribs/gnodev/pkg/dev/node_state_test.go @@ -6,10 +6,11 @@ import ( "testing" "time" - emitter "github.com/gnolang/gno/contribs/gnodev/internal/mock" + mock "github.com/gnolang/gno/contribs/gnodev/internal/mock/emitter" "github.com/gnolang/gno/contribs/gnodev/pkg/events" "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gnovm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -136,28 +137,29 @@ func TestExportState(t *testing.T) { }) } -func testingCounterRealm(t *testing.T, inc int) (*Node, *emitter.ServerEmitter) { +func testingCounterRealm(t *testing.T, inc int) (*Node, *mock.ServerEmitter) { t.Helper() - const ( - // foo package - counterGnoMod = "module gno.land/r/dev/counter\n" - counterFile = `package counter + const counterFile = ` +package counter + import "strconv" var value int = 0 func Inc(v int) { value += v } // method to increment value func Render(_ string) string { return strconv.Itoa(value) } ` - ) - // Generate package counter - counterPkg := generateTestingPackage(t, - "gno.mod", counterGnoMod, - "foo.gno", counterFile) + counterPkg := gnovm.MemPackage{ + Name: "counter", + Path: "gno.land/r/dev/counter", + Files: []*gnovm.MemFile{ + {Name: "file.gno", Body: counterFile}, + }, + } // Call NewDevNode with no package should work - node, emitter := newTestingDevNode(t, counterPkg) + node, emitter := newTestingDevNode(t, &counterPkg) assert.Len(t, node.ListPkgs(), 1) // Test rendering diff --git a/contribs/gnodev/pkg/dev/node_test.go b/contribs/gnodev/pkg/dev/node_test.go index 38fab0a3360..89fd419a9ae 100644 --- a/contribs/gnodev/pkg/dev/node_test.go +++ b/contribs/gnodev/pkg/dev/node_test.go @@ -3,22 +3,20 @@ package dev import ( "context" "encoding/json" - "os" - "path/filepath" "testing" "time" - mock "github.com/gnolang/gno/contribs/gnodev/internal/mock" - + mock "github.com/gnolang/gno/contribs/gnodev/internal/mock/emitter" "github.com/gnolang/gno/contribs/gnodev/pkg/events" + "github.com/gnolang/gno/contribs/gnodev/pkg/packages" "github.com/gnolang/gno/gno.land/pkg/gnoclient" "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" "github.com/gnolang/gno/gno.land/pkg/integration" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gnovm" "github.com/gnolang/gno/gnovm/pkg/gnoenv" core_types "github.com/gnolang/gno/tm2/pkg/bft/rpc/core/types" "github.com/gnolang/gno/tm2/pkg/bft/types" - "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/crypto/keys" tm2events "github.com/gnolang/gno/tm2/pkg/events" "github.com/gnolang/gno/tm2/pkg/log" @@ -26,10 +24,6 @@ import ( "github.com/stretchr/testify/require" ) -// XXX: We should probably use txtar to test this package. - -var nodeTestingAddress = crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") - // TestNewNode_NoPackages tests the NewDevNode method with no package. func TestNewNode_NoPackages(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) @@ -49,32 +43,35 @@ func TestNewNode_NoPackages(t *testing.T) { } // TestNewNode_WithPackage tests the NewDevNode with a single package. -func TestNewNode_WithPackage(t *testing.T) { +func TestNewNode_WithLoader(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - const ( - // foobar package - testGnoMod = "module gno.land/r/dev/foobar\n" - testFile = `package foobar + pkg := gnovm.MemPackage{ + Name: "foobar", + Path: "gno.land/r/dev/foobar", + Files: []*gnovm.MemFile{ + { + Name: "foobar.gno", + Body: `package foobar func Render(_ string) string { return "foo" } -` - ) +`, + }, + }, + } - // Generate package - pkgpath := generateTestingPackage(t, "gno.mod", testGnoMod, "foobar.gno", testFile) logger := log.NewTestingLogger(t) - // Call NewDevNode with no package should work cfg := DefaultNodeConfig(gnoenv.RootDir(), "gno.land") - cfg.PackagesPathList = []PackagePath{pkgpath} + cfg.Loader = packages.NewLoader(packages.NewMockResolver(&pkg)) cfg.Logger = logger - node, err := NewDevNode(ctx, cfg) + + node, err := NewDevNode(ctx, cfg, pkg.Path) require.NoError(t, err) assert.Len(t, node.ListPkgs(), 1) // Test rendering - render, err := testingRenderRealm(t, node, "gno.land/r/dev/foobar") + render, err := testingRenderRealm(t, node, pkg.Path) require.NoError(t, err) assert.Equal(t, render, "foo") @@ -83,24 +80,37 @@ func Render(_ string) string { return "foo" } func TestNodeAddPackage(t *testing.T) { // Setup a Node instance - const ( - // foo package - fooGnoMod = "module gno.land/r/dev/foo\n" - fooFile = `package foo + fooPkg := gnovm.MemPackage{ + Name: "foo", + Path: "gno.land/r/dev/foo", + Files: []*gnovm.MemFile{ + { + Name: "foo.gno", + Body: `package foo func Render(_ string) string { return "foo" } -` - // bar package - barGnoMod = "module gno.land/r/dev/bar\n" - barFile = `package bar +`, + }, + }, + } + + barPkg := gnovm.MemPackage{ + Name: "bar", + Path: "gno.land/r/dev/bar", + Files: []*gnovm.MemFile{ + { + Name: "bar.gno", + Body: `package bar func Render(_ string) string { return "bar" } -` - ) +`, + }, + }, + } // Generate package foo - foopkg := generateTestingPackage(t, "gno.mod", fooGnoMod, "foo.gno", fooFile) + cfg := newTestingNodeConfig(&fooPkg, &barPkg) // Call NewDevNode with no package should work - node, emitter := newTestingDevNode(t, foopkg) + node, emitter := newTestingDevNodeWithConfig(t, cfg, fooPkg.Path) assert.Len(t, node.ListPkgs(), 1) // Test render @@ -108,54 +118,60 @@ func Render(_ string) string { return "bar" } require.NoError(t, err) require.Equal(t, render, "foo") - // Generate package bar - barpkg := generateTestingPackage(t, "gno.mod", barGnoMod, "bar.gno", barFile) - err = node.UpdatePackages(barpkg.Path) - require.NoError(t, err) - assert.Len(t, node.ListPkgs(), 2) - // Render should fail as the node hasn't reloaded render, err = testingRenderRealm(t, node, "gno.land/r/dev/bar") require.Error(t, err) + // Add bar package + node.AddPackagePaths(barPkg.Path) + err = node.Reload(context.Background()) require.NoError(t, err) assert.Equal(t, emitter.NextEvent().Type(), events.EvtReload) // After a reload, render should succeed - render, err = testingRenderRealm(t, node, "gno.land/r/dev/bar") + render, err = testingRenderRealm(t, node, barPkg.Path) require.NoError(t, err) require.Equal(t, render, "bar") } func TestNodeUpdatePackage(t *testing.T) { - // Setup a Node instance - const ( - // foo package - foobarGnoMod = "module gno.land/r/dev/foobar\n" - fooFile = `package foobar + foorbarPkg := gnovm.MemPackage{ + Name: "foobar", + Path: "gno.land/r/dev/foobar", + } + + fooFiles := []*gnovm.MemFile{ + { + Name: "foo.gno", + Body: `package foobar func Render(_ string) string { return "foo" } -` - barFile = `package foobar +`, + }, + } + + barFiles := []*gnovm.MemFile{ + { + Name: "bar.gno", + Body: `package foobar func Render(_ string) string { return "bar" } -` - ) +`, + }, + } - // Generate package foo - foopkg := generateTestingPackage(t, "gno.mod", foobarGnoMod, "foo.gno", fooFile) + // Update foobar content with bar content + foorbarPkg.Files = fooFiles - // Call NewDevNode with no package should work - node, emitter := newTestingDevNode(t, foopkg) + node, emitter := newTestingDevNode(t, &foorbarPkg) assert.Len(t, node.ListPkgs(), 1) // Test that render is correct - render, err := testingRenderRealm(t, node, "gno.land/r/dev/foobar") + render, err := testingRenderRealm(t, node, foorbarPkg.Path) require.NoError(t, err) require.Equal(t, render, "foo") - // Override `foo.gno` file with bar content - err = os.WriteFile(filepath.Join(foopkg.Path, "foo.gno"), []byte(barFile), 0o700) - require.NoError(t, err) + // Update foobar content with bar content + foorbarPkg.Files = barFiles err = node.Reload(context.Background()) require.NoError(t, err) @@ -164,7 +180,7 @@ func Render(_ string) string { return "bar" } assert.Equal(t, events.EvtReload, emitter.NextEvent().Type()) // After a reload, render should succeed - render, err = testingRenderRealm(t, node, "gno.land/r/dev/foobar") + render, err = testingRenderRealm(t, node, foorbarPkg.Path) require.NoError(t, err) require.Equal(t, render, "bar") @@ -172,31 +188,33 @@ func Render(_ string) string { return "bar" } } func TestNodeReset(t *testing.T) { - const ( - // foo package - foobarGnoMod = "module gno.land/r/dev/foo\n" - fooFile = `package foo + fooPkg := gnovm.MemPackage{ + Name: "foo", + Path: "gno.land/r/dev/foo", + Files: []*gnovm.MemFile{ + { + Name: "foo.gno", + Body: `package foo var str string = "foo" func UpdateStr(newStr string) { str = newStr } // method to update 'str' variable func Render(_ string) string { return str } -` - ) - - // Generate package foo - foopkg := generateTestingPackage(t, "gno.mod", foobarGnoMod, "foo.gno", fooFile) +`, + }, + }, + } // Call NewDevNode with no package should work - node, emitter := newTestingDevNode(t, foopkg) + node, emitter := newTestingDevNode(t, &fooPkg) assert.Len(t, node.ListPkgs(), 1) // Test rendering - render, err := testingRenderRealm(t, node, "gno.land/r/dev/foo") + render, err := testingRenderRealm(t, node, fooPkg.Path) require.NoError(t, err) require.Equal(t, render, "foo") // Call `UpdateStr` to update `str` value with "bar" msg := vm.MsgCall{ - PkgPath: "gno.land/r/dev/foo", + PkgPath: fooPkg.Path, Func: "UpdateStr", Args: []string{"bar"}, Send: nil, @@ -208,7 +226,7 @@ func Render(_ string) string { return str } assert.Equal(t, emitter.NextEvent().Type(), events.EvtTxResult) // Check for correct render update - render, err = testingRenderRealm(t, node, "gno.land/r/dev/foo") + render, err = testingRenderRealm(t, node, fooPkg.Path) require.NoError(t, err) require.Equal(t, render, "bar") @@ -218,7 +236,7 @@ func Render(_ string) string { return str } assert.Equal(t, emitter.NextEvent().Type(), events.EvtReset) // Test rendering should return initial `str` value - render, err = testingRenderRealm(t, node, "gno.land/r/dev/foo") + render, err = testingRenderRealm(t, node, fooPkg.Path) require.NoError(t, err) require.Equal(t, render, "foo") @@ -226,10 +244,9 @@ func Render(_ string) string { return str } } func TestTxTimestampRecover(t *testing.T) { - const ( - // foo package - foobarGnoMod = "module gno.land/r/dev/foo\n" - fooFile = `package foo + const fooFile = ` +package foo + import ( "strconv" "strings" @@ -259,12 +276,35 @@ func Render(_ string) string { return strs.String() } ` - ) + fooPkg := gnovm.MemPackage{ + Name: "foo", + Path: "gno.land/r/dev/foo", + Files: []*gnovm.MemFile{ + { + Name: "foo.gno", + Body: fooFile, + }, + }, + } // Add a hard deadline of 20 seconds to avoid potential deadlock and fail early ctx, cancel := context.WithTimeout(context.Background(), time.Second*20) defer cancel() + // XXX(gfanton): Setting this to `false` somehow makes the time block + // drift from the time spanned by the VM. + cfg := newTestingNodeConfig(&fooPkg) + cfg.TMConfig.Consensus.SkipTimeoutCommit = false + cfg.TMConfig.Consensus.TimeoutCommit = 500 * time.Millisecond + cfg.TMConfig.Consensus.TimeoutPropose = 100 * time.Millisecond + cfg.TMConfig.Consensus.CreateEmptyBlocks = true + + node, emitter := newTestingDevNodeWithConfig(t, cfg, fooPkg.Path) + + render, err := testingRenderRealm(t, node, fooPkg.Path) + require.NoError(t, err) + require.NotEmpty(t, render) + parseJSONTimesList := func(t *testing.T, render string) []time.Time { t.Helper() @@ -282,21 +322,6 @@ func Render(_ string) string { return times } - // Generate package foo - foopkg := generateTestingPackage(t, "gno.mod", foobarGnoMod, "foo.gno", fooFile) - - // Call NewDevNode with no package should work - cfg := createDefaultTestingNodeConfig(foopkg) - - // XXX(gfanton): Setting this to `false` somehow makes the time block - // drift from the time spanned by the VM. - cfg.TMConfig.Consensus.SkipTimeoutCommit = false - cfg.TMConfig.Consensus.TimeoutCommit = 500 * time.Millisecond - cfg.TMConfig.Consensus.TimeoutPropose = 100 * time.Millisecond - cfg.TMConfig.Consensus.CreateEmptyBlocks = true - - node, emitter := newTestingDevNodeWithConfig(t, cfg) - // We need to make sure that blocks are separated by at least 1 second // (minimal time between blocks). We can ensure this by listening for // new blocks and comparing timestamps @@ -329,7 +354,7 @@ func Render(_ string) string { // Span multiple time for i := 0; i < nevents; i++ { - t.Logf("waiting for a bock greater than height(%d) and unix(%d)", refHeight, refTimestamp) + t.Logf("waiting for a block greater than height(%d) and unix(%d)", refHeight, refTimestamp) for { var block types.EventNewBlock select { @@ -357,7 +382,7 @@ func Render(_ string) string { // Span a new time msg := vm.MsgCall{ - PkgPath: "gno.land/r/dev/foo", + PkgPath: fooPkg.Path, Func: "SpanTime", } @@ -373,7 +398,7 @@ func Render(_ string) string { } // Render JSON times list - render, err := testingRenderRealm(t, node, "gno.land/r/dev/foo") + render, err = testingRenderRealm(t, node, fooPkg.Path) require.NoError(t, err) // Parse times list @@ -396,12 +421,12 @@ func Render(_ string) string { assert.Equal(t, emitter.NextEvent().Type(), events.EvtReload) // Fetch time list again from render - render, err = testingRenderRealm(t, node, "gno.land/r/dev/foo") + render, err = testingRenderRealm(t, node, fooPkg.Path) require.NoError(t, err) timesList2 := parseJSONTimesList(t, render) - // Times list should be identical from the orignal list + // Times list should be identical from the original list require.Len(t, timesList2, len(timesList1)) for i := 0; i < len(timesList1); i++ { t1nsec, t2nsec := timesList1[i].UnixNano(), timesList2[i].UnixNano() @@ -452,42 +477,32 @@ func testingCallRealm(t *testing.T, node *Node, msgs ...vm.MsgCall) (*core_types return cli.Call(txcfg, vmMsgs...) } -func generateTestingPackage(t *testing.T, nameFile ...string) PackagePath { - t.Helper() - workdir := t.TempDir() +func newTestingNodeConfig(pkgs ...*gnovm.MemPackage) *NodeConfig { + var loader packages.BaseLoader + gnoroot := gnoenv.RootDir() - if len(nameFile)%2 != 0 { - require.FailNow(t, "Generate testing packages require paired arguments.") - } - - for i := 0; i < len(nameFile); i += 2 { - name := nameFile[i] - content := nameFile[i+1] - - err := os.WriteFile(filepath.Join(workdir, name), []byte(content), 0o700) - require.NoError(t, err) - } - - return PackagePath{ - Path: workdir, - Creator: nodeTestingAddress, - } -} - -func createDefaultTestingNodeConfig(pkgslist ...PackagePath) *NodeConfig { + loader.Resolver = packages.MiddlewareResolver( + packages.NewMockResolver(pkgs...), + packages.FilterStdlibs) cfg := DefaultNodeConfig(gnoenv.RootDir(), "gno.land") - cfg.PackagesPathList = pkgslist + cfg.TMConfig = integration.DefaultTestingTMConfig(gnoroot) + cfg.Loader = &loader return cfg } -func newTestingDevNode(t *testing.T, pkgslist ...PackagePath) (*Node, *mock.ServerEmitter) { +func newTestingDevNode(t *testing.T, pkgs ...*gnovm.MemPackage) (*Node, *mock.ServerEmitter) { t.Helper() - cfg := createDefaultTestingNodeConfig(pkgslist...) - return newTestingDevNodeWithConfig(t, cfg) + cfg := newTestingNodeConfig(pkgs...) + paths := make([]string, len(pkgs)) + for i, pkg := range pkgs { + paths[i] = pkg.Path + } + + return newTestingDevNodeWithConfig(t, cfg, paths...) } -func newTestingDevNodeWithConfig(t *testing.T, cfg *NodeConfig) (*Node, *mock.ServerEmitter) { +func newTestingDevNodeWithConfig(t *testing.T, cfg *NodeConfig, pkgpaths ...string) (*Node, *mock.ServerEmitter) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) @@ -497,9 +512,9 @@ func newTestingDevNodeWithConfig(t *testing.T, cfg *NodeConfig) (*Node, *mock.Se cfg.Emitter = emitter cfg.Logger = logger - node, err := NewDevNode(ctx, cfg) + node, err := NewDevNode(ctx, cfg, pkgpaths...) require.NoError(t, err) - assert.Len(t, node.ListPkgs(), len(cfg.PackagesPathList)) + require.Equal(t, emitter.NextEvent().Type(), events.EvtReset) t.Cleanup(func() { node.Close() diff --git a/contribs/gnodev/pkg/dev/packages.go b/contribs/gnodev/pkg/dev/packages.go deleted file mode 100644 index 62c1907b8c9..00000000000 --- a/contribs/gnodev/pkg/dev/packages.go +++ /dev/null @@ -1,170 +0,0 @@ -package dev - -import ( - "errors" - "fmt" - "net/url" - "path/filepath" - "time" - - "github.com/gnolang/gno/contribs/gnodev/pkg/address" - "github.com/gnolang/gno/gno.land/pkg/gnoland" - vmm "github.com/gnolang/gno/gno.land/pkg/sdk/vm" - gno "github.com/gnolang/gno/gnovm/pkg/gnolang" - "github.com/gnolang/gno/gnovm/pkg/gnomod" - "github.com/gnolang/gno/tm2/pkg/crypto" - "github.com/gnolang/gno/tm2/pkg/std" -) - -type PackagePath struct { - Path string - Creator crypto.Address - Deposit std.Coins -} - -func ResolvePackagePathQuery(bk *address.Book, path string) (PackagePath, error) { - var ppath PackagePath - - upath, err := url.Parse(path) - if err != nil { - return ppath, fmt.Errorf("malformed path/query: %w", err) - } - ppath.Path = filepath.Clean(upath.Path) - - // Check for creator option - creator := upath.Query().Get("creator") - if creator != "" { - address, err := crypto.AddressFromBech32(creator) - if err != nil { - var ok bool - address, ok = bk.GetByName(creator) - if !ok { - return ppath, fmt.Errorf("invalid name or address for creator %q", creator) - } - } - - ppath.Creator = address - } - - // Check for deposit option - deposit := upath.Query().Get("deposit") - if deposit != "" { - coins, err := std.ParseCoins(deposit) - if err != nil { - return ppath, fmt.Errorf( - "unable to parse deposit amount %q (should be in the form xxxugnot): %w", deposit, err, - ) - } - - ppath.Deposit = coins - } - - return ppath, nil -} - -type Package struct { - gnomod.Pkg - Creator crypto.Address - Deposit std.Coins -} - -type PackagesMap map[string]Package - -var ( - ErrEmptyCreatorPackage = errors.New("no creator specified for package") - ErrEmptyDepositPackage = errors.New("no deposit specified for package") -) - -func NewPackagesMap(ppaths []PackagePath) (PackagesMap, error) { - pkgs := make(map[string]Package) - for _, ppath := range ppaths { - if ppath.Creator.IsZero() { - return nil, fmt.Errorf("unable to load package %q: %w", ppath.Path, ErrEmptyCreatorPackage) - } - - abspath, err := filepath.Abs(ppath.Path) - if err != nil { - return nil, fmt.Errorf("unable to guess absolute path for %q: %w", ppath.Path, err) - } - - // list all packages from target path - pkgslist, err := gnomod.ListPkgs(abspath) - if err != nil { - return nil, fmt.Errorf("listing gno packages: %w", err) - } - - for _, pkg := range pkgslist { - if pkg.Dir == "" { - continue - } - - if _, ok := pkgs[pkg.Dir]; ok { - continue // skip - } - pkgs[pkg.Dir] = Package{ - Pkg: pkg, - Creator: ppath.Creator, - Deposit: ppath.Deposit, - } - } - } - - return pkgs, nil -} - -func (pm PackagesMap) toList() gnomod.PkgList { - list := make([]gnomod.Pkg, 0, len(pm)) - for _, pkg := range pm { - list = append(list, pkg.Pkg) - } - return list -} - -func (pm PackagesMap) Load(fee std.Fee, start time.Time) ([]gnoland.TxWithMetadata, error) { - pkgs := pm.toList() - - sorted, err := pkgs.Sort() - if err != nil { - return nil, fmt.Errorf("unable to sort pkgs: %w", err) - } - - nonDraft := sorted.GetNonDraftPkgs() - - metatxs := make([]gnoland.TxWithMetadata, 0, len(nonDraft)) - for _, modPkg := range nonDraft { - pkg := pm[modPkg.Dir] - if pkg.Creator.IsZero() { - return nil, fmt.Errorf("no creator set for %q", pkg.Dir) - } - - // Open files in directory as MemPackage. - memPkg := gno.MustReadMemPackage(modPkg.Dir, modPkg.Name) - if err := memPkg.Validate(); err != nil { - return nil, fmt.Errorf("invalid package: %w", err) - } - - // Create transaction - tx := std.Tx{ - Fee: fee, - Msgs: []std.Msg{ - vmm.MsgAddPackage{ - Creator: pkg.Creator, - Deposit: pkg.Deposit, - Package: memPkg, - }, - }, - } - - tx.Signatures = make([]std.Signature, len(tx.GetSigners())) - metatx := gnoland.TxWithMetadata{ - Tx: tx, - Metadata: &gnoland.GnoTxMetadata{ - Timestamp: start.Unix(), - }, - } - - metatxs = append(metatxs, metatx) - } - - return metatxs, nil -} diff --git a/contribs/gnodev/pkg/dev/packages_test.go b/contribs/gnodev/pkg/dev/packages_test.go deleted file mode 100644 index 151a89a7815..00000000000 --- a/contribs/gnodev/pkg/dev/packages_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package dev - -import ( - "testing" - - "github.com/gnolang/gno/contribs/gnodev/pkg/address" - "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" - "github.com/gnolang/gno/tm2/pkg/crypto" - "github.com/gnolang/gno/tm2/pkg/std" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestResolvePackagePathQuery(t *testing.T) { - t.Parallel() - - var ( - testingName = "testAccount" - testingAddress = crypto.MustAddressFromString("g1hr3dl82qdy84a5h3dmckh0suc7zgwm5rnns6na") - ) - - book := address.NewBook() - book.Add(testingAddress, testingName) - - cases := []struct { - Path string - ExpectedPackagePath PackagePath - ShouldFail bool - }{ - { - Path: ".", - ExpectedPackagePath: PackagePath{ - Path: ".", - }, - }, - { - Path: "/simple/path", - ExpectedPackagePath: PackagePath{ - Path: "/simple/path", - }, - }, - { - Path: "/ambiguo/u//s/path///", - ExpectedPackagePath: PackagePath{ - Path: "/ambiguo/u/s/path", - }, - }, - { - Path: "/path/with/creator?creator=testAccount", - ExpectedPackagePath: PackagePath{ - Path: "/path/with/creator", - Creator: testingAddress, - }, - }, - { - Path: "/path/with/deposit?deposit=" + ugnot.ValueString(100), - ExpectedPackagePath: PackagePath{ - Path: "/path/with/deposit", - Deposit: std.MustParseCoins(ugnot.ValueString(100)), - }, - }, - { - Path: ".?creator=g1hr3dl82qdy84a5h3dmckh0suc7zgwm5rnns6na&deposit=" + ugnot.ValueString(100), - ExpectedPackagePath: PackagePath{ - Path: ".", - Creator: testingAddress, - Deposit: std.MustParseCoins(ugnot.ValueString(100)), - }, - }, - - // errors cases - { - Path: "/invalid/account?creator=UnknownAccount", - ShouldFail: true, - }, - { - Path: "/invalid/address?creator=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", - ShouldFail: true, - }, - { - Path: "/invalid/deposit?deposit=abcd", - ShouldFail: true, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.Path, func(t *testing.T) { - t.Parallel() - - result, err := ResolvePackagePathQuery(book, tc.Path) - if tc.ShouldFail { - assert.Error(t, err) - return - } - require.NoError(t, err) - - assert.Equal(t, tc.ExpectedPackagePath.Path, result.Path) - assert.Equal(t, tc.ExpectedPackagePath.Creator, result.Creator) - assert.Equal(t, tc.ExpectedPackagePath.Deposit.String(), result.Deposit.String()) - }) - } -} diff --git a/contribs/gnodev/pkg/dev/query_path.go b/contribs/gnodev/pkg/dev/query_path.go new file mode 100644 index 00000000000..e899d8212e4 --- /dev/null +++ b/contribs/gnodev/pkg/dev/query_path.go @@ -0,0 +1,58 @@ +package dev + +import ( + "fmt" + "net/url" + "path/filepath" + + "github.com/gnolang/gno/contribs/gnodev/pkg/address" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/std" +) + +type QueryPath struct { + Path string + Creator crypto.Address + Deposit std.Coins +} + +func ResolveQueryPath(bk *address.Book, query string) (QueryPath, error) { + var qpath QueryPath + + upath, err := url.Parse(query) + if err != nil { + return qpath, fmt.Errorf("malformed path/query: %w", err) + } + + qpath.Path = filepath.Clean(upath.Path) + + // Check for creator option + creator := upath.Query().Get("creator") + if creator != "" { + address, err := crypto.AddressFromBech32(creator) + if err != nil { + var ok bool + address, ok = bk.GetByName(creator) + if !ok { + return qpath, fmt.Errorf("invalid name or address for creator %q", creator) + } + } + + qpath.Creator = address + } + + // Check for deposit option + deposit := upath.Query().Get("deposit") + if deposit != "" { + coins, err := std.ParseCoins(deposit) + if err != nil { + return qpath, fmt.Errorf( + "unable to parse deposit amount %q (should be in the form xxxugnot): %w", deposit, err, + ) + } + + qpath.Deposit = coins + } + + return qpath, nil +} diff --git a/contribs/gnodev/pkg/dev/query_path_test.go b/contribs/gnodev/pkg/dev/query_path_test.go new file mode 100644 index 00000000000..519edcc7739 --- /dev/null +++ b/contribs/gnodev/pkg/dev/query_path_test.go @@ -0,0 +1,132 @@ +package dev_test + +import ( + "testing" + + "github.com/gnolang/gno/contribs/gnodev/pkg/address" + "github.com/gnolang/gno/contribs/gnodev/pkg/dev" + "github.com/gnolang/gno/gno.land/pkg/integration" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolvePackageModifierQuery(t *testing.T) { + validAddr := crypto.MustAddressFromString(integration.DefaultAccount_Address) + validBech32Addr := validAddr.String() + validCoins := std.MustParseCoins("100ugnot") + + tests := []struct { + name string + path string + book *address.Book + wantQuery dev.QueryPath + wantErrMsg string + }{ + { + name: "valid creator bech32", + path: "abc.xy/some/path?creator=" + validBech32Addr, + book: address.NewBook(), + wantQuery: dev.QueryPath{ + Path: "abc.xy/some/path", + Creator: validAddr, + }, + }, + + { + name: "valid creator name", + path: "abc.xy/path?creator=alice", + book: func() *address.Book { + bk := address.NewBook() + bk.Add(validAddr, "alice") + return bk + }(), + wantQuery: dev.QueryPath{ + Path: "abc.xy/path", + Creator: validAddr, + }, + }, + + { + name: "invalid creator", + path: "abc.xy/path?creator=bob", + book: address.NewBook(), + wantErrMsg: `invalid name or address for creator "bob"`, + }, + + { + name: "invalid bech32 creator", + path: "abc.xy/path?creator=invalid", + book: address.NewBook(), + wantErrMsg: `invalid name or address for creator "invalid"`, + }, + + { + name: "valid deposit", + path: "abc.xy/path?deposit=100ugnot", + book: address.NewBook(), + wantQuery: dev.QueryPath{ + Path: "abc.xy/path", + Deposit: validCoins, + }, + }, + + { + name: "invalid deposit", + path: "abc.xy/path?deposit=invalid", + book: address.NewBook(), + wantErrMsg: `unable to parse deposit amount "invalid" (should be in the form xxxugnot)`, + }, + + { + name: "both creator and deposit", + path: "abc.xy/path?creator=" + validBech32Addr + "&deposit=100ugnot", + book: address.NewBook(), + wantQuery: dev.QueryPath{ + Path: "abc.xy/path", + Creator: validAddr, + Deposit: validCoins, + }, + }, + + { + name: "malformed path", + path: "://invalid", + book: address.NewBook(), + wantErrMsg: "malformed path/query", + }, + + { + name: "no creator or deposit", + path: "abc.xy/path", + book: address.NewBook(), + wantQuery: dev.QueryPath{ + Path: "abc.xy/path", + }, + }, + + { + name: "clean path with ..", + path: "abc.xy/foo/../bar", + book: address.NewBook(), + wantQuery: dev.QueryPath{ + Path: "abc.xy/bar", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotQuery, err := dev.ResolveQueryPath(tt.book, tt.path) + if tt.wantErrMsg != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrMsg) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantQuery, gotQuery) + }) + } +} diff --git a/contribs/gnodev/pkg/emitter/server.go b/contribs/gnodev/pkg/emitter/server.go index 3e32984268d..29f8e3050ba 100644 --- a/contribs/gnodev/pkg/emitter/server.go +++ b/contribs/gnodev/pkg/emitter/server.go @@ -1,6 +1,7 @@ package emitter import ( + "encoding/json" "log/slog" "net/http" "sync" @@ -32,6 +33,10 @@ func NewServer(logger *slog.Logger) *Server { } } +func (s *Server) LockEmit() { s.muClients.Lock() } + +func (s *Server) UnlockEmit() { s.muClients.Unlock() } + // ws handler func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { conn, err := s.upgrader.Upgrade(w, r, nil) @@ -69,13 +74,9 @@ func (s *Server) emit(evt events.Event) { s.muClients.Lock() defer s.muClients.Unlock() - jsonEvt := EventJSON{evt.Type(), evt} - - s.logger.Info("sending event to clients", - "clients", len(s.clients), - "type", evt.Type(), - "event", evt) + s.logEvent(evt) + jsonEvt := EventJSON{evt.Type(), evt} for conn := range s.clients { err := conn.WriteJSON(jsonEvt) if err != nil { @@ -96,3 +97,15 @@ func (s *Server) conns() []*websocket.Conn { return conns } + +func (s *Server) logEvent(evt events.Event) { + var logEvt string + if rawEvt, err := json.Marshal(evt); err == nil { + logEvt = string(rawEvt) + } + + s.logger.Info("sending event to clients", + "clients", len(s.clients), + "type", evt.Type(), + "event", logEvt) +} diff --git a/contribs/gnodev/pkg/emitter/static/hotreload.js b/contribs/gnodev/pkg/emitter/static/hotreload.js index 28e47c1ea15..7b58fc35004 100644 --- a/contribs/gnodev/pkg/emitter/static/hotreload.js +++ b/contribs/gnodev/pkg/emitter/static/hotreload.js @@ -1,19 +1,34 @@ -(function() { +document.addEventListener('DOMContentLoaded', function() { // Define the events that will trigger a page reload const eventsReload = [ {{range .ReloadEvents}}'{{.}}',{{end}} ]; - + // Establish the WebSocket connection to the event server const ws = new WebSocket('ws://{{- .Remote -}}'); - + // `gracePeriod` mitigates reload loops due to excessive events. This period // occurs post-loading and lasts for the `graceTimeout` duration. const graceTimeout = 1000; // ms let gracePeriod = true; let debounceTimeout = setTimeout(function() { gracePeriod = false; - }, graceTimeout); + }, graceTimeout); + + // Flag to track if a link click is in progress + let clickInProgress = false; + + // Capture clicks on tags to prevent reloading appening when clicking on link + document.addEventListener('click', function(event) { + const target = event.target; + if (target.tagName === 'A' && target.href) { + clickInProgress = true; + // Wait a bit before allowing reload again + setTimeout(function() { + clickInProgress = false; + }, 5000); + } + }); // Handle incoming WebSocket messages ws.onmessage = function(event) { @@ -23,19 +38,21 @@ // Ignore events not in the reload-triggering list if (!eventsReload.includes(message.type)) { - return; + return; } - // Reload the page immediately if we're not in the grace period - if (!gracePeriod) { + // Reload the page immediately if we're not in the grace period and no clicks are in progress + if (!gracePeriod && !clickInProgress) { window.location.reload(); return; } - // If still in the grace period, debounce the reload + // If still in the grace period or a click is in progress, debounce the reload clearTimeout(debounceTimeout); debounceTimeout = setTimeout(function() { - window.location.reload(); + if (!clickInProgress) { + window.location.reload(); + } }, graceTimeout); } catch (e) { @@ -50,4 +67,4 @@ ws.onclose = function() { console.log('WebSocket connection closed'); }; -})(); +}); diff --git a/contribs/gnodev/pkg/logger/log_column.go b/contribs/gnodev/pkg/logger/log_column.go index 2a720525903..0e6c181ad6d 100644 --- a/contribs/gnodev/pkg/logger/log_column.go +++ b/contribs/gnodev/pkg/logger/log_column.go @@ -21,7 +21,9 @@ func NewColumnLogger(w io.Writer, level slog.Level, profile termenv.Profile) *Co }) // Default column output - defaultOutput := newColumeWriter(lipgloss.NewStyle(), "", w) + renderer := lipgloss.NewRenderer(nil, termenv.WithProfile(profile)) + + defaultOutput := newColumeWriter(w, lipgloss.NewStyle(), "") charmLogger.SetOutput(defaultOutput) charmLogger.SetStyles(defaultStyles()) charmLogger.SetColorProfile(profile) @@ -40,10 +42,12 @@ func NewColumnLogger(w io.Writer, level slog.Level, profile termenv.Profile) *Co } return &ColumnLogger{ - Logger: charmLogger, - writer: w, - prefix: charmLogger.GetPrefix(), - colors: map[string]lipgloss.Color{}, + Logger: charmLogger, + writer: w, + prefix: charmLogger.GetPrefix(), + colors: map[string]lipgloss.Color{}, + colorProfile: profile, + renderer: renderer, } } @@ -52,6 +56,7 @@ type ColumnLogger struct { prefix string writer io.Writer + renderer *lipgloss.Renderer colorProfile termenv.Profile colors map[string]lipgloss.Color @@ -72,10 +77,11 @@ func (cl *ColumnLogger) WithGroup(group string) slog.Handler { // generate bright color based on the group name fg = colorFromString(group, 0.5, 0.6) } - baseStyle := lipgloss.NewStyle().Foreground(fg) + + baseStyle := lipgloss.NewStyle().Foreground(fg).Renderer(cl.renderer) nlog := cl.Logger.With() // clone logger - nlog.SetOutput(newColumeWriter(baseStyle, group, cl.writer)) + nlog.SetOutput(newColumeWriter(cl.writer, baseStyle, group)) nlog.SetColorProfile(cl.colorProfile) return &ColumnLogger{ Logger: nlog, @@ -99,7 +105,7 @@ type columnWriter struct { writer io.Writer } -func newColumeWriter(baseStyle lipgloss.Style, prefix string, writer io.Writer) *columnWriter { +func newColumeWriter(w io.Writer, baseStyle lipgloss.Style, prefix string) *columnWriter { const width = 12 style := baseStyle. @@ -112,7 +118,7 @@ func newColumeWriter(baseStyle lipgloss.Style, prefix string, writer io.Writer) prefix = prefix[:width-3] + "..." } - return &columnWriter{style: style, prefix: prefix, writer: writer} + return &columnWriter{style: style, prefix: prefix, writer: w} } func (cl *columnWriter) Write(buf []byte) (n int, err error) { diff --git a/contribs/gnodev/pkg/packages/glob.go b/contribs/gnodev/pkg/packages/glob.go new file mode 100644 index 00000000000..1b76425deb4 --- /dev/null +++ b/contribs/gnodev/pkg/packages/glob.go @@ -0,0 +1,214 @@ +// Inspired by: https://cs.opensource.google/go/x/tools/+/master:gopls/internal/test/integration/fake/glob/glob.go + +package packages + +import ( + "errors" + "fmt" + "strings" +) + +var ErrAdjacentSlash = errors.New("** may only be adjacent to '/'") + +// Glob patterns can have the following syntax: +// - `*` to match one or more characters in a path segment +// - `**` to match any number of path segments, including none +// +// Expanding on this: +// - '/' matches one or more literal slashes. +// - any other character matches itself literally. +type Glob struct { + elems []element // pattern elements +} + +// Parse builds a Glob for the given pattern, returning an error if the pattern +// is invalid. +func Parse(pattern string) (*Glob, error) { + g, _, err := parse(pattern) + return g, err +} + +func parse(pattern string) (*Glob, string, error) { + g := new(Glob) + for len(pattern) > 0 { + switch pattern[0] { + case '/': + // Skip consecutive slashes + for len(pattern) > 0 && pattern[0] == '/' { + pattern = pattern[1:] + } + g.elems = append(g.elems, slash{}) + + case '*': + if len(pattern) > 1 && pattern[1] == '*' { + if (len(g.elems) > 0 && g.elems[len(g.elems)-1] != slash{}) || (len(pattern) > 2 && pattern[2] != '/') { + return nil, "", ErrAdjacentSlash + } + pattern = pattern[2:] + g.elems = append(g.elems, starStar{}) + break + } + pattern = pattern[1:] + g.elems = append(g.elems, star{}) + + default: + pattern = g.parseLiteral(pattern) + } + } + return g, "", nil +} + +func (g *Glob) parseLiteral(pattern string) string { + end := strings.IndexAny(pattern, "*/") + if end == -1 { + end = len(pattern) + } + g.elems = append(g.elems, literal(pattern[:end])) + return pattern[end:] +} + +func (g *Glob) String() string { + var b strings.Builder + for _, e := range g.elems { + fmt.Fprint(&b, e) + } + return b.String() +} + +func (g *Glob) StarFreeBase() string { + var b strings.Builder + for _, e := range g.elems { + if e == (star{}) || e == (starStar{}) { + break + } + fmt.Fprint(&b, e) + } + return b.String() +} + +// element holds a glob pattern element, as defined below. +type element fmt.Stringer + +// element types. +type ( + slash struct{} // One or more '/' separators + literal string // string literal, not containing / or * + star struct{} // * + starStar struct{} // ** +) + +func (s slash) String() string { return "/" } +func (l literal) String() string { return string(l) } +func (s star) String() string { return "*" } +func (s starStar) String() string { return "**" } + +// Match reports whether the input string matches the glob pattern. +func (g *Glob) Match(input string) bool { + return match(g.elems, input) +} + +func match(elems []element, input string) (ok bool) { + var elem interface{} + for len(elems) > 0 { + elem, elems = elems[0], elems[1:] + switch elem := elem.(type) { + case slash: + // Skip consecutive slashes in the input + if len(input) == 0 || input[0] != '/' { + return false + } + for len(input) > 0 && input[0] == '/' { + input = input[1:] + } + + case starStar: + // Special cases: + // - **/a matches "a" + // - **/ matches everything + // + // Note that if ** is followed by anything, it must be '/' (this is + // enforced by Parse). + if len(elems) > 0 { + elems = elems[1:] + } + + // A trailing ** matches anything. + if len(elems) == 0 { + return true + } + + // Backtracking: advance pattern segments until the remaining pattern + // elements match. + for len(input) != 0 { + if match(elems, input) { + return true + } + _, input = split(input) + } + return false + + case literal: + if !strings.HasPrefix(input, string(elem)) { + return false + } + input = input[len(elem):] + + case star: + var segInput string + segInput, input = split(input) + + elemEnd := len(elems) + for i, e := range elems { + if e == (slash{}) { + elemEnd = i + break + } + } + segElems := elems[:elemEnd] + elems = elems[elemEnd:] + + // A trailing * matches the entire segment. + if len(segElems) == 0 { + if len(elems) > 0 && elems[0] == (slash{}) { + elems = elems[1:] // shift elems + } + break + } + + // Backtracking: advance characters until remaining subpattern elements + // match. + matched := false + for i := range segInput { + if match(segElems, segInput[i:]) { + matched = true + break + } + } + if !matched { + return false + } + + default: + panic(fmt.Sprintf("segment type %T not implemented", elem)) + } + } + + return len(input) == 0 +} + +// split returns the portion before and after the first slash +// (or sequence of consecutive slashes). If there is no slash +// it returns (input, nil). +func split(input string) (first, rest string) { + i := strings.IndexByte(input, '/') + if i < 0 { + return input, "" + } + first = input[:i] + for j := i; j < len(input); j++ { + if input[j] != '/' { + return first, input[j:] + } + } + return first, "" +} diff --git a/contribs/gnodev/pkg/packages/glob_test.go b/contribs/gnodev/pkg/packages/glob_test.go new file mode 100644 index 00000000000..7fad4eb2fe1 --- /dev/null +++ b/contribs/gnodev/pkg/packages/glob_test.go @@ -0,0 +1,93 @@ +// Inspired by: https://cs.opensource.google/go/x/tools/+/master:gopls/internal/test/integration/fake/glob/glob_test.go + +package packages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatch(t *testing.T) { + t.Parallel() + + tests := []struct { + pattern, input string + want bool + }{ + // Basic cases. + {"", "", true}, + {"", "a", false}, + {"", "/", false}, + {"abc", "abc", true}, + + // ** behavior + {"**", "abc", true}, + {"**/abc", "abc", true}, + {"**", "abc/def", true}, + + // * behavior + {"/*", "/a", true}, + {"*", "foo", true}, + {"*o", "foo", true}, + {"*o", "foox", false}, + {"f*o", "foo", true}, + {"f*o", "fo", true}, + + // Dirs cases + {"**/", "path/to/foo/", true}, + {"**/", "path/to/foo", true}, + + {"path/to/foo", "path/to/foo", true}, + {"path/to/foo", "path/to/bar", false}, + {"path/*/foo", "path/to/foo", true}, + {"path/*/1/*/3/*/5*/foo", "path/to/1/2/3/4/522/foo", true}, + {"path/*/1/*/3/*/5*/foo", "path/to/1/2/3/4/722/foo", false}, + {"path/*/1/*/3/*/5*/foo", "path/to/1/2/3/4/522/bar", false}, + {"path/*/foo", "path/to/to/foo", false}, + {"path/**/foo", "path/to/to/foo", true}, + {"path/**/foo", "path/to/to/bar", false}, + {"path/**/foo", "path/foo", true}, + {"**/abc/**", "foo/r/x/abc/bar", true}, + + // Realistic examples. + {"**/*.ts", "path/to/foo.ts", true}, + {"**/*.js", "path/to/foo.js", true}, + {"**/*.go", "path/to/foo.go", true}, + } + + for _, test := range tests { + g, err := Parse(test.pattern) + require.NoErrorf(t, err, "Parse(%q) failed unexpectedly: %v", test.pattern, err) + assert.Equalf(t, test.want, g.Match(test.input), + "Parse(%q).Match(%q) = %t, want %t", test.pattern, test.input, !test.want, test.want) + } +} + +func TestBaseFreeStar(t *testing.T) { + t.Parallel() + + tests := []struct { + pattern, baseFree string + }{ + // Basic cases. + {"", ""}, + {"foo", "foo"}, + {"foo/bar", "foo/bar"}, + {"foo///bar", "foo/bar"}, + {"foo/bar/", "foo/bar/"}, + {"foo/bar/*/*/z", "foo/bar/"}, + {"foo/bar/**", "foo/bar/"}, + {"**", ""}, + {"/**", "/"}, + } + + for _, test := range tests { + g, err := Parse(test.pattern) + require.NoErrorf(t, err, "Parse(%q) failed unexpectedly: %v", test.pattern, err) + got := g.StarFreeBase() + assert.Equalf(t, test.baseFree, got, + "Parse(%q).Match(%q) = %q, want %q", test.pattern, test.baseFree, got, test.baseFree) + } +} diff --git a/contribs/gnodev/pkg/packages/loader.go b/contribs/gnodev/pkg/packages/loader.go new file mode 100644 index 00000000000..3bc978721e6 --- /dev/null +++ b/contribs/gnodev/pkg/packages/loader.go @@ -0,0 +1,12 @@ +package packages + +type Loader interface { + // Load resolves package package paths and all their dependencies in the correct order. + Load(paths ...string) ([]Package, error) + + // Resolve processes a single package path and returns the corresponding Package. + Resolve(path string) (*Package, error) + + // Name of the loader + Name() string +} diff --git a/contribs/gnodev/pkg/packages/loader_base.go b/contribs/gnodev/pkg/packages/loader_base.go new file mode 100644 index 00000000000..039932bd400 --- /dev/null +++ b/contribs/gnodev/pkg/packages/loader_base.go @@ -0,0 +1,104 @@ +package packages + +import ( + "errors" + "fmt" + "go/parser" + "go/token" +) + +type BaseLoader struct { + Resolver +} + +func NewLoader(res ...Resolver) *BaseLoader { + return &BaseLoader{ChainResolvers(res...)} +} + +func (l BaseLoader) Name() string { + return l.Resolver.Name() +} + +func (l BaseLoader) Load(paths ...string) ([]Package, error) { + fset := token.NewFileSet() + visited, stack := map[string]bool{}, map[string]bool{} + pkgs := make([]Package, 0) + for _, root := range paths { + deps, err := load(root, fset, l.Resolver, visited, stack) + if err != nil { + return nil, err + } + pkgs = append(pkgs, deps...) + } + + return pkgs, nil +} + +func (l BaseLoader) Resolve(path string) (*Package, error) { + fset := token.NewFileSet() + return l.Resolver.Resolve(fset, path) +} + +func load(path string, fset *token.FileSet, resolver Resolver, visited, stack map[string]bool) ([]Package, error) { + if stack[path] { + return nil, fmt.Errorf("cycle detected: %s", path) + } + if visited[path] { + return nil, nil + } + + visited[path] = true + + mempkg, err := resolver.Resolve(fset, path) + if err != nil { + if errors.Is(err, ErrResolverPackageSkip) { + return nil, nil + } + + return nil, fmt.Errorf("unable to resolve package %q: %w", path, err) + } + + var name string + imports := map[string]struct{}{} + for _, file := range mempkg.Files { + fname := file.Name + if !isGnoFile(fname) || isTestFile(fname) { + continue + } + + f, err := parser.ParseFile(fset, fname, file.Body, parser.ImportsOnly) + if err != nil { + return nil, fmt.Errorf("unable to parse file %q: %w", file.Name, err) + } + + if name != "" && name != f.Name.Name { + return nil, fmt.Errorf("conflict package name between %q and %q", name, f.Name.Name) + } + + for _, imp := range f.Imports { + if len(imp.Path.Value) <= 2 { + continue + } + + val := imp.Path.Value[1 : len(imp.Path.Value)-1] + imports[val] = struct{}{} + } + + name = f.Name.Name + } + + pkgs := []Package{} + for imp := range imports { + subDeps, err := load(imp, fset, resolver, visited, stack) + if err != nil { + return nil, fmt.Errorf("importing %q: %w", imp, err) + } + + pkgs = append(pkgs, subDeps...) + } + pkgs = append(pkgs, *mempkg) + + stack[path] = false + + return pkgs, nil +} diff --git a/contribs/gnodev/pkg/packages/loader_glob.go b/contribs/gnodev/pkg/packages/loader_glob.go new file mode 100644 index 00000000000..dabfe613574 --- /dev/null +++ b/contribs/gnodev/pkg/packages/loader_glob.go @@ -0,0 +1,94 @@ +package packages + +import ( + "fmt" + "go/token" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type GlobLoader struct { + Root string + Resolver Resolver +} + +func NewGlobLoader(rootpath string, res ...Resolver) *GlobLoader { + return &GlobLoader{rootpath, ChainResolvers(res...)} +} + +func (l GlobLoader) Name() string { + return l.Resolver.Name() +} + +func (l GlobLoader) MatchPaths(globs ...string) ([]string, error) { + if l.Root == "" { + return globs, nil + } + + if _, err := os.Stat(l.Root); err != nil { + return nil, fmt.Errorf("unable to stat root: %w", err) + } + + mpaths := []string{} + for _, input := range globs { + cleanInput := filepath.Clean(input) + gpath, err := Parse(cleanInput) + if err != nil { + return nil, fmt.Errorf("invalid glob path %q: %w", input, err) + } + + base := gpath.StarFreeBase() + if base == cleanInput { + mpaths = append(mpaths, base) + continue + } + + // root := filepath.Join(l.Root, base) + root := l.Root + err = filepath.WalkDir(root, func(dirpath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, relErr := filepath.Rel(root, dirpath) + if relErr != nil { + return relErr + } + + if !d.IsDir() { + return nil + } + + if strings.HasPrefix(d.Name(), ".") { + return fs.SkipDir + } + + if gpath.Match(relPath) { + mpaths = append(mpaths, relPath) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("walking directory %q: %w", root, err) + } + } + + return mpaths, nil +} + +func (l GlobLoader) Load(gpaths ...string) ([]Package, error) { + paths, err := l.MatchPaths(gpaths...) + if err != nil { + return nil, fmt.Errorf("match glob pattern error: %w", err) + } + + loader := &BaseLoader{Resolver: l.Resolver} + return loader.Load(paths...) +} + +func (l GlobLoader) Resolve(path string) (*Package, error) { + return l.Resolver.Resolve(token.NewFileSet(), path) +} diff --git a/contribs/gnodev/pkg/packages/loader_test.go b/contribs/gnodev/pkg/packages/loader_test.go new file mode 100644 index 00000000000..1fa338587b0 --- /dev/null +++ b/contribs/gnodev/pkg/packages/loader_test.go @@ -0,0 +1,83 @@ +package packages + +import ( + "testing" + + "github.com/gnolang/gno/gnovm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoader_LoadWithDeps(t *testing.T) { + t.Parallel() + + fsresolver := NewRootResolver("./testdata") + loader := NewLoader(fsresolver) + + // package c depend on package b + pkgs, err := loader.Load(TestdataPkgC) + require.NoError(t, err) + require.Len(t, pkgs, 3) + for i, path := range []string{TestdataPkgA, TestdataPkgB, TestdataPkgC} { + assert.Equal(t, path, pkgs[i].Path) + } +} + +func TestLoader_ResolverPriority(t *testing.T) { + t.Parallel() + + const commonPath = "abc.yz/pkg/a" + + pkgA := gnovm.MemPackage{Name: "pkga", Path: commonPath} + resolverA := NewMockResolver(&pkgA) + + pkgB := gnovm.MemPackage{Name: "pkgb", Path: commonPath} + resolverB := NewMockResolver(&pkgB) + + t.Run("pkgA then pkgB", func(t *testing.T) { + t.Parallel() + + loader := NewLoader(resolverA, resolverB) + pkg, err := loader.Resolve(commonPath) + require.NoError(t, err) + require.Equal(t, pkgA.Name, pkg.Name) + require.Equal(t, commonPath, pkg.Path) + }) + + t.Run("pkgB then pkgA", func(t *testing.T) { + t.Parallel() + + loader := NewLoader(resolverB, resolverA) + pkg, err := loader.Resolve(commonPath) + require.NoError(t, err) + require.Equal(t, pkgB.Name, pkg.Name) + require.Equal(t, commonPath, pkg.Path) + }) +} + +func TestLoader_Glob(t *testing.T) { + const root = "./testdata" + cases := []struct { + GlobPath string + PkgResults []string + }{ + {"abc.xy/pkg/*", []string{TestdataPkgA, TestdataPkgB, TestdataPkgC}}, + {"abc.xy/nested/*", []string{TestdataNestedA}}, + {"abc.xy/**/cc", []string{TestdataNestedC, TestdataPkgA, TestdataPkgB, TestdataPkgC}}, + {"abc.xy/*/aa", []string{TestdataNestedA, TestdataPkgA}}, + } + + fsresolver := NewRootResolver("./testdata") + globloader := NewGlobLoader("./testdata", fsresolver) + + for _, tc := range cases { + t.Run(tc.GlobPath, func(t *testing.T) { + pkgs, err := globloader.Load(tc.GlobPath) + require.NoError(t, err) + require.Len(t, pkgs, len(tc.PkgResults)) + for i, expected := range tc.PkgResults { + assert.Equal(t, expected, pkgs[i].Path) + } + }) + } +} diff --git a/contribs/gnodev/pkg/packages/package.go b/contribs/gnodev/pkg/packages/package.go new file mode 100644 index 00000000000..d6aa532ce64 --- /dev/null +++ b/contribs/gnodev/pkg/packages/package.go @@ -0,0 +1,102 @@ +package packages + +import ( + "fmt" + "go/parser" + "go/token" + "os" + "path/filepath" + + "github.com/gnolang/gno/gnovm" + "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/gnolang/gno/gnovm/pkg/gnomod" +) + +type PackageKind int + +const ( + PackageKindOther = iota + PackageKindRemote = iota + PackageKindFS +) + +type Package struct { + gnovm.MemPackage + Kind PackageKind + Location string +} + +func ReadPackageFromDir(fset *token.FileSet, path, dir string) (*Package, error) { + modpath := filepath.Join(dir, "gno.mod") + if _, err := os.Stat(modpath); err == nil { + draft, err := isDraftFile(modpath) + if err != nil { + return nil, err + } + + // Skip draft package + // XXX: We could potentially do that in a middleware, but doing this + // here avoid to potentially parse broken files + if draft { + return nil, ErrResolverPackageSkip + } + } + + mempkg, err := gnolang.ReadMemPackage(dir, path) + switch { + case err == nil: // ok + case os.IsNotExist(err): + return nil, ErrResolverPackageNotFound + default: + return nil, fmt.Errorf("unable to read package %q: %w", dir, err) + } + + if err := validateMemPackage(fset, mempkg); err != nil { + return nil, err + } + + return &Package{ + MemPackage: *mempkg, + Location: dir, + Kind: PackageKindFS, + }, nil +} + +func validateMemPackage(fset *token.FileSet, mempkg *gnovm.MemPackage) error { + if mempkg.IsEmpty() { + return fmt.Errorf("empty package: %w", ErrResolverPackageSkip) + } + + // Validate package name + for _, file := range mempkg.Files { + if !isGnoFile(file.Name) || isTestFile(file.Name) { + continue + } + + f, err := parser.ParseFile(fset, file.Name, file.Body, parser.PackageClauseOnly) + if err != nil { + return fmt.Errorf("unable to parse file %q: %w", file.Name, err) + } + + if f.Name.Name != mempkg.Name { + return fmt.Errorf("%q package name conflict, expected %q found %q", + mempkg.Path, mempkg.Name, f.Name.Name) + } + } + + return nil +} + +func isDraftFile(modpath string) (bool, error) { + modfile, err := os.ReadFile(modpath) + if err != nil { + return false, fmt.Errorf("unable to read file %q: %w", modpath, err) + } + + mod, err := gnomod.Parse(modpath, modfile) + if err != nil { + return false, fmt.Errorf("unable to parse `gno.mod`: %w", err) + } + + return mod.Draft, nil +} diff --git a/contribs/gnodev/pkg/packages/resolver.go b/contribs/gnodev/pkg/packages/resolver.go new file mode 100644 index 00000000000..9ed9269b6d8 --- /dev/null +++ b/contribs/gnodev/pkg/packages/resolver.go @@ -0,0 +1,234 @@ +package packages + +import ( + "errors" + "fmt" + "go/parser" + "go/scanner" + "go/token" + "log/slog" + "strings" + "time" +) + +var ( + ErrResolverPackageNotFound = errors.New("package not found") + ErrResolverPackageSkip = errors.New("package has been skip") +) + +type Resolver interface { + Name() string + Resolve(fset *token.FileSet, path string) (*Package, error) +} + +type NoopResolver struct{} + +func (NoopResolver) Name() string { return "" } +func (NoopResolver) Resolve(fset *token.FileSet, path string) (*Package, error) { + return nil, ErrResolverPackageNotFound +} + +// Chain Resolver + +type ChainedResolver []Resolver + +func ChainResolvers(rs ...Resolver) Resolver { + switch len(rs) { + case 0: + return &NoopResolver{} + case 1: + return rs[0] + default: + return ChainedResolver(rs) + } +} + +func (cr ChainedResolver) Name() string { + names := make([]string, 0, len(cr)) + for _, r := range cr { + rname := r.Name() + if rname == "" { + continue + } + + names = append(names, rname) + } + + return strings.Join(names, "/") +} + +func (cr ChainedResolver) Resolve(fset *token.FileSet, path string) (*Package, error) { + for _, resolver := range cr { + pkg, err := resolver.Resolve(fset, path) + if err == nil { + return pkg, nil + } else if errors.Is(err, ErrResolverPackageNotFound) { + continue + } + + return nil, fmt.Errorf("resolver %q error: %w", resolver.Name(), err) + } + + return nil, ErrResolverPackageNotFound +} + +type MiddlewareHandler func(fset *token.FileSet, path string, next Resolver) (*Package, error) + +type middlewareResolver struct { + Handler MiddlewareHandler + Next Resolver +} + +func MiddlewareResolver(r Resolver, handlers ...MiddlewareHandler) Resolver { + // Start with the final resolver + start := r + + // Wrap each handler around the previous one + for _, handler := range handlers { + start = &middlewareResolver{ + Next: start, + Handler: handler, + } + } + + return start +} + +func (r middlewareResolver) Name() string { + return r.Next.Name() +} + +func (r *middlewareResolver) Resolve(fset *token.FileSet, path string) (*Package, error) { + if r.Handler != nil { + return r.Handler(fset, path, r.Next) + } + + return r.Next.Resolve(fset, path) +} + +// LogMiddleware creates a logging middleware handler. +func LogMiddleware(logger *slog.Logger) MiddlewareHandler { + return func(fset *token.FileSet, path string, next Resolver) (*Package, error) { + start := time.Now() + pkg, err := next.Resolve(fset, path) + switch { + case err == nil: + logger.Debug("path resolved", + "resolver", next.Name(), + "path", path, + "name", pkg.Name, + "took", time.Since(start).String(), + "location", pkg.Location, + ) + case errors.Is(err, ErrResolverPackageSkip): + logger.Debug(err.Error(), + "resolver", next.Name(), + "path", path, + "took", time.Since(start).String(), + ) + + case errors.Is(err, ErrResolverPackageNotFound): + logger.Warn(err.Error(), + "resolver", next.Name(), + "path", path, + "took", time.Since(start).String()) + + default: + logger.Error(err.Error(), + "resolver", next.Name(), + "path", path, + "took", time.Since(start).String()) + } + + return pkg, err + } +} + +type ShouldCacheFunc func(pkg *Package) bool + +func CacheAll(_ *Package) bool { return true } + +// CacheMiddleware creates a caching middleware handler. +func CacheMiddleware(shouldCache ShouldCacheFunc) MiddlewareHandler { + cacheMap := make(map[string]*Package) + return func(fset *token.FileSet, path string, next Resolver) (*Package, error) { + if pkg, ok := cacheMap[path]; ok { + return pkg, nil + } + + pkg, err := next.Resolve(fset, path) + if pkg != nil && shouldCache(pkg) { + cacheMap[path] = pkg + } + + return pkg, err + } +} + +// FilterPathHandler defines the function signature for filter handlers. +type FilterPathHandler func(path string) bool + +func FilterPathMiddleware(name string, filter FilterPathHandler) MiddlewareHandler { + return func(fset *token.FileSet, path string, next Resolver) (*Package, error) { + if filter(path) { + return nil, fmt.Errorf("filter %q: %w", name, ErrResolverPackageSkip) + } + + return next.Resolve(fset, path) + } +} + +var FilterStdlibs = FilterPathMiddleware("stdlibs", isStdPath) + +func isStdPath(path string) bool { + if i := strings.IndexRune(path, '/'); i > 0 { + if j := strings.IndexRune(path[:i], '.'); j >= 0 { + return false + } + } + + return true +} + +// PackageCheckerMiddleware creates a middleware handler for post-processing syntax. +func PackageCheckerMiddleware(logger *slog.Logger) MiddlewareHandler { + return func(fset *token.FileSet, path string, next Resolver) (*Package, error) { + // First, resolve the package using the next resolver in the chain. + pkg, err := next.Resolve(fset, path) + if err != nil { + return nil, err + } + + if err := pkg.Validate(); err != nil { + return nil, fmt.Errorf("invalid package %q: %w", path, err) + } + + // Post-process each file in the package. + for _, file := range pkg.Files { + fname := file.Name + if !isGnoFile(fname) { + continue + } + + logger.Debug("checking syntax", "path", path, "filename", fname) + _, err := parser.ParseFile(fset, file.Name, file.Body, parser.AllErrors) + if err == nil { + continue + } + + if el, ok := err.(scanner.ErrorList); ok { + for _, e := range el { + logger.Error("syntax error", + "path", path, + "filename", fname, + "err", e.Error(), + ) + } + } + + return nil, fmt.Errorf("file %q have error(s)", file.Name) + } + + return pkg, nil + } +} diff --git a/contribs/gnodev/pkg/packages/resolver_local.go b/contribs/gnodev/pkg/packages/resolver_local.go new file mode 100644 index 00000000000..13448aca52d --- /dev/null +++ b/contribs/gnodev/pkg/packages/resolver_local.go @@ -0,0 +1,39 @@ +package packages + +import ( + "fmt" + "go/token" + "path/filepath" + "strings" +) + +type LocalResolver struct { + Path string + Dir string +} + +func NewLocalResolver(path, dir string) *LocalResolver { + return &LocalResolver{ + Path: path, + Dir: dir, + } +} + +func (r *LocalResolver) Name() string { + return fmt.Sprintf("local<%s>", filepath.Base(r.Dir)) +} + +func (r LocalResolver) IsValid() bool { + pkg, err := r.Resolve(token.NewFileSet(), r.Path) + return err == nil && pkg != nil +} + +func (r LocalResolver) Resolve(fset *token.FileSet, path string) (*Package, error) { + after, found := strings.CutPrefix(path, r.Path) + if !found { + return nil, ErrResolverPackageNotFound + } + + dir := filepath.Join(r.Dir, after) + return ReadPackageFromDir(fset, path, dir) +} diff --git a/contribs/gnodev/pkg/packages/resolver_mock.go b/contribs/gnodev/pkg/packages/resolver_mock.go new file mode 100644 index 00000000000..f6a09af8883 --- /dev/null +++ b/contribs/gnodev/pkg/packages/resolver_mock.go @@ -0,0 +1,40 @@ +package packages + +import ( + "go/token" + + "github.com/gnolang/gno/gnovm" +) + +type MockResolver struct { + pkgs map[string]*gnovm.MemPackage + resolveCalls map[string]int // Track resolve calls per path +} + +func NewMockResolver(pkgs ...*gnovm.MemPackage) *MockResolver { + mappkgs := make(map[string]*gnovm.MemPackage, len(pkgs)) + for _, pkg := range pkgs { + mappkgs[pkg.Path] = pkg + } + return &MockResolver{ + pkgs: mappkgs, + resolveCalls: make(map[string]int), + } +} + +func (m *MockResolver) ResolveCalls(fset *token.FileSet, path string) int { + count, _ := m.resolveCalls[path] + return count +} + +func (m *MockResolver) Resolve(fset *token.FileSet, path string) (*Package, error) { + m.resolveCalls[path]++ // Increment call count + if mempkg, ok := m.pkgs[path]; ok { + return &Package{MemPackage: *mempkg}, nil + } + return nil, ErrResolverPackageNotFound +} + +func (m *MockResolver) Name() string { + return "mock" +} diff --git a/contribs/gnodev/pkg/packages/resolver_remote.go b/contribs/gnodev/pkg/packages/resolver_remote.go new file mode 100644 index 00000000000..94396f70c83 --- /dev/null +++ b/contribs/gnodev/pkg/packages/resolver_remote.go @@ -0,0 +1,94 @@ +package packages + +import ( + "bytes" + "errors" + "fmt" + "go/parser" + "go/token" + "path/filepath" + "strings" + + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gnovm" + "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" +) + +type remoteResolver struct { + *client.RPCClient + name string + fset *token.FileSet +} + +func NewRemoteResolver(name string, cl *client.RPCClient) Resolver { + return &remoteResolver{ + RPCClient: cl, + name: name, + fset: token.NewFileSet(), + } +} + +func (res *remoteResolver) Name() string { + return fmt.Sprintf("remote<%s>", res.name) +} + +func (res *remoteResolver) Resolve(fset *token.FileSet, path string) (*Package, error) { + const qpath = "vm/qfile" + + // First query files + data := []byte(path) + qres, err := res.RPCClient.ABCIQuery(qpath, data) + if err != nil { + return nil, fmt.Errorf("client unable to query: %w", err) + } + + if err := qres.Response.Error; err != nil { + if errors.Is(err, vm.InvalidPkgPathError{}) || + strings.HasSuffix(err.Error(), "is not available") { // XXX: find a better to check this + return nil, ErrResolverPackageNotFound + } + + return nil, fmt.Errorf("querying %q error: %w", path, err) + } + + var name string + memFiles := []*gnovm.MemFile{} + files := bytes.Split(qres.Response.Data, []byte{'\n'}) + for _, filename := range files { + fname := string(filename) + fpath := filepath.Join(path, fname) + qres, err := res.RPCClient.ABCIQuery(qpath, []byte(fpath)) + if err != nil { + return nil, fmt.Errorf("unable to query path") + } + + if err := qres.Response.Error; err != nil { + return nil, fmt.Errorf("unable to query file %q on path %q: %w", fname, path, err) + } + body := qres.Response.Data + + // Check package name + if name == "" && isGnoFile(fname) && !isTestFile(fname) { + // Check package name + f, err := parser.ParseFile(fset, fname, body, parser.PackageClauseOnly) + if err != nil { + return nil, fmt.Errorf("unable to parse file %q: %w", fname, err) + } + name = f.Name.Name + } + + memFiles = append(memFiles, &gnovm.MemFile{ + Name: fname, Body: string(body), + }) + } + + return &Package{ + MemPackage: gnovm.MemPackage{ + Name: name, + Path: path, + Files: memFiles, + }, + Kind: PackageKindRemote, + Location: path, + }, nil +} diff --git a/contribs/gnodev/pkg/packages/resolver_remote_test.go b/contribs/gnodev/pkg/packages/resolver_remote_test.go new file mode 100644 index 00000000000..69347c0ad4d --- /dev/null +++ b/contribs/gnodev/pkg/packages/resolver_remote_test.go @@ -0,0 +1 @@ +package packages diff --git a/contribs/gnodev/pkg/packages/resolver_root.go b/contribs/gnodev/pkg/packages/resolver_root.go new file mode 100644 index 00000000000..ae6a9d416ea --- /dev/null +++ b/contribs/gnodev/pkg/packages/resolver_root.go @@ -0,0 +1,30 @@ +package packages + +import ( + "fmt" + "go/token" + "os" + "path/filepath" +) + +type rootResolver struct { + root string // Root folder +} + +func NewRootResolver(path string) Resolver { + return &rootResolver{root: path} +} + +func (r *rootResolver) Name() string { + return fmt.Sprintf("root<%s>", filepath.Base(r.root)) +} + +func (r *rootResolver) Resolve(fset *token.FileSet, path string) (*Package, error) { + dir := filepath.Join(r.root, path) + _, err := os.Stat(dir) + if err != nil { + return nil, ErrResolverPackageNotFound + } + + return ReadPackageFromDir(fset, path, dir) +} diff --git a/contribs/gnodev/pkg/packages/resolver_test.go b/contribs/gnodev/pkg/packages/resolver_test.go new file mode 100644 index 00000000000..9341bb80d7b --- /dev/null +++ b/contribs/gnodev/pkg/packages/resolver_test.go @@ -0,0 +1,290 @@ +package packages + +import ( + "bytes" + "errors" + "go/token" + "log/slog" + "path/filepath" + "testing" + + "github.com/gnolang/gno/gno.land/pkg/integration" + "github.com/gnolang/gno/gnovm" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/gnolang/gno/tm2/pkg/crypto/secp256k1" + "github.com/gnolang/gno/tm2/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogMiddleware(t *testing.T) { + t.Parallel() + + mockResolver := NewMockResolver(&gnovm.MemPackage{ + Path: "abc.xy/test/pkg", + Name: "pkg", + Files: []*gnovm.MemFile{ + {Name: "file.gno", Body: "package pkg"}, + }, + }) + + t.Run("logs package not found", func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + + logger := slog.New(slog.NewTextHandler(&buff, &slog.HandlerOptions{})) + middleware := LogMiddleware(logger) + + resolver := MiddlewareResolver(mockResolver, middleware) + pkg, err := resolver.Resolve(token.NewFileSet(), "abc.xy/invalid/pkg") + require.Error(t, err) + require.Nil(t, pkg) + assert.Contains(t, buff.String(), "package not found") + }) + + t.Run("logs package resolution", func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buff, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + middleware := LogMiddleware(logger) + + resolver := MiddlewareResolver(mockResolver, middleware) + pkg, err := resolver.Resolve(token.NewFileSet(), "abc.xy/test/pkg") + require.NoError(t, err) + require.NotNil(t, pkg) + assert.Contains(t, buff.String(), "path resolved") + }) +} + +func TestCacheMiddleware(t *testing.T) { + t.Parallel() + + pkg := &gnovm.MemPackage{Path: "abc.xy/cached/pkg", Name: "pkg"} + t.Run("caches resolved packages", func(t *testing.T) { + t.Parallel() + + mockResolver := NewMockResolver(pkg) + cacheMiddleware := CacheMiddleware(CacheAll) + cachedResolver := MiddlewareResolver(mockResolver, cacheMiddleware) + + // First call + pkg1, err := cachedResolver.Resolve(token.NewFileSet(), pkg.Path) + require.NoError(t, err) + require.Equal(t, 1, mockResolver.resolveCalls[pkg.Path]) + + // Second call + pkg2, err := cachedResolver.Resolve(token.NewFileSet(), pkg.Path) + require.NoError(t, err) + require.Same(t, pkg1, pkg2) + require.Equal(t, 1, mockResolver.resolveCalls[pkg.Path]) + }) + + t.Run("no cache when shouldCache is false", func(t *testing.T) { + t.Parallel() + + mockResolver := NewMockResolver(pkg) + cacheMiddleware := CacheMiddleware(func(*Package) bool { return false }) + cachedResolver := MiddlewareResolver(mockResolver, cacheMiddleware) + + pkg1, err := cachedResolver.Resolve(token.NewFileSet(), pkg.Path) + require.NoError(t, err) + pkg2, err := cachedResolver.Resolve(token.NewFileSet(), pkg.Path) + require.NoError(t, err) + require.NotSame(t, pkg1, pkg2) + require.Equal(t, 2, mockResolver.resolveCalls[pkg.Path]) + }) +} + +func TestFilterStdlibsMiddleware(t *testing.T) { + t.Parallel() + + middleware := FilterStdlibs + mockResolver := NewMockResolver(&gnovm.MemPackage{ + Path: "abc.xy/pkg", + Name: "pkg", + Files: []*gnovm.MemFile{ + {Name: "file.gno", Body: "package pkg"}, + }, + }) + filteredResolver := MiddlewareResolver(mockResolver, middleware) + + t.Run("filters stdlib paths", func(t *testing.T) { + t.Parallel() + + _, err := filteredResolver.Resolve(token.NewFileSet(), "fmt") + require.Error(t, err) + require.True(t, errors.Is(err, ErrResolverPackageSkip)) + require.Equal(t, 0, mockResolver.resolveCalls["fmt"]) + }) + + t.Run("allows non-stdlib paths", func(t *testing.T) { + t.Parallel() + + pkg, err := filteredResolver.Resolve(token.NewFileSet(), "abc.xy/pkg") + require.NoError(t, err) + require.NotNil(t, pkg) + require.Equal(t, 1, mockResolver.resolveCalls["abc.xy/pkg"]) + }) +} + +func TestPackageCheckerMiddleware(t *testing.T) { + t.Parallel() + + logger := log.NewTestingLogger(t) + t.Run("valid package syntax", func(t *testing.T) { + t.Parallel() + + validPkg := &gnovm.MemPackage{ + Path: "abc.xy/r/valid/pkg", + Name: "valid", + Files: []*gnovm.MemFile{ + {Name: "valid.gno", Body: "package valid; func Foo() {}"}, + }, + } + mockResolver := NewMockResolver(validPkg) + middleware := PackageCheckerMiddleware(logger) + resolver := MiddlewareResolver(mockResolver, middleware) + + pkg, err := resolver.Resolve(token.NewFileSet(), validPkg.Path) + require.NoError(t, err) + require.NotNil(t, pkg) + }) + + t.Run("invalid package syntax", func(t *testing.T) { + t.Parallel() + + invalidPkg := &gnovm.MemPackage{ + Path: "abc.xy/r/invalid/pkg", + Name: "invalid", + Files: []*gnovm.MemFile{ + {Name: "invalid.gno", Body: "package invalid\nfunc Foo() {"}, + }, + } + mockResolver := NewMockResolver(invalidPkg) + middleware := PackageCheckerMiddleware(logger) + resolver := MiddlewareResolver(mockResolver, middleware) + + _, err := resolver.Resolve(token.NewFileSet(), invalidPkg.Path) + require.Error(t, err) + require.Contains(t, err.Error(), `file "invalid.gno" have error(s)`) + }) + + t.Run("ignores non-gno files", func(t *testing.T) { + t.Parallel() + + nonGnoPkg := &gnovm.MemPackage{ + Path: "abc.xy/r/non/gno/pkg", + Name: "pkg", + Files: []*gnovm.MemFile{ + {Name: "README.md", Body: "# Documentation"}, + }, + } + mockResolver := NewMockResolver(nonGnoPkg) + middleware := PackageCheckerMiddleware(logger) + resolver := MiddlewareResolver(mockResolver, middleware) + + _, err := resolver.Resolve(token.NewFileSet(), nonGnoPkg.Path) + require.NoError(t, err) + }) +} + +func TestResolverLocal_Resolve(t *testing.T) { + t.Parallel() + + const anotherPath = "abc.xy/another/path" + localResolver := NewLocalResolver(anotherPath, filepath.Join("./testdata", TestdataPkgA)) + + t.Run("valid package", func(t *testing.T) { + t.Parallel() + + pkg, err := localResolver.Resolve(token.NewFileSet(), anotherPath) + require.NoError(t, err) + require.NotNil(t, pkg) + require.Equal(t, pkg.Name, "aa") + }) + + t.Run("invalid package", func(t *testing.T) { + t.Parallel() + + pkg, err := localResolver.Resolve(token.NewFileSet(), "abc.xy/wrong/package") + require.Nil(t, pkg) + require.Error(t, err) + require.ErrorIs(t, err, ErrResolverPackageNotFound) + }) +} + +func TestResolver_ResolveRemote(t *testing.T) { + const targetPath = "gno.land/r/target/path" + + mempkg := gnovm.MemPackage{ + Name: "foo", + Path: targetPath, + Files: []*gnovm.MemFile{ + { + Name: "foo.gno", + Body: `package foo; func Render(_ string) string { return "bar" }`, + }, + {Name: "gno.mod", Body: `module ` + targetPath}, + }, + } + + rootdir := gnoenv.RootDir() + cfg := integration.TestingMinimalNodeConfig(rootdir) + logger := log.NewTestingLogger(t) + + // Setup genesis state + privKey := secp256k1.GenPrivKey() + cfg.Genesis.AppState = integration.GenerateTestingGenesisState(privKey, mempkg) + + _, address := integration.TestingInMemoryNode(t, logger, cfg) + cl, err := client.NewHTTPClient(address) + require.NoError(t, err) + + remoteResolver := NewRemoteResolver(address, cl) + t.Run("valid package", func(t *testing.T) { + pkg, err := remoteResolver.Resolve(token.NewFileSet(), mempkg.Path) + require.NoError(t, err) + require.NotNil(t, pkg) + assert.Equal(t, mempkg, pkg.MemPackage) + }) + + t.Run("invalid package", func(t *testing.T) { + pkg, err := remoteResolver.Resolve(token.NewFileSet(), "gno.land/r/not/a/valid/package") + require.Nil(t, pkg) + require.Error(t, err) + require.ErrorIs(t, err, ErrResolverPackageNotFound) + }) +} + +func TestResolverRoot_Resolve(t *testing.T) { + t.Parallel() + + fsResolver := NewRootResolver("./testdata") + t.Run("valid packages", func(t *testing.T) { + t.Parallel() + + for _, tpkg := range []string{TestdataPkgA, TestdataPkgB, TestdataPkgC} { + t.Run(tpkg, func(t *testing.T) { + pkg, err := fsResolver.Resolve(token.NewFileSet(), tpkg) + require.NoError(t, err) + require.NotNil(t, pkg) + require.Equal(t, tpkg, pkg.Path) + require.Equal(t, filepath.Base(tpkg), pkg.Name) + }) + } + }) + + t.Run("invalid packages", func(t *testing.T) { + t.Parallel() + + pkg, err := fsResolver.Resolve(token.NewFileSet(), "abc.xy/wrong/package") + require.Nil(t, pkg) + require.Error(t, err) + require.ErrorIs(t, err, ErrResolverPackageNotFound) + }) +} diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/aa/file.gno b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/aa/file.gno new file mode 100644 index 00000000000..14492ef76f3 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/aa/file.gno @@ -0,0 +1 @@ +package aa diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/aa/gno.mod b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/aa/gno.mod new file mode 100644 index 00000000000..071e676d43e --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/aa/gno.mod @@ -0,0 +1 @@ +module abc.xy/nested/aa diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/bb/file.gno b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/bb/file.gno new file mode 100644 index 00000000000..592f1946da0 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/bb/file.gno @@ -0,0 +1 @@ +package bb diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/bb/gno.mod b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/bb/gno.mod new file mode 100644 index 00000000000..2e0f55a7954 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/bb/gno.mod @@ -0,0 +1 @@ +module abc.xy/nested/nested/bb \ No newline at end of file diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/cc/file.gno b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/cc/file.gno new file mode 100644 index 00000000000..10702f6990c --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/cc/file.gno @@ -0,0 +1 @@ +package cc diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/cc/gno.mod b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/cc/gno.mod new file mode 100644 index 00000000000..0932deb1366 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/nested/nested/cc/gno.mod @@ -0,0 +1 @@ +module abc.xy/nested/nested/cc \ No newline at end of file diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/aa/file.gno b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/aa/file.gno new file mode 100644 index 00000000000..b809785a376 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/aa/file.gno @@ -0,0 +1,3 @@ +package aa + +type SA struct{} diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/aa/gno.mod b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/aa/gno.mod new file mode 100644 index 00000000000..02d58054ca6 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/aa/gno.mod @@ -0,0 +1 @@ +module abc.xy/pkg/aa \ No newline at end of file diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/bb/file.gno b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/bb/file.gno new file mode 100644 index 00000000000..5cca9ec3c21 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/bb/file.gno @@ -0,0 +1,5 @@ +package bb + +import "abc.xy/pkg/aa" + +type SB = aa.SA diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/bb/gno.mod b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/bb/gno.mod new file mode 100644 index 00000000000..b5d760d6f75 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/bb/gno.mod @@ -0,0 +1 @@ +module abc.xy/pkg/bb \ No newline at end of file diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/cc/file.gno b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/cc/file.gno new file mode 100644 index 00000000000..21819a7b686 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/cc/file.gno @@ -0,0 +1,5 @@ +package cc + +import "abc.xy/pkg/bb" + +type SC = bb.SB diff --git a/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/cc/gno.mod b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/cc/gno.mod new file mode 100644 index 00000000000..bc993583fd3 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata/abc.xy/pkg/cc/gno.mod @@ -0,0 +1 @@ +module abc.xy/pkg/cc \ No newline at end of file diff --git a/contribs/gnodev/pkg/packages/testdata_test.go b/contribs/gnodev/pkg/packages/testdata_test.go new file mode 100644 index 00000000000..5c9a8b45cd5 --- /dev/null +++ b/contribs/gnodev/pkg/packages/testdata_test.go @@ -0,0 +1,44 @@ +// This test file serves as a reference for the testdata directory tree. + +package packages + +// The structure of the testdata directory is as follows: +// +// testdata +// ├── abc.xy +// ├── nested +// │ ├── aa +// │ │ └── gno.mod +// │ └── nested +// │ ├── bb +// │ │ └── gno.mod +// │ └── cc +// │ └── gno.mod +// └── pkg +// ├── aa +// │ ├── file1.gno +// │ └── gno.mod +// ├── bb // depends on aa +// │ ├── file1.gno +// │ └── gno.mod +// └── cc // depends on bb +// ├── file1.gno +// └── gno.mod + +const ( + TestdataPkgA = "abc.xy/pkg/aa" + TestdataPkgB = "abc.xy/pkg/bb" + TestdataPkgC = "abc.xy/pkg/cc" +) + +// List of testdata package paths +var testdataPkgs = []string{TestdataPkgA, TestdataPkgB, TestdataPkgC} + +const ( + TestdataNestedA = "abc.xy/nested/aa" // Path to nested package A + TestdataNestedB = "abc.xy/nested/nested/bb" // Path to nested package B + TestdataNestedC = "abc.xy/nested/nested/cc" // Path to nested package C +) + +// List of nested package paths +var testdataNested = []string{TestdataNestedA, TestdataNestedB, TestdataNestedC} diff --git a/contribs/gnodev/pkg/packages/utils.go b/contribs/gnodev/pkg/packages/utils.go new file mode 100644 index 00000000000..93160a3a1a5 --- /dev/null +++ b/contribs/gnodev/pkg/packages/utils.go @@ -0,0 +1,14 @@ +package packages + +import ( + "path/filepath" + "strings" +) + +func isGnoFile(name string) bool { + return filepath.Ext(name) == ".gno" && !strings.HasPrefix(name, ".") +} + +func isTestFile(name string) bool { + return strings.HasSuffix(name, "_filetest.gno") || strings.HasSuffix(name, "_test.gno") +} diff --git a/contribs/gnodev/pkg/proxy/path_interceptor.go b/contribs/gnodev/pkg/proxy/path_interceptor.go new file mode 100644 index 00000000000..84d2f92b22f --- /dev/null +++ b/contribs/gnodev/pkg/proxy/path_interceptor.go @@ -0,0 +1,330 @@ +package proxy + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "path/filepath" + "strings" + "sync" + + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/tm2/pkg/amino" + rpctypes "github.com/gnolang/gno/tm2/pkg/bft/rpc/lib/types" + "github.com/gnolang/gno/tm2/pkg/std" +) + +type PathHandler func(path ...string) + +type PathInterceptor struct { + proxyAddr, targetAddr net.Addr + + logger *slog.Logger + listener net.Listener + handlers []PathHandler + muHandlers sync.RWMutex +} + +// NewPathInterceptor creates a new path proxy interceptor. +func NewPathInterceptor(logger *slog.Logger, target net.Addr) (*PathInterceptor, error) { + // Create a listener on the target address + proxyListener, err := net.Listen(target.Network(), target.String()) + if err != nil { + return nil, fmt.Errorf("failed to listen on %s://%s", target.Network(), target.String()) + } + + // Find on a new random port for the target + targetListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to listen on tcp://127.0.0.1:0") + } + proxyAddr := targetListener.Addr() + // Immediately close this listener after proxy initialization + defer targetListener.Close() + + proxy := &PathInterceptor{ + listener: proxyListener, + logger: logger, + targetAddr: target, + proxyAddr: proxyAddr, + } + + go proxy.handleConnections() + + return proxy, nil +} + +// HandlePath adds a new path handler to the interceptor. +func (proxy *PathInterceptor) HandlePath(fn PathHandler) { + proxy.muHandlers.Lock() + defer proxy.muHandlers.Unlock() + proxy.handlers = append(proxy.handlers, fn) +} + +// ProxyAddress returns the network address of the proxy. +func (proxy *PathInterceptor) ProxyAddress() string { + return fmt.Sprintf("%s://%s", proxy.proxyAddr.Network(), proxy.proxyAddr.String()) +} + +// TargetAddress returns the network address of the target. +func (proxy *PathInterceptor) TargetAddress() string { + return fmt.Sprintf("%s://%s", proxy.targetAddr.Network(), proxy.targetAddr.String()) +} + +// handleConnections manages incoming connections to the proxy. +func (proxy *PathInterceptor) handleConnections() { + defer proxy.listener.Close() + + for { + conn, err := proxy.listener.Accept() + if err != nil { + if !errors.Is(err, net.ErrClosed) { + proxy.logger.Debug("failed to accept connection", "error", err) + } + + return + } + + proxy.logger.Debug("new connection", "remote", conn.RemoteAddr()) + go proxy.handleConnection(conn) + } +} + +// handleConnection processes a single connection between client and target. +func (proxy *PathInterceptor) handleConnection(inConn net.Conn) { + logger := proxy.logger.With(slog.String("in", inConn.RemoteAddr().String())) + + // Establish a connection to the target + outConn, err := net.Dial(proxy.proxyAddr.Network(), proxy.proxyAddr.String()) + if err != nil { + logger.Error("target connection failed", "target", proxy.proxyAddr.String(), "error", err) + inConn.Close() + return + } + logger = logger.With(slog.String("out", outConn.RemoteAddr().String())) + + // Coordinate connection closure + var closeOnce sync.Once + closeConnections := func() { + inConn.Close() + outConn.Close() + } + + // Setup bidirectional copying + var wg sync.WaitGroup + wg.Add(2) + + // Response path (target -> client) + go func() { + defer wg.Done() + defer closeOnce.Do(closeConnections) + + _, err := io.Copy(inConn, outConn) + if err == nil || errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { + return // Connection has been closed + } + + logger.Debug("response copy error", "error", err) + }() + + // Request path (client -> target) + go func() { + defer wg.Done() + defer closeOnce.Do(closeConnections) + + var buffer bytes.Buffer + tee := io.TeeReader(inConn, &buffer) + reader := bufio.NewReader(tee) + + // Process HTTP requests + if err := proxy.processHTTPRequests(reader, &buffer, outConn); err != nil { + if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { + return // Connection has been closed + } + + if _, isNetError := err.(net.Error); isNetError { + logger.Debug("request processing error", "error", err) + return + } + + // Continue processing the connection if not a network error + } + + // Forward remaining data after HTTP processing + if buffer.Len() > 0 { + if _, err := outConn.Write(buffer.Bytes()); err != nil { + logger.Debug("buffer flush failed", "error", err) + } + } + + // Directly pipe remaining traffic + if _, err := io.Copy(outConn, inConn); err != nil && !errors.Is(err, net.ErrClosed) { + logger.Debug("raw copy failed", "error", err) + } + }() + + wg.Wait() + logger.Debug("connection closed") +} + +// processHTTPRequests handles the HTTP request/response cycle. +func (proxy *PathInterceptor) processHTTPRequests(reader *bufio.Reader, buffer *bytes.Buffer, outConn net.Conn) error { + for { + request, err := http.ReadRequest(reader) + if err != nil { + return fmt.Errorf("read request failed: %w", err) + } + + // Check for websocket upgrade + if isWebSocket(request) { + return errors.New("websocket upgrade requested") + } + + // Read and process the request body + body, err := io.ReadAll(request.Body) + request.Body.Close() + if err != nil { + return fmt.Errorf("body read failed: %w", err) + } + + if err := proxy.handleRequest(body); err != nil { + proxy.logger.Debug("request handler warning", "error", err) + } + + // Forward the original request bytes + if _, err := outConn.Write(buffer.Bytes()); err != nil { + return fmt.Errorf("request forward failed: %w", err) + } + + buffer.Reset() // Prepare for the next request + } +} + +func isWebSocket(req *http.Request) bool { + return strings.EqualFold(req.Header.Get("Upgrade"), "websocket") +} + +type uniqPaths map[string]struct{} + +func (upaths uniqPaths) list() []string { + paths := make([]string, 0, len(upaths)) + for p := range upaths { + paths = append(paths, p) + } + return paths +} + +func (upaths uniqPaths) add(path string) { upaths[path] = struct{}{} } + +// handleRequest parses and processes the RPC request body. +func (proxy *PathInterceptor) handleRequest(body []byte) error { + ps := make(uniqPaths) + if err := parseRPCRequest(body, ps); err != nil { + return fmt.Errorf("unable to parse RPC request: %w", err) + } + + paths := ps.list() + if len(paths) == 0 { + return nil + } + + proxy.logger.Debug("parsed request paths", "paths", paths) + + proxy.muHandlers.RLock() + defer proxy.muHandlers.RUnlock() + + for _, handle := range proxy.handlers { + handle(paths...) + } + + return nil +} + +// Close closes the proxy listener. +func (proxy *PathInterceptor) Close() error { + return proxy.listener.Close() +} + +// parseRPCRequest unmarshals and processes RPC requests, returning paths. +func parseRPCRequest(body []byte, upaths uniqPaths) error { + var req rpctypes.RPCRequest + if err := json.Unmarshal(body, &req); err != nil { + return fmt.Errorf("unable to unmarshal RPC request: %w", err) + } + + switch req.Method { + case "abci_query": + var squery struct { + Path string `json:"path"` + Data []byte `json:"data,omitempty"` + } + if err := json.Unmarshal(req.Params, &squery); err != nil { + return fmt.Errorf("unable to unmarshal params: %w", err) + } + + return handleQuery(squery.Path, squery.Data, upaths) + + case "broadcast_tx_commit": + var stx struct { + Tx []byte `json:"tx"` + } + if err := json.Unmarshal(req.Params, &stx); err != nil { + return fmt.Errorf("unable to unmarshal params: %w", err) + } + + return handleTx(stx.Tx, upaths) + } + + return fmt.Errorf("unhandled method: %q", req.Method) +} + +// handleTx processes the transaction and returns relevant paths. +func handleTx(bz []byte, upaths uniqPaths) error { + var tx std.Tx + if err := amino.Unmarshal(bz, &tx); err != nil { + return fmt.Errorf("unable to unmarshal tx: %w", err) + } + + for _, msg := range tx.Msgs { + switch msg := msg.(type) { + case vm.MsgAddPackage: // MsgAddPackage should not be handled + case vm.MsgCall: + upaths.add(msg.PkgPath) + case vm.MsgRun: + upaths.add(msg.Package.Path) + } + } + + return nil +} + +// handleQuery processes the query and returns relevant paths. +func handleQuery(path string, data []byte, upaths uniqPaths) error { + switch path { + case ".app/simulate": + return handleTx(data, upaths) + + case "vm/qrender", "vm/qfile", "vm/qfuncs", "vm/qeval": + path, _, _ := strings.Cut(string(data), ":") // Cut arguments out + path = filepath.Clean(path) + + // If path is a file, grab the directory instead + if ext := filepath.Ext(path); ext != "" { + path = filepath.Dir(path) + } + + upaths.add(path) + return nil + + default: + return fmt.Errorf("unhandled: %q", path) + } + + // XXX: handle more cases +} diff --git a/contribs/gnodev/pkg/proxy/path_interceptor_test.go b/contribs/gnodev/pkg/proxy/path_interceptor_test.go new file mode 100644 index 00000000000..c7082adfa30 --- /dev/null +++ b/contribs/gnodev/pkg/proxy/path_interceptor_test.go @@ -0,0 +1,179 @@ +package proxy_test + +import ( + "net" + "net/http" + "path/filepath" + "testing" + + "github.com/gnolang/gno/contribs/gnodev/pkg/proxy" + "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" + "github.com/gnolang/gno/gno.land/pkg/integration" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gnovm" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/gnolang/gno/tm2/pkg/crypto/secp256k1" + "github.com/gnolang/gno/tm2/pkg/log" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProxy(t *testing.T) { + const targetPath = "gno.land/r/target/foo" + + pkg := gnovm.MemPackage{ + Name: "foo", + Path: targetPath, + Files: []*gnovm.MemFile{ + { + Name: "foo.gno", + Body: `package foo; func Render(_ string) string { return "foo" }`, + }, + {Name: "gno.mod", Body: `module ` + targetPath}, + }, + } + + rootdir := gnoenv.RootDir() + cfg := integration.TestingMinimalNodeConfig(rootdir) + logger := log.NewTestingLogger(t) + + tmp := t.TempDir() + sock := filepath.Join(tmp, "node.sock") + addr, err := net.ResolveUnixAddr("unix", sock) + require.NoError(t, err) + + // Create proxy + interceptor, err := proxy.NewPathInterceptor(logger, addr) + require.NoError(t, err) + defer interceptor.Close() + cfg.TMConfig.RPC.ListenAddress = interceptor.ProxyAddress() + cfg.SkipGenesisVerification = true + + // Setup genesis + privKey := secp256k1.GenPrivKey() + cfg.Genesis.AppState = integration.GenerateTestingGenesisState(privKey, pkg) + creator := privKey.PubKey().Address() + + integration.TestingInMemoryNode(t, logger, cfg) + pathChan := make(chan []string, 1) + interceptor.HandlePath(func(paths ...string) { + pathChan <- paths + }) + + // ---- Test Cases ---- + + t.Run("valid_vm_query", func(t *testing.T) { + cli, err := client.NewHTTPClient(interceptor.TargetAddress()) + require.NoError(t, err) + + res, err := cli.ABCIQuery("vm/qrender", []byte(targetPath+":\n")) + require.NoError(t, err) + assert.Nil(t, res.Response.Error) + + select { + case paths := <-pathChan: + require.Len(t, paths, 1) + assert.Equal(t, []string{targetPath}, paths) + default: + t.Fatal("paths not captured") + } + }) + + t.Run("valid_vm_query_file", func(t *testing.T) { + cli, err := client.NewHTTPClient(interceptor.TargetAddress()) + require.NoError(t, err) + + res, err := cli.ABCIQuery("vm/qfile", []byte(filepath.Join(targetPath, "foo.gno"))) + require.NoError(t, err) + assert.Nil(t, res.Response.Error) + + select { + case paths := <-pathChan: + require.Len(t, paths, 1) + assert.Equal(t, []string{targetPath}, paths) + default: + t.Fatal("paths not captured") + } + }) + + t.Run("simulate_tx_paths", func(t *testing.T) { + // Build transaction with multiple messages + var tx std.Tx + send := std.MustParseCoins(ugnot.ValueString(10_000_000)) + tx.Fee = std.Fee{GasWanted: 1e6, GasFee: std.Coin{Amount: 1e6, Denom: "ugnot"}} + tx.Msgs = []std.Msg{ + vm.NewMsgCall(creator, send, targetPath, "Render", []string{""}), + vm.NewMsgCall(creator, send, targetPath, "Render", []string{""}), + vm.NewMsgCall(creator, send, targetPath, "Render", []string{""}), + } + + bytes, err := tx.GetSignBytes(cfg.Genesis.ChainID, 0, 0) + require.NoError(t, err) + signature, err := privKey.Sign(bytes) + require.NoError(t, err) + tx.Signatures = []std.Signature{{PubKey: privKey.PubKey(), Signature: signature}} + + bz, err := amino.Marshal(tx) + require.NoError(t, err) + + cli, err := client.NewHTTPClient(interceptor.TargetAddress()) + require.NoError(t, err) + + res, err := cli.BroadcastTxCommit(bz) + require.NoError(t, err) + assert.NoError(t, res.CheckTx.Error) + assert.NoError(t, res.DeliverTx.Error) + + select { + case paths := <-pathChan: + require.Len(t, paths, 1) + assert.Equal(t, []string{targetPath}, paths) + default: + t.Fatal("paths not captured") + } + }) + + t.Run("websocket_forward", func(t *testing.T) { + // For now simply try to connect and upgrade the connection + // XXX: fully support ws + + conn, err := net.Dial(addr.Network(), addr.String()) + require.NoError(t, err) + defer conn.Close() + + // Send WebSocket handshake + req, _ := http.NewRequest("GET", "http://"+interceptor.TargetAddress(), nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + err = req.Write(conn) + require.NoError(t, err) + }) + + t.Run("invalid_query_data", func(t *testing.T) { + // Making a valid call but not supported by the proxy + // should succeed + query := "auth/accounts/" + creator.String() + + cli, err := client.NewHTTPClient(interceptor.TargetAddress()) + require.NoError(t, err) + defer cli.Close() + + res, err := cli.ABCIQuery(query, []byte{}) + require.NoError(t, err) + require.NoError(t, res.Response.Error) + + var qret struct{ BaseAccount std.BaseAccount } + err = amino.UnmarshalJSON(res.Response.Data, &qret) + require.NoError(t, err) + assert.Equal(t, qret.BaseAccount.Address, creator) + + select { + case paths := <-pathChan: + require.FailNowf(t, "should not catch a path", "catched: %+v", paths) + default: + } + }) +} diff --git a/contribs/gnodev/pkg/rawterm/keypress.go b/contribs/gnodev/pkg/rawterm/keypress.go index 45c64c999dd..e9c1728bd4b 100644 --- a/contribs/gnodev/pkg/rawterm/keypress.go +++ b/contribs/gnodev/pkg/rawterm/keypress.go @@ -26,6 +26,12 @@ const ( KeyN KeyPress = 'N' KeyP KeyPress = 'P' KeyR KeyPress = 'R' + + // Special keys + KeyUp KeyPress = 0x80 // Arbitrary value outside ASCII range + KeyDown KeyPress = 0x81 + KeyLeft KeyPress = 0x82 + KeyRight KeyPress = 0x83 ) func (k KeyPress) Upper() KeyPress { @@ -52,6 +58,14 @@ func (k KeyPress) String() string { return "Ctrl+S" case KeyCtrlT: return "Ctrl+T" + case KeyUp: + return "Up Arrow" + case KeyDown: + return "Down Arrow" + case KeyLeft: + return "Left Arrow" + case KeyRight: + return "Right Arrow" default: // For printable ASCII characters if k > 0x20 && k < 0x7e { diff --git a/contribs/gnodev/pkg/rawterm/rawterm.go b/contribs/gnodev/pkg/rawterm/rawterm.go index 58b8dde1530..7ff4cadaf94 100644 --- a/contribs/gnodev/pkg/rawterm/rawterm.go +++ b/contribs/gnodev/pkg/rawterm/rawterm.go @@ -54,12 +54,31 @@ func (rt *RawTerm) read(buf []byte) (n int, err error) { } func (rt *RawTerm) ReadKeyPress() (KeyPress, error) { - buf := make([]byte, 1) - if _, err := rt.read(buf); err != nil { + buf := make([]byte, 3) + n, err := rt.read(buf) + if err != nil { return KeyNone, err } - return KeyPress(buf[0]), nil + if n == 1 && buf[0] != '\x1b' { + // Single character, not an escape sequence + return KeyPress(buf[0]), nil + } + + if n >= 3 && buf[0] == '\x1b' && buf[1] == '[' { + switch buf[2] { + case 'A': + return KeyUp, nil + case 'B': + return KeyDown, nil + case 'C': + return KeyRight, nil + case 'D': + return KeyLeft, nil + } + } + + return KeyNone, fmt.Errorf("unknown key sequence: %v", buf[:n]) } // writeWithCRLF writes buf to w but replaces all occurrences of \n with \r\n. diff --git a/contribs/gnodev/pkg/watcher/watch.go b/contribs/gnodev/pkg/watcher/watch.go index 63158a06c4b..5f277fd6646 100644 --- a/contribs/gnodev/pkg/watcher/watch.go +++ b/contribs/gnodev/pkg/watcher/watch.go @@ -5,15 +5,14 @@ import ( "fmt" "log/slog" "path/filepath" - "sort" "strings" "time" emitter "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" events "github.com/gnolang/gno/contribs/gnodev/pkg/events" + "github.com/gnolang/gno/contribs/gnodev/pkg/packages" "github.com/fsnotify/fsnotify" - "github.com/gnolang/gno/gnovm/pkg/gnomod" ) type PackageWatcher struct { @@ -25,7 +24,6 @@ type PackageWatcher struct { logger *slog.Logger watcher *fsnotify.Watcher - pkgsDir []string emitter emitter.Emitter } @@ -39,7 +37,6 @@ func NewPackageWatcher(logger *slog.Logger, emitter emitter.Emitter) (*PackageWa p := &PackageWatcher{ ctx: ctx, stop: cancel, - pkgsDir: []string{}, logger: logger, watcher: watcher, emitter: emitter, @@ -114,58 +111,61 @@ func (p *PackageWatcher) Stop() { p.stop() } -// AddPackages adds new packages to the watcher. -// Packages are sorted by their length in descending order to facilitate easier -// and more efficient matching with corresponding paths. The longest paths are -// compared first. -func (p *PackageWatcher) AddPackages(pkgs ...gnomod.Pkg) error { +func (p *PackageWatcher) UpdatePackagesWatch(pkgs ...packages.Package) { + watchList := p.watcher.WatchList() + + oldPkgs := make(map[string]struct{}, len(watchList)) + for _, path := range watchList { + oldPkgs[path] = struct{}{} + } + + newPkgs := make(map[string]struct{}, len(pkgs)) for _, pkg := range pkgs { - dir := pkg.Dir + if pkg.Kind != packages.PackageKindFS { + continue + } - abs, err := filepath.Abs(dir) + path, err := filepath.Abs(pkg.Location) if err != nil { - return fmt.Errorf("unable to get absolute path of %q: %w", dir, err) + p.logger.Error("Unable to get absolute path", "path", pkg.Location, "error", err) + continue } - // Use binary search to find the correct insertion point - index := sort.Search(len(p.pkgsDir), func(i int) bool { - return len(p.pkgsDir[i]) <= len(dir) // Longest paths first - }) + newPkgs[path] = struct{}{} + } - // Check for duplicates - if index < len(p.pkgsDir) && p.pkgsDir[index] == dir { - continue // Skip + for path := range oldPkgs { + if _, exists := newPkgs[path]; !exists { + p.watcher.Remove(path) + p.logger.Debug("Watcher list: removed", "path", path) } + } - // Insert the package - p.pkgsDir = append(p.pkgsDir[:index], append([]string{abs}, p.pkgsDir[index:]...)...) - - // Add the package to the watcher and handle any errors - if err := p.watcher.Add(abs); err != nil { - return fmt.Errorf("unable to watch %q: %w", pkg.Dir, err) + for path := range newPkgs { + if _, exists := oldPkgs[path]; !exists { + p.watcher.Add(path) + p.logger.Debug("Watcher list: added", "path", path) } } - - return nil } func (p *PackageWatcher) generatePackagesUpdateList(paths []string) PackageUpdateList { pkgsUpdate := []events.PackageUpdate{} mpkgs := map[string]*events.PackageUpdate{} // Pkg -> Update + watchList := p.watcher.WatchList() for _, path := range paths { - for _, pkg := range p.pkgsDir { - dirPath := filepath.Dir(path) + for _, pkg := range watchList { + if len(pkg) == len(path) { + continue // Skip if pkg == path + } // Check if a package directory contain our path directory + dirPath := filepath.Dir(path) if !strings.HasPrefix(pkg, dirPath) { continue } - if len(pkg) == len(path) { - continue // Skip if pkg == path - } - // Accumulate file updates for each package pkgu, ok := mpkgs[pkg] if !ok { diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go index b5ee98614f3..cfad9919506 100644 --- a/gno.land/pkg/gnoweb/handler.go +++ b/gno.land/pkg/gnoweb/handler.go @@ -75,6 +75,7 @@ func (h *WebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + w.Header().Add("Content-Type", "text/html; charset=utf-8") h.Get(w, r) } diff --git a/gno.land/pkg/integration/node_testing.go b/gno.land/pkg/integration/node_testing.go index c613048ebd7..1af699f014d 100644 --- a/gno.land/pkg/integration/node_testing.go +++ b/gno.land/pkg/integration/node_testing.go @@ -8,6 +8,8 @@ import ( "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" + vmm "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gnovm" abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" tmcfg "github.com/gnolang/gno/tm2/pkg/bft/config" "github.com/gnolang/gno/tm2/pkg/bft/node" @@ -186,3 +188,30 @@ func DefaultTestingTMConfig(gnoroot string) *tmcfg.Config { tmconfig.P2P.ListenAddress = defaultListner return tmconfig } + +func GenerateTestingGenesisState(creator crypto.PrivKey, pkgs ...gnovm.MemPackage) gnoland.GnoGenesisState { + txs := make([]gnoland.TxWithMetadata, len(pkgs)) + for i, pkg := range pkgs { + // Create transaction + var tx std.Tx + tx.Fee = std.Fee{GasWanted: 1e6, GasFee: std.Coin{Amount: 1e6, Denom: "ugnot"}} + tx.Msgs = []std.Msg{ + vmm.MsgAddPackage{ + Creator: creator.PubKey().Address(), + Package: &pkg, + }, + } + + tx.Signatures = make([]std.Signature, len(tx.GetSigners())) + txs[i] = gnoland.TxWithMetadata{Tx: tx} + } + + gnoland.SignGenesisTxs(txs, creator, "tendermint_test") + return gnoland.GnoGenesisState{ + Txs: txs, + Balances: []gnoland.Balance{{ + Address: creator.PubKey().Address(), + Amount: std.MustParseCoins(ugnot.ValueString(10_000_000_000_000)), + }}, + } +} diff --git a/gno.land/pkg/integration/node_testing_test.go b/gno.land/pkg/integration/node_testing_test.go new file mode 100644 index 00000000000..96b40bc0ec7 --- /dev/null +++ b/gno.land/pkg/integration/node_testing_test.go @@ -0,0 +1,75 @@ +package integration + +import ( + "testing" + + "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gnovm" + "github.com/gnolang/gno/tm2/pkg/crypto/secp256k1" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateTestingGenesisState(t *testing.T) { + // Generate a test private key and address + privKey := secp256k1.GenPrivKey() + creatorAddr := privKey.PubKey().Address() + + // Create sample packages + pkg1 := gnovm.MemPackage{ + Name: "pkg1", + Path: "pkg1", + Files: []*gnovm.MemFile{ + {Name: "file.gno", Body: "package1"}, + }, + } + pkg2 := gnovm.MemPackage{ + Name: "pkg2", + Path: "pkg2", + Files: []*gnovm.MemFile{ + {Name: "file.gno", Body: "package2"}, + }, + } + + t.Run("single package genesis", func(t *testing.T) { + genesis := GenerateTestingGenesisState(privKey, pkg1) + + // Verify transactions + require.Len(t, genesis.Txs, 1) + tx := genesis.Txs[0].Tx + + // Check the transaction's message + require.Len(t, tx.Msgs, 1) + msg, ok := tx.Msgs[0].(vm.MsgAddPackage) + require.True(t, ok, "expected MsgAddPackage") + assert.Equal(t, pkg1, *msg.Package, "package mismatch") + + // Verify transaction signatures + require.Len(t, tx.Signatures, 1) + assert.NotEmpty(t, tx.Signatures[0], "signature should not be empty") + + // Verify balances + require.Len(t, genesis.Balances, 1) + balance := genesis.Balances[0] + assert.Equal(t, creatorAddr, balance.Address) + assert.Equal(t, std.MustParseCoins(ugnot.ValueString(10_000_000_000_000)), balance.Amount) + }) + + t.Run("multiple packages genesis", func(t *testing.T) { + genesis := GenerateTestingGenesisState(privKey, pkg1, pkg2) + + // Verify two transactions are created + require.Len(t, genesis.Txs, 2) + + // Check each transaction's package + for i, expectedPkg := range []gnovm.MemPackage{pkg1, pkg2} { + tx := genesis.Txs[i].Tx + require.Len(t, tx.Msgs, 1) + msg, ok := tx.Msgs[0].(vm.MsgAddPackage) + require.True(t, ok, "expected MsgAddPackage") + assert.Equal(t, expectedPkg, *msg.Package, "package mismatch in tx %d", i) + } + }) +} diff --git a/gno.land/pkg/keyscli/run.go b/gno.land/pkg/keyscli/run.go index 00b2be585c6..2d7f754203e 100644 --- a/gno.land/pkg/keyscli/run.go +++ b/gno.land/pkg/keyscli/run.go @@ -106,11 +106,12 @@ func execMakeRun(cfg *MakeRunCfg, args []string, cmdio commands.IO) error { } } } + + memPkg.Name = "main" if memPkg.IsEmpty() { panic(fmt.Sprintf("found an empty package %q", memPkg.Path)) } - memPkg.Name = "main" // Set to empty; this will be automatically set by the VM keeper. memPkg.Path = "" diff --git a/gnovm/memfile.go b/gnovm/memfile.go index 6988c893dd7..a08e89579ad 100644 --- a/gnovm/memfile.go +++ b/gnovm/memfile.go @@ -34,7 +34,7 @@ func (mempkg *MemPackage) GetFile(name string) *MemFile { } func (mempkg *MemPackage) IsEmpty() bool { - return len(mempkg.Files) == 0 + return mempkg.Name == "" || len(mempkg.Files) == 0 } const pathLengthLimit = 256 diff --git a/gnovm/pkg/gnolang/nodes.go b/gnovm/pkg/gnolang/nodes.go index 445968a2c9c..0e8f701dea8 100644 --- a/gnovm/pkg/gnolang/nodes.go +++ b/gnovm/pkg/gnolang/nodes.go @@ -1332,12 +1332,13 @@ func ReadMemPackageFromList(list []string, pkgPath string) (*gnovm.MemPackage, e }) } + memPkg.Name = string(pkgName) + // If no .gno files are present, package simply does not exist. if !memPkg.IsEmpty() { if err := validatePkgName(string(pkgName)); err != nil { return nil, err } - memPkg.Name = string(pkgName) } return memPkg, nil diff --git a/tm2/pkg/commands/command.go b/tm2/pkg/commands/command.go index aa717b62ad9..a7f80b69a70 100644 --- a/tm2/pkg/commands/command.go +++ b/tm2/pkg/commands/command.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "io" "os" "strings" "text/tabwriter" @@ -31,26 +32,28 @@ func HelpExec(_ context.Context, _ []string) error { // Metadata contains basic help // information about a command type Metadata struct { - Name string - ShortUsage string - ShortHelp string - LongHelp string - Options []ff.Option + Name string + ShortUsage string + ShortHelp string + LongHelp string + Options []ff.Option + NoParentFlags bool } // Command is a simple wrapper for gnoland commands. type Command struct { - name string - shortUsage string - shortHelp string - longHelp string - options []ff.Option - cfg Config - flagSet *flag.FlagSet - subcommands []*Command - exec ExecMethod - selected *Command - args []string + name string + shortUsage string + shortHelp string + longHelp string + options []ff.Option + cfg Config + flagSet *flag.FlagSet + subcommands []*Command + exec ExecMethod + selected *Command + args []string + noParentFlags bool } func NewCommand( @@ -59,14 +62,15 @@ func NewCommand( exec ExecMethod, ) *Command { command := &Command{ - name: meta.Name, - shortUsage: meta.ShortUsage, - shortHelp: meta.ShortHelp, - longHelp: meta.LongHelp, - options: meta.Options, - flagSet: flag.NewFlagSet(meta.Name, flag.ContinueOnError), - exec: exec, - cfg: config, + name: meta.Name, + shortUsage: meta.ShortUsage, + shortHelp: meta.ShortHelp, + longHelp: meta.LongHelp, + options: meta.Options, + noParentFlags: meta.NoParentFlags, + flagSet: flag.NewFlagSet(meta.Name, flag.ContinueOnError), + exec: exec, + cfg: config, } if config != nil { @@ -77,11 +81,17 @@ func NewCommand( return command } +// SetOutput sets the destination for usage and error messages. +// If output is nil, [os.Stderr] is used. +func (c *Command) SetOutput(output io.Writer) { + c.flagSet.SetOutput(output) +} + // AddSubCommands adds a variable number of subcommands // and registers common flags using the flagset func (c *Command) AddSubCommands(cmds ...*Command) { for _, cmd := range cmds { - if c.cfg != nil { + if c.cfg != nil && !cmd.noParentFlags { // Register the parent flagset with the child. // The syntax is not intuitive, but the flagset being // modified is the subcommand's, using the flags defined