From 7f7b0e869641ff61cf5c07fa5bfe7ae3ad83151b Mon Sep 17 00:00:00 2001 From: ygd58 Date: Tue, 21 Jul 2026 02:26:03 +0000 Subject: [PATCH 1/2] feat(gnovm): add -X flag to gno run and gno test (AST-based) Adds a -X name=value flag (repeatable) to 'gno run' and 'gno test', mirroring 'go build -ldflags "-X pkg.name=value"'. Rewritten from an earlier regex-based approach per review feedback (PR #5985 / notJoon): a text-based regex can't distinguish a real top-level var declaration from text that merely looks like one, e.g. inside a backtick-quoted raw string literal elsewhere in the file. This version parses the source with go/parser, walks only the file's top-level Decls (so it never descends into function bodies, and never touches local variables or string-literal *contents*), finds package-level 'var name = "..."' declarations (including grouped 'var (...)' blocks, and both quoted and raw-string initializers) whose name matches an override, replaces the literal's Value via strconv.Quote, and re-serializes the file with go/printer. Only top-level var (never const) declarations are eligible, matching go build -X's own semantics. Note: 'gno build' and 'gno publish' don't currently exist as separate gno CLI subcommands (only run/test/lint/etc. do), so this covers the two existing commands that parse and execute .gno source directly; see the PR discussion for more on this. Added a dedicated regression test for the exact scenario from review (a raw string containing text that resembles a var declaration must be left untouched), plus tests for grouped var blocks, const declarations being skipped, and function-local variables being left alone. Closes #1021 --- gnovm/cmd/gno/run.go | 33 +++- gnovm/cmd/gno/run_test.go | 12 ++ gnovm/cmd/gno/test.go | 14 ++ gnovm/cmd/gno/xflag.go | 128 +++++++++++++ gnovm/cmd/gno/xflag_test.go | 266 +++++++++++++++++++++++++++ gnovm/tests/integ/run_xflag/main.gno | 7 + 6 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 gnovm/cmd/gno/xflag.go create mode 100644 gnovm/cmd/gno/xflag_test.go create mode 100644 gnovm/tests/integ/run_xflag/main.gno diff --git a/gnovm/cmd/gno/run.go b/gnovm/cmd/gno/run.go index 5975b02e5f3..33e49ac1634 100644 --- a/gnovm/cmd/gno/run.go +++ b/gnovm/cmd/gno/run.go @@ -25,6 +25,7 @@ type runCmd struct { debug bool debugAddr string pkgPath string + xVars *xFlag } func newRunCmd(cio commands.IO) *commands.Command { @@ -85,6 +86,14 @@ func (c *runCmd) RegisterFlags(fs *flag.FlagSet) { "", "run with this package path, overriding the \"// PKGPATH:\" file directive and the gnomod.toml module path", ) + + c.xVars = newXFlag() + fs.Var( + c.xVars, + "X", + "set the value of a package-level string variable, e.g. -X myVar=override (may be repeated); "+ + "like 'go build -ldflags \"-X ...\"', only simple 'var name = \"...\"' declarations are patched", + ) } func packageNameFromFiles(args []string) (string, error) { @@ -272,7 +281,7 @@ func execRun(cfg *runCmd, args []string, cio commands.IO) error { } // read files - files, err := parseFiles(m, args, stderr) + files, err := parseFiles(m, args, stderr, cfg.xVars.values) if err != nil { return err } @@ -335,7 +344,7 @@ func derivePkgPath(arg string) (string, error) { return mod.Module, nil } -func parseFiles(m *gno.Machine, fpaths []string, stderr io.WriteCloser) ([]*gno.FileNode, error) { +func parseFiles(m *gno.Machine, fpaths []string, stderr io.WriteCloser, xOverrides map[string]string) ([]*gno.FileNode, error) { files := make([]*gno.FileNode, 0, len(fpaths)) var didPanic bool for _, fpath := range fpaths { @@ -344,7 +353,7 @@ func parseFiles(m *gno.Machine, fpaths []string, stderr io.WriteCloser) ([]*gno. if err != nil { return nil, err } - subFiles, err := parseFiles(m, subFns, stderr) + subFiles, err := parseFiles(m, subFns, stderr, xOverrides) if err != nil { return nil, err } @@ -358,7 +367,7 @@ func parseFiles(m *gno.Machine, fpaths []string, stderr io.WriteCloser) ([]*gno. dir, fname := filepath.Split(fpath) didPanic = catchPanic(dir, fname, stderr, func() { - files = append(files, m.MustReadFile(fpath)) + files = append(files, mustReadAndPatchFile(m, fpath, xOverrides)) }) } @@ -368,6 +377,22 @@ func parseFiles(m *gno.Machine, fpaths []string, stderr io.WriteCloser) ([]*gno. return files, nil } +// mustReadAndPatchFile reads fpath, applies any -X overrides to its +// package-level string variable declarations (see patchXVars), and parses +// the (possibly patched) result. It panics on error, like +// (*gno.Machine).MustReadFile, which it replaces as the read path here so +// that overrides can be applied before parsing. +func mustReadAndPatchFile(m *gno.Machine, fpath string, xOverrides map[string]string) *gno.FileNode { + bz, err := os.ReadFile(fpath) + if err != nil { + panic(err) + } + + body := patchXVars(fpath, string(bz), xOverrides) + + return m.MustParseFile(fpath, body) +} + func listNonTestFiles(dir string) ([]string, error) { fs, err := os.ReadDir(dir) if err != nil { diff --git a/gnovm/cmd/gno/run_test.go b/gnovm/cmd/gno/run_test.go index 65197da48f7..6daeff2f468 100644 --- a/gnovm/cmd/gno/run_test.go +++ b/gnovm/cmd/gno/run_test.go @@ -19,6 +19,18 @@ func TestRunApp(t *testing.T) { args: []string{"run", "../../tests/integ/run_main/"}, stdoutShouldContain: "hello world!", }, + { + args: []string{"run", "../../tests/integ/run_xflag/main.gno"}, + stdoutShouldContain: "default", + }, + { + args: []string{"run", "-X", "myVar=overridden", "../../tests/integ/run_xflag/main.gno"}, + stdoutShouldContain: "overridden", + }, + { + args: []string{"run", "-X", "invalidnoequals", "../../tests/integ/run_xflag/main.gno"}, + errShouldContain: "invalid -X value", + }, { args: []string{"run", "../../tests/integ/does_not_exist"}, errShouldContain: "no such file or directory", diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 833fee3c1ab..1d008144d9a 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -38,6 +38,7 @@ type testCmd struct { printEvents bool debug bool parallel int + xVars *xFlag } func newTestCmd(io commands.IO) *commands.Command { @@ -187,6 +188,14 @@ func (c *testCmd) RegisterFlags(fs *flag.FlagSet) { "-debug enforces -p 1.", runtime.GOMAXPROCS(0)), ) + + c.xVars = newXFlag() + fs.Var( + c.xVars, + "X", + "set the value of a package-level string variable, e.g. -X myVar=override (may be repeated); "+ + "like 'go build -ldflags \"-X ...\"', only simple 'var name = \"...\"' declarations are patched", + ) } func execTest(cmd *testCmd, args []string, io commands.IO) error { @@ -437,6 +446,11 @@ func (c *testCmd) testPkg( // Read MemPackage with all files. mpkg := gno.MustReadMemPackage(pkg.Dir, pkgPath, gno.MPAnyAll) + if len(c.xVars.values) > 0 { + for _, f := range mpkg.Files { + f.Body = patchXVars(f.Name, f.Body, c.xVars.values) + } + } var didPanic, didError bool startedAt := time.Now() didPanic = catchPanic(pkg.Dir, pkgPath, io.Err(), func() { diff --git a/gnovm/cmd/gno/xflag.go b/gnovm/cmd/gno/xflag.go new file mode 100644 index 00000000000..bdd5ec7d694 --- /dev/null +++ b/gnovm/cmd/gno/xflag.go @@ -0,0 +1,128 @@ +package main + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "strconv" + "strings" +) + +// patchXVars parses body as Go source and rewrites the string-literal +// initializer of any package-level `var name = "..."` (or +// `var name T = "..."`, including grouped `var ( ... )` blocks) +// declaration whose name matches a key in overrides, similar to +// `go build -ldflags "-X pkg.name=value"`. +// +// Unlike a text-based find/replace, this walks the parsed AST and only +// descends into top-level declarations, so: +// - a string or raw string literal elsewhere in the file that merely +// looks like a var declaration (e.g. inside a backtick-quoted +// template string) is never touched, and +// - local variables declared inside function bodies are never touched, +// even if they shadow a package-level name in overrides. +// +// If overrides is empty, body is returned unchanged. If body fails to +// parse, body is also returned unchanged: the caller's own subsequent +// parse of the file (with gno's parser) will surface the real syntax +// error, so this function doesn't need to duplicate that diagnostic. +func patchXVars(fname, body string, overrides map[string]string) string { + if len(overrides) == 0 { + return body + } + + fset := token.NewFileSet() + + astFile, err := parser.ParseFile(fset, fname, body, parser.ParseComments) + if err != nil { + return body + } + + patched := false + + for _, decl := range astFile.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.VAR { + continue + } + + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + for i, name := range valueSpec.Names { + if i >= len(valueSpec.Values) { + continue // no initializer to patch, e.g. `var x string` + } + + value, ok := overrides[name.Name] + if !ok { + continue + } + + lit, ok := valueSpec.Values[i].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue // not a simple string literal initializer + } + + lit.Value = strconv.Quote(value) + patched = true + } + } + } + + if !patched { + return body + } + + var buf bytes.Buffer + if err := printer.Fprint(&buf, fset, astFile); err != nil { + return body + } + + return buf.String() +} + +// xFlag implements flag.Value, collecting repeated `-X name=value` pairs +// into a map, mirroring the semantics of `go build -ldflags "-X ...=..."`. +type xFlag struct { + values map[string]string +} + +// newXFlag returns an initialized, empty xFlag. +func newXFlag() *xFlag { + return &xFlag{values: map[string]string{}} +} + +// String implements flag.Value. +func (x *xFlag) String() string { + if x == nil || len(x.values) == 0 { + return "" + } + + parts := make([]string, 0, len(x.values)) + for k, v := range x.values { + parts = append(parts, k+"="+v) + } + + return strings.Join(parts, ",") +} + +// Set implements flag.Value. It parses a single "name=value" pair and +// records it, overwriting any previous value for the same name. It may be +// called multiple times (once per -X flag occurrence on the command line). +func (x *xFlag) Set(s string) error { + name, value, found := strings.Cut(s, "=") + if !found || name == "" { + return fmt.Errorf("invalid -X value %q: expected format name=value", s) + } + + x.values[name] = value + + return nil +} diff --git a/gnovm/cmd/gno/xflag_test.go b/gnovm/cmd/gno/xflag_test.go new file mode 100644 index 00000000000..0d36da930e1 --- /dev/null +++ b/gnovm/cmd/gno/xflag_test.go @@ -0,0 +1,266 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "strconv" + "strings" + "testing" +) + +// varValue re-parses body and returns the unquoted string value of the +// package-level `var name = "..."` declaration named name, or ("", false) +// if no such declaration (with a string literal initializer) exists. +// Used to make assertions robust to go/printer's exact formatting choices +// (spacing, alignment, etc.), which this package doesn't want to hardcode +// expectations about. +func varValue(t *testing.T, body, name string) (string, bool) { + t.Helper() + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "test.gno", body, 0) + if err != nil { + t.Fatalf("varValue: re-parsing patched output failed: %v\n---\n%s", err, body) + } + + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.VAR { + continue + } + + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + for i, n := range valueSpec.Names { + if n.Name != name || i >= len(valueSpec.Values) { + continue + } + + lit, ok := valueSpec.Values[i].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + + unquoted, err := strconv.Unquote(lit.Value) + if err != nil { + t.Fatalf("varValue: unquoting %s: %v", lit.Value, err) + } + + return unquoted, true + } + } + } + + return "", false +} + +func TestPatchXVars(t *testing.T) { + t.Parallel() + + t.Run("no overrides returns body unchanged", func(t *testing.T) { + t.Parallel() + + body := "package main\n\nvar myVar string = \"default\"\n" + got := patchXVars("test.gno", body, nil) + + if got != body { + t.Errorf("expected unchanged body, got:\n%s", got) + } + }) + + t.Run("simple override, explicit string type", func(t *testing.T) { + t.Parallel() + + body := "package main\n\nvar myVar string = \"default\"\n" + got := patchXVars("test.gno", body, map[string]string{"myVar": "override"}) + + value, ok := varValue(t, got, "myVar") + if !ok || value != "override" { + t.Errorf("myVar = %q, ok = %v; want %q, true", value, ok, "override") + } + }) + + t.Run("simple override, inferred type", func(t *testing.T) { + t.Parallel() + + body := "package main\n\nvar myVar = \"default\"\n" + got := patchXVars("test.gno", body, map[string]string{"myVar": "override"}) + + value, ok := varValue(t, got, "myVar") + if !ok || value != "override" { + t.Errorf("myVar = %q, ok = %v; want %q, true", value, ok, "override") + } + }) + + t.Run("raw string literal initializer replaced", func(t *testing.T) { + t.Parallel() + + body := "package main\n\nvar myVar = `default`\n" + got := patchXVars("test.gno", body, map[string]string{"myVar": "override"}) + + value, ok := varValue(t, got, "myVar") + if !ok || value != "override" { + t.Errorf("myVar = %q, ok = %v; want %q, true", value, ok, "override") + } + }) + + t.Run("override value round-trips through quoting", func(t *testing.T) { + t.Parallel() + + body := "package main\n\nvar myVar = \"default\"\n" + want := `has "quotes" and \backslash` + got := patchXVars("test.gno", body, map[string]string{"myVar": want}) + + value, ok := varValue(t, got, "myVar") + if !ok || value != want { + t.Errorf("myVar = %q, ok = %v; want %q, true", value, ok, want) + } + }) + + t.Run("no matching var name leaves declaration untouched", func(t *testing.T) { + t.Parallel() + + body := "package main\n\nvar otherVar = \"default\"\n" + got := patchXVars("test.gno", body, map[string]string{"myVar": "override"}) + + value, ok := varValue(t, got, "otherVar") + if !ok || value != "default" { + t.Errorf("otherVar = %q, ok = %v; want %q, true", value, ok, "default") + } + }) + + t.Run("grouped var block: only matching names are patched", func(t *testing.T) { + t.Parallel() + + body := "package main\n\nvar (\n\ta = \"a-default\"\n\tb = \"b-default\"\n\tc = \"c-default\"\n)\n" + got := patchXVars("test.gno", body, map[string]string{ + "a": "a-override", + "c": "c-override", + }) + + for name, want := range map[string]string{ + "a": "a-override", + "b": "b-default", + "c": "c-override", + } { + value, ok := varValue(t, got, name) + if !ok || value != want { + t.Errorf("%s = %q, ok = %v; want %q, true", name, value, ok, want) + } + } + }) + + t.Run("text resembling a var decl inside a raw string is left alone", func(t *testing.T) { + t.Parallel() + + // Regression test for a review comment on the original, + // regex-based implementation: a raw string literal that merely + // *contains* text resembling a top-level var declaration must + // never be touched, since it isn't a real declaration. + body := "package main\n\nvar myVar = `\nvar myVar = \"not a real declaration\"\n`\n" + got := patchXVars("test.gno", body, map[string]string{"myVar": "override"}) + + value, ok := varValue(t, got, "myVar") + if !ok || value != "override" { + t.Errorf("myVar = %q, ok = %v; want %q, true", value, ok, "override") + } + + // The AST-level check above already proves only the real + // declaration was patched (there's only one `myVar` decl to find + // once the raw string's fake one is correctly excluded from + // consideration), but assert readably too: the literal source + // text of the fake declaration must be preserved verbatim. + if !strings.Contains(got, `not a real declaration`) { + t.Errorf("expected the raw string's contents to be preserved, got:\n%s", got) + } + }) + + t.Run("local variable in a function body is left alone", func(t *testing.T) { + t.Parallel() + + body := "package main\n\nvar myVar = \"default\"\n\nfunc f() string {\n\tmyVar := \"local\"\n\treturn myVar\n}\n" + got := patchXVars("test.gno", body, map[string]string{"myVar": "override"}) + + value, ok := varValue(t, got, "myVar") + if !ok || value != "override" { + t.Errorf("package-level myVar = %q, ok = %v; want %q, true", value, ok, "override") + } + + if !strings.Contains(got, `"local"`) { + t.Errorf("expected the function-local assignment to be preserved, got:\n%s", got) + } + }) + + t.Run("const declarations are never patched", func(t *testing.T) { + t.Parallel() + + body := "package main\n\nconst myVar = \"default\"\n" + got := patchXVars("test.gno", body, map[string]string{"myVar": "override"}) + + if !strings.Contains(got, `"default"`) || strings.Contains(got, `"override"`) { + t.Errorf("expected const myVar to remain \"default\", got:\n%s", got) + } + }) + + t.Run("unparseable source is returned unchanged", func(t *testing.T) { + t.Parallel() + + body := "this is not valid go source {{{" + got := patchXVars("test.gno", body, map[string]string{"myVar": "override"}) + + if got != body { + t.Errorf("expected unchanged body for unparseable source, got:\n%s", got) + } + }) +} + +func TestXFlag_SetAndString(t *testing.T) { + t.Parallel() + + x := newXFlag() + + if err := x.Set("myVar=override"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if got, want := x.values["myVar"], "override"; got != want { + t.Errorf("values[myVar] = %q, want %q", got, want) + } + + // A value containing an "=" sign should only split on the first one. + if err := x.Set("eq=a=b"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if got, want := x.values["eq"], "a=b"; got != want { + t.Errorf("values[eq] = %q, want %q", got, want) + } +} + +func TestXFlag_SetInvalid(t *testing.T) { + t.Parallel() + + x := newXFlag() + + if err := x.Set("novalue"); err == nil { + t.Error("Set(\"novalue\") expected an error, got nil") + } + + if err := x.Set("=novalue"); err == nil { + t.Error("Set(\"=novalue\") expected an error, got nil") + } +} + +func TestXFlag_NilString(t *testing.T) { + t.Parallel() + + var x *xFlag + if got := x.String(); got != "" { + t.Errorf("(*xFlag)(nil).String() = %q, want empty string", got) + } +} diff --git a/gnovm/tests/integ/run_xflag/main.gno b/gnovm/tests/integ/run_xflag/main.gno new file mode 100644 index 00000000000..0bea6cfc92e --- /dev/null +++ b/gnovm/tests/integ/run_xflag/main.gno @@ -0,0 +1,7 @@ +package main + +var myVar string = "default" + +func main() { + println(myVar) +} From 639f73fb35da2d2e8ab3e66a1622817a49a1e9ad Mon Sep 17 00:00:00 2001 From: ygd58 Date: Tue, 21 Jul 2026 04:31:13 +0000 Subject: [PATCH 2/2] test(gnovm): fix flawed regression test for the raw-string review scenario The previous version of this test only had ONE actual var declaration (whose value happened to be a raw string containing fake-looking declaration text), so patching that single declaration's entire value -- including the embedded fake text -- was correct behavior, not a bug. The test's assertion that the fake text should survive was wrong. Rewritten with two separate declarations: a real "var myVar = ..." to patch, and a different "var otherVar = " whose raw string value merely contains text resembling a var myVar declaration. This actually exercises the scenario from review: only the real declaration is patched, and otherVar's raw string content is preserved byte-for-byte. --- gnovm/cmd/gno/xflag_test.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/gnovm/cmd/gno/xflag_test.go b/gnovm/cmd/gno/xflag_test.go index 0d36da930e1..1b439704e3f 100644 --- a/gnovm/cmd/gno/xflag_test.go +++ b/gnovm/cmd/gno/xflag_test.go @@ -155,14 +155,17 @@ func TestPatchXVars(t *testing.T) { } }) - t.Run("text resembling a var decl inside a raw string is left alone", func(t *testing.T) { + t.Run("text resembling a var decl inside another var's raw string is left alone", func(t *testing.T) { t.Parallel() // Regression test for a review comment on the original, // regex-based implementation: a raw string literal that merely // *contains* text resembling a top-level var declaration must - // never be touched, since it isn't a real declaration. - body := "package main\n\nvar myVar = `\nvar myVar = \"not a real declaration\"\n`\n" + // never be touched, since it isn't a real declaration -- only + // the actual "var myVar = ..." decl below is. + body := "package main\n\n" + + "var otherVar = `\nvar myVar = \"fake, must not be touched\"\n`\n\n" + + "var myVar = \"real default\"\n" got := patchXVars("test.gno", body, map[string]string{"myVar": "override"}) value, ok := varValue(t, got, "myVar") @@ -170,13 +173,12 @@ func TestPatchXVars(t *testing.T) { t.Errorf("myVar = %q, ok = %v; want %q, true", value, ok, "override") } - // The AST-level check above already proves only the real - // declaration was patched (there's only one `myVar` decl to find - // once the raw string's fake one is correctly excluded from - // consideration), but assert readably too: the literal source - // text of the fake declaration must be preserved verbatim. - if !strings.Contains(got, `not a real declaration`) { - t.Errorf("expected the raw string's contents to be preserved, got:\n%s", got) + // otherVar's raw string content -- which merely contains text + // that *looks* like a var myVar declaration -- must be preserved + // byte-for-byte, proving the fake declaration inside it was + // never treated as a real one. + if !strings.Contains(got, `var myVar = "fake, must not be touched"`) { + t.Errorf("expected otherVar's raw string contents to be preserved verbatim, got:\n%s", got) } })