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