diff --git a/examples/Makefile b/examples/Makefile index 63a20f78eb9..73413e357fd 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -1,65 +1,65 @@ -.PHONY: help -help: - @echo "Available make commands:" - @cat Makefile | grep '^[a-z][^:]*:' | cut -d: -f1 | sort | sed 's/^/ /' - -# command to run dependency utilities, like goimports. -rundep=go run -modfile ../misc/devdeps/go.mod - -######################################## -# Environment variables -# You can overwrite any of the following by passing a different value on the -# command line, ie. `CGO_ENABLED=1 make test`. - -# disable cgo by default. cgo requires some additional dependencies in some -# cases, and is not strictly required by any tm2 code. -CGO_ENABLED ?= 0 -export CGO_ENABLED -# flags for `make fmt`. -w will write the result to the destination files. -GOFMT_FLAGS ?= -w -# flags for `make imports`. -GOIMPORTS_FLAGS ?= $(GOFMT_FLAGS) -# test suite flags. -GOTEST_FLAGS ?= -v -p 1 -timeout=30m - -# Official packages (non-overridable): more reliable and tested modules, distinct from the experimentation area. -OFFICIAL_PACKAGES = ./gno.land/p -OFFICIAL_PACKAGES += ./gno.land/r/demo -OFFICIAL_PACKAGES += ./gno.land/r/gnoland -OFFICIAL_PACKAGES += ./gno.land/r/sys -OFFICIAL_PACKAGES += ./gno.land/r/gov - -######################################## -# Dev tools -.PHONY: transpile -transpile: - go run ../gnovm/cmd/gno tool transpile -v . - -.PHONY: build -build: - go run ../gnovm/cmd/gno tool transpile -v --gobuild . - -.PHONY: test -test: - go run ../gnovm/cmd/gno test -v ./... - -.PHONY: lint -lint: - go run ../gnovm/cmd/gno tool lint -v $(OFFICIAL_PACKAGES) - -.PHONY: test.sync -test.sync: - go run ../gnovm/cmd/gno test -v --update-golden-tests ./... - -.PHONY: clean -clean: - find . \( -name "*.gno.gen.go" -or -name ".*.gno.gen_test.go" \) -delete - -.PHONY: fmt -GNOFMT_FLAGS ?= -w -fmt: - go run ../gnovm/cmd/gno fmt $(GNOFMT_FLAGS) ./... - -.PHONY: tidy -tidy: - go run github.com/gnolang/gno/gnovm/cmd/gno mod tidy -v --recursive +.PHONY: help +help: + @echo "Available make commands:" + @cat Makefile | grep '^[a-z][^:]*:' | cut -d: -f1 | sort | sed 's/^/ /' + +# command to run dependency utilities, like goimports. +rundep=go run -modfile ../misc/devdeps/go.mod + +######################################## +# Environment variables +# You can overwrite any of the following by passing a different value on the +# command line, ie. `CGO_ENABLED=1 make test`. + +# disable cgo by default. cgo requires some additional dependencies in some +# cases, and is not strictly required by any tm2 code. +CGO_ENABLED ?= 0 +export CGO_ENABLED +# flags for `make fmt`. -w will write the result to the destination files. +GOFMT_FLAGS ?= -w +# flags for `make imports`. +GOIMPORTS_FLAGS ?= $(GOFMT_FLAGS) +# test suite flags. +GOTEST_FLAGS ?= -v -p 1 -timeout=30m + +# Official packages (non-overridable): more reliable and tested modules, distinct from the experimentation area. +OFFICIAL_PACKAGES = ./gno.land/p +OFFICIAL_PACKAGES += ./gno.land/r/demo +OFFICIAL_PACKAGES += ./gno.land/r/gnoland +OFFICIAL_PACKAGES += ./gno.land/r/sys +OFFICIAL_PACKAGES += ./gno.land/r/gov + +######################################## +# Dev tools +.PHONY: transpile +transpile: + go run ../gnovm/cmd/gno tool transpile -v . + +.PHONY: build +build: + go run ../gnovm/cmd/gno tool transpile -v --gobuild . + +.PHONY: test +test: + go run ../gnovm/cmd/gno test -v ./... + +.PHONY: lint +lint: + go run ../gnovm/cmd/gno tool lint -v $(OFFICIAL_PACKAGES) + +.PHONY: test.sync +test.sync: + go run ../gnovm/cmd/gno test -v --update-golden-tests ./... + +.PHONY: clean +clean: + find . \( -name "*.gno.gen.go" -or -name ".*.gno.gen_test.go" \) -delete + +.PHONY: fmt +GNOFMT_FLAGS ?= -w +fmt: + go run ../gnovm/cmd/gno fmt $(GNOFMT_FLAGS) ./... + +.PHONY: tidy +tidy: + go run github.com/gnolang/gno/gnovm/cmd/gno mod tidy -v --recursive diff --git a/examples/gno.land/p/moul/xmath/generate.go b/examples/gno.land/p/moul/xmath/generate.go index ad70adb06bd..76431c5edb7 100644 --- a/examples/gno.land/p/moul/xmath/generate.go +++ b/examples/gno.land/p/moul/xmath/generate.go @@ -1,3 +1,3 @@ -package xmath - -//go:generate go run generator.go +package xmath + +//go:generate go run generator.go diff --git a/examples/gno.land/p/moul/xmath/generator.go b/examples/gno.land/p/moul/xmath/generator.go index afe5a4341fa..38157bf3cd7 100644 --- a/examples/gno.land/p/moul/xmath/generator.go +++ b/examples/gno.land/p/moul/xmath/generator.go @@ -1,184 +1,184 @@ -//go:build ignore - -package main - -import ( - "bytes" - "fmt" - "go/format" - "log" - "os" - "strings" - "text/template" -) - -type Type struct { - Name string - ZeroValue string - Signed bool - Float bool -} - -var types = []Type{ - {"Int8", "0", true, false}, - {"Int16", "0", true, false}, - {"Int32", "0", true, false}, - {"Int64", "0", true, false}, - {"Int", "0", true, false}, - {"Uint8", "0", false, false}, - {"Uint16", "0", false, false}, - {"Uint32", "0", false, false}, - {"Uint64", "0", false, false}, - {"Uint", "0", false, false}, - {"Float32", "0.0", true, true}, - {"Float64", "0.0", true, true}, -} - -const sourceTpl = `// Code generated by generator.go; DO NOT EDIT. -package xmath - -{{ range .Types }} -// {{.Name}} helpers -func Max{{.Name}}(a, b {{.Name | lower}}) {{.Name | lower}} { - if a > b { - return a - } - return b -} - -func Min{{.Name}}(a, b {{.Name | lower}}) {{.Name | lower}} { - if a < b { - return a - } - return b -} - -func Clamp{{.Name}}(value, min, max {{.Name | lower}}) {{.Name | lower}} { - if value < min { - return min - } - if value > max { - return max - } - return value -} -{{if .Signed}} -func Abs{{.Name}}(x {{.Name | lower}}) {{.Name | lower}} { - if x < 0 { - return -x - } - return x -} - -func Sign{{.Name}}(x {{.Name | lower}}) {{.Name | lower}} { - if x < 0 { - return -1 - } - if x > 0 { - return 1 - } - return 0 -} -{{end}} -{{end}} -` - -const testTpl = `package xmath - -import "testing" - -{{range .Types}} -func Test{{.Name}}Helpers(t *testing.T) { - // Test Max{{.Name}} - if Max{{.Name}}(1, 2) != 2 { - t.Error("Max{{.Name}}(1, 2) should be 2") - } - {{if .Signed}}if Max{{.Name}}(-1, -2) != -1 { - t.Error("Max{{.Name}}(-1, -2) should be -1") - }{{end}} - - // Test Min{{.Name}} - if Min{{.Name}}(1, 2) != 1 { - t.Error("Min{{.Name}}(1, 2) should be 1") - } - {{if .Signed}}if Min{{.Name}}(-1, -2) != -2 { - t.Error("Min{{.Name}}(-1, -2) should be -2") - }{{end}} - - // Test Clamp{{.Name}} - if Clamp{{.Name}}(5, 1, 3) != 3 { - t.Error("Clamp{{.Name}}(5, 1, 3) should be 3") - } - if Clamp{{.Name}}(0, 1, 3) != 1 { - t.Error("Clamp{{.Name}}(0, 1, 3) should be 1") - } - if Clamp{{.Name}}(2, 1, 3) != 2 { - t.Error("Clamp{{.Name}}(2, 1, 3) should be 2") - } - {{if .Signed}} - // Test Abs{{.Name}} - if Abs{{.Name}}(-5) != 5 { - t.Error("Abs{{.Name}}(-5) should be 5") - } - if Abs{{.Name}}(5) != 5 { - t.Error("Abs{{.Name}}(5) should be 5") - } - - // Test Sign{{.Name}} - if Sign{{.Name}}(-5) != -1 { - t.Error("Sign{{.Name}}(-5) should be -1") - } - if Sign{{.Name}}(5) != 1 { - t.Error("Sign{{.Name}}(5) should be 1") - } - if Sign{{.Name}}({{.ZeroValue}}) != 0 { - t.Error("Sign{{.Name}}({{.ZeroValue}}) should be 0") - } - {{end}} -} -{{end}} -` - -func main() { - funcMap := template.FuncMap{ - "lower": strings.ToLower, - } - - // Generate source file - sourceTmpl := template.Must(template.New("source").Funcs(funcMap).Parse(sourceTpl)) - var sourceOut bytes.Buffer - if err := sourceTmpl.Execute(&sourceOut, struct{ Types []Type }{types}); err != nil { - log.Fatal(err) - } - - // Format the generated code - formattedSource, err := format.Source(sourceOut.Bytes()) - if err != nil { - log.Fatal(err) - } - - // Write source file - if err := os.WriteFile("xmath.gen.gno", formattedSource, 0644); err != nil { - log.Fatal(err) - } - - // Generate test file - testTmpl := template.Must(template.New("test").Parse(testTpl)) - var testOut bytes.Buffer - if err := testTmpl.Execute(&testOut, struct{ Types []Type }{types}); err != nil { - log.Fatal(err) - } - - // Format the generated test code - formattedTest, err := format.Source(testOut.Bytes()) - if err != nil { - log.Fatal(err) - } - - // Write test file - if err := os.WriteFile("xmath.gen_test.gno", formattedTest, 0644); err != nil { - log.Fatal(err) - } - - fmt.Println("Generated xmath.gen.gno and xmath.gen_test.gno") -} +//go:build ignore + +package main + +import ( + "bytes" + "fmt" + "go/format" + "log" + "os" + "strings" + "text/template" +) + +type Type struct { + Name string + ZeroValue string + Signed bool + Float bool +} + +var types = []Type{ + {"Int8", "0", true, false}, + {"Int16", "0", true, false}, + {"Int32", "0", true, false}, + {"Int64", "0", true, false}, + {"Int", "0", true, false}, + {"Uint8", "0", false, false}, + {"Uint16", "0", false, false}, + {"Uint32", "0", false, false}, + {"Uint64", "0", false, false}, + {"Uint", "0", false, false}, + {"Float32", "0.0", true, true}, + {"Float64", "0.0", true, true}, +} + +const sourceTpl = `// Code generated by generator.go; DO NOT EDIT. +package xmath + +{{ range .Types }} +// {{.Name}} helpers +func Max{{.Name}}(a, b {{.Name | lower}}) {{.Name | lower}} { + if a > b { + return a + } + return b +} + +func Min{{.Name}}(a, b {{.Name | lower}}) {{.Name | lower}} { + if a < b { + return a + } + return b +} + +func Clamp{{.Name}}(value, min, max {{.Name | lower}}) {{.Name | lower}} { + if value < min { + return min + } + if value > max { + return max + } + return value +} +{{if .Signed}} +func Abs{{.Name}}(x {{.Name | lower}}) {{.Name | lower}} { + if x < 0 { + return -x + } + return x +} + +func Sign{{.Name}}(x {{.Name | lower}}) {{.Name | lower}} { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} +{{end}} +{{end}} +` + +const testTpl = `package xmath + +import "testing" + +{{range .Types}} +func Test{{.Name}}Helpers(t *testing.T) { + // Test Max{{.Name}} + if Max{{.Name}}(1, 2) != 2 { + t.Error("Max{{.Name}}(1, 2) should be 2") + } + {{if .Signed}}if Max{{.Name}}(-1, -2) != -1 { + t.Error("Max{{.Name}}(-1, -2) should be -1") + }{{end}} + + // Test Min{{.Name}} + if Min{{.Name}}(1, 2) != 1 { + t.Error("Min{{.Name}}(1, 2) should be 1") + } + {{if .Signed}}if Min{{.Name}}(-1, -2) != -2 { + t.Error("Min{{.Name}}(-1, -2) should be -2") + }{{end}} + + // Test Clamp{{.Name}} + if Clamp{{.Name}}(5, 1, 3) != 3 { + t.Error("Clamp{{.Name}}(5, 1, 3) should be 3") + } + if Clamp{{.Name}}(0, 1, 3) != 1 { + t.Error("Clamp{{.Name}}(0, 1, 3) should be 1") + } + if Clamp{{.Name}}(2, 1, 3) != 2 { + t.Error("Clamp{{.Name}}(2, 1, 3) should be 2") + } + {{if .Signed}} + // Test Abs{{.Name}} + if Abs{{.Name}}(-5) != 5 { + t.Error("Abs{{.Name}}(-5) should be 5") + } + if Abs{{.Name}}(5) != 5 { + t.Error("Abs{{.Name}}(5) should be 5") + } + + // Test Sign{{.Name}} + if Sign{{.Name}}(-5) != -1 { + t.Error("Sign{{.Name}}(-5) should be -1") + } + if Sign{{.Name}}(5) != 1 { + t.Error("Sign{{.Name}}(5) should be 1") + } + if Sign{{.Name}}({{.ZeroValue}}) != 0 { + t.Error("Sign{{.Name}}({{.ZeroValue}}) should be 0") + } + {{end}} +} +{{end}} +` + +func main() { + funcMap := template.FuncMap{ + "lower": strings.ToLower, + } + + // Generate source file + sourceTmpl := template.Must(template.New("source").Funcs(funcMap).Parse(sourceTpl)) + var sourceOut bytes.Buffer + if err := sourceTmpl.Execute(&sourceOut, struct{ Types []Type }{types}); err != nil { + log.Fatal(err) + } + + // Format the generated code + formattedSource, err := format.Source(sourceOut.Bytes()) + if err != nil { + log.Fatal(err) + } + + // Write source file + if err := os.WriteFile("xmath.gen.gno", formattedSource, 0644); err != nil { + log.Fatal(err) + } + + // Generate test file + testTmpl := template.Must(template.New("test").Parse(testTpl)) + var testOut bytes.Buffer + if err := testTmpl.Execute(&testOut, struct{ Types []Type }{types}); err != nil { + log.Fatal(err) + } + + // Format the generated test code + formattedTest, err := format.Source(testOut.Bytes()) + if err != nil { + log.Fatal(err) + } + + // Write test file + if err := os.WriteFile("xmath.gen_test.gno", formattedTest, 0644); err != nil { + log.Fatal(err) + } + + fmt.Println("Generated xmath.gen.gno and xmath.gen_test.gno") +} diff --git a/examples/gno.land/r/jjoptimist/home/config.gno b/examples/gno.land/r/jjoptimist/home/config.gno new file mode 100644 index 00000000000..ab24178ba6a --- /dev/null +++ b/examples/gno.land/r/jjoptimist/home/config.gno @@ -0,0 +1,33 @@ +package home + +import ( + "std" + + "gno.land/p/demo/ownable" +) + +type Config struct { + Title string + Description string + Github string +} + +var ( + config = Config{ + Title: "JJOptimist's Home Realm 🏠", + Description: "Exploring Gno and building on-chain", + Github: "jjoptimist", + } + Ownable = ownable.NewWithAddress(std.Address("g16vfw3r7zuz43fhky3xfsuc2hdv9tnhvlkyn0nj")) +) + +func GetConfig() Config { + return config +} + +func UpdateConfig(newTitle, newDescription, newGithub string) { + Ownable.AssertCallerIsOwner() + config.Title = newTitle + config.Description = newDescription + config.Github = newGithub +} diff --git a/examples/gno.land/r/jjoptimist/home/gno.mod b/examples/gno.land/r/jjoptimist/home/gno.mod new file mode 100644 index 00000000000..b4b591f6ab7 --- /dev/null +++ b/examples/gno.land/r/jjoptimist/home/gno.mod @@ -0,0 +1 @@ +module gno.land/r/jjoptimist/home diff --git a/examples/gno.land/r/jjoptimist/home/home.gno b/examples/gno.land/r/jjoptimist/home/home.gno new file mode 100644 index 00000000000..e88a6fcb685 --- /dev/null +++ b/examples/gno.land/r/jjoptimist/home/home.gno @@ -0,0 +1,82 @@ +package home + +import ( + "std" + "strconv" + "time" + + "gno.land/r/leon/hof" +) + +const ( + gnomeArt1 = ` /\ + / \ + ,,,,, +(o.o) +(\_/) +-"-"-` + + gnomeArt2 = ` /\ + / \ + ,,,,, +(^.^) +(\_/) + -"-` + + gnomeArt3 = ` /\ + / \ + ,,,,, +(*.*) +(\_/) +"-"-"` + + gnomeArt4 = ` /\ + / \ + ,,,,, +(o.~) +(\_/) + -"-` +) + +var creation time.Time + +func init() { + creation = time.Now() + hof.Register() +} + +func Render(path string) string { + height := std.GetHeight() + + output := "# " + config.Title + "\n\n" + + output += "## About Me\n" + output += "- 👋 Hi, I'm JJOptimist\n" + output += getGnomeArt(height) + output += "- 🌱 " + config.Description + "\n" + + output += "## Contact\n" + output += "- 📫 GitHub: [" + config.Github + "](https://github.com/" + config.Github + ")\n" + + output += "\n---\n" + output += "_Realm created: " + creation.Format("2006-01-02 15:04:05 UTC") + "_\n" + output += "_Owner: " + Ownable.Owner().String() + "_\n" + output += "_Current Block Height: " + strconv.Itoa(int(height)) + "_" + + return output +} + +func getGnomeArt(height int64) string { + var art string + switch { + case height%7 == 0: + art = gnomeArt4 // winking gnome + case height%5 == 0: + art = gnomeArt3 // starry-eyed gnome + case height%3 == 0: + art = gnomeArt2 // happy gnome + default: + art = gnomeArt1 // regular gnome + } + return "```\n" + art + "\n```\n" +} diff --git a/examples/gno.land/r/jjoptimist/home/home_test.gno b/examples/gno.land/r/jjoptimist/home/home_test.gno new file mode 100644 index 00000000000..742204cca71 --- /dev/null +++ b/examples/gno.land/r/jjoptimist/home/home_test.gno @@ -0,0 +1,60 @@ +package home + +import ( + "strings" + "testing" +) + +func TestConfig(t *testing.T) { + cfg := GetConfig() + + if cfg.Title != "JJOptimist's Home Realm 🏠" { + t.Errorf("Expected title to be 'JJOptimist's Home Realm 🏠', got %s", cfg.Title) + } + if cfg.Description != "Exploring Gno and building on-chain" { + t.Errorf("Expected description to be 'Exploring Gno and building on-chain', got %s", cfg.Description) + } + if cfg.Github != "jjoptimist" { + t.Errorf("Expected github to be 'jjoptimist', got %s", cfg.Github) + } +} + +func TestRender(t *testing.T) { + output := Render("") + + // Test that required sections are present + if !strings.Contains(output, "# "+config.Title) { + t.Error("Rendered output missing title") + } + if !strings.Contains(output, "## About Me") { + t.Error("Rendered output missing About Me section") + } + if !strings.Contains(output, "## Contact") { + t.Error("Rendered output missing Contact section") + } + if !strings.Contains(output, config.Description) { + t.Error("Rendered output missing description") + } + if !strings.Contains(output, config.Github) { + t.Error("Rendered output missing github link") + } +} + +func TestGetGnomeArt(t *testing.T) { + tests := []struct { + height int64 + expected string + }{ + {7, gnomeArt4}, // height divisible by 7 + {5, gnomeArt3}, // height divisible by 5 + {3, gnomeArt2}, // height divisible by 3 + {2, gnomeArt1}, // default case + } + + for _, tt := range tests { + art := getGnomeArt(tt.height) + if !strings.Contains(art, tt.expected) { + t.Errorf("For height %d, expected art containing %s, got %s", tt.height, tt.expected, art) + } + } +} diff --git a/examples/no_cycles_test.go b/examples/no_cycles_test.go index 7cc6fbfd183..b71c9d9a787 100644 --- a/examples/no_cycles_test.go +++ b/examples/no_cycles_test.go @@ -1,214 +1,214 @@ -package examples_test - -import ( - "fmt" - "io/fs" - "os" - pathlib "path" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/gnolang/gno/gnovm" - "github.com/gnolang/gno/gnovm/pkg/gnoenv" - "github.com/gnolang/gno/gnovm/pkg/gnomod" - "github.com/gnolang/gno/gnovm/pkg/packages" - "github.com/stretchr/testify/require" -) - -// XXX: move this into `gno lint` - -var injectedTestingLibs = []string{"encoding/json", "fmt", "os", "internal/os_test"} - -// TestNoCycles checks that there is no import cycles in stdlibs and non-draft examples -func TestNoCycles(t *testing.T) { - // find stdlibs - gnoRoot := gnoenv.RootDir() - pkgs, err := listPkgs(gnomod.Pkg{ - Dir: filepath.Join(gnoRoot, "gnovm", "stdlibs"), - Name: "", - }) - require.NoError(t, err) - - // find examples - examples, err := gnomod.ListPkgs(filepath.Join(gnoRoot, "examples")) - require.NoError(t, err) - for _, example := range examples { - if example.Draft { - continue - } - examplePkgs, err := listPkgs(example) - require.NoError(t, err) - pkgs = append(pkgs, examplePkgs...) - } - - // detect cycles - visited := make(map[string]bool) - for _, p := range pkgs { - require.NoError(t, detectCycles(p, pkgs, visited)) - } -} - -// detectCycles detects import cycles -// -// We need to check -// 3 kinds of nodes -// -// - normal pkg: compiled source -// -// - xtest pkg: external test source (include xtests and filetests), can be treated as their own package -// -// - test pkg: embedded test sources, -// these should not have their corresponding normal package in their dependencies tree -// -// The tricky thing is that we need to split test sources and normal source -// while not considering them as distincitive packages. -// Otherwise we will have false positive for example if we have these edges: -// -// - foo_pkg/foo_test.go imports bar_pkg -// -// - bar_pkg/bar_test.go import foo_pkg -// -// In go, the above example is allowed -// but the following is not -// -// - foo_pkg/foo.go imports bar_pkg -// -// - bar_pkg/bar_test.go imports foo_pkg -func detectCycles(root testPkg, pkgs []testPkg, visited map[string]bool) error { - // check cycles in package's sources - stack := []string{} - if err := visitPackage(root, pkgs, visited, stack); err != nil { - return fmt.Errorf("pkgsrc import: %w", err) - } - // check cycles in external tests' dependencies we might have missed - if err := visitImports([]packages.FileKind{packages.FileKindXTest, packages.FileKindFiletest}, root, pkgs, visited, stack); err != nil { - return fmt.Errorf("xtest import: %w", err) - } - - // check cycles in tests' imports by marking the current package as visited while visiting the tests' imports - // we also consider PackageSource imports here because tests can call package code - visited = map[string]bool{root.PkgPath: true} - stack = []string{root.PkgPath} - if err := visitImports([]packages.FileKind{packages.FileKindPackageSource, packages.FileKindTest}, root, pkgs, visited, stack); err != nil { - return fmt.Errorf("test import: %w", err) - } - - return nil -} - -// visitImports resolves and visits imports by kinds -func visitImports(kinds []packages.FileKind, root testPkg, pkgs []testPkg, visited map[string]bool, stack []string) error { - for _, imp := range root.Imports.Merge(kinds...) { - if slices.Contains(injectedTestingLibs, imp.PkgPath) { - continue - } - idx := slices.IndexFunc(pkgs, func(p testPkg) bool { return p.PkgPath == imp.PkgPath }) - if idx == -1 { - return fmt.Errorf("import %q not found for %q tests", imp.PkgPath, root.PkgPath) - } - if err := visitPackage(pkgs[idx], pkgs, visited, stack); err != nil { - return fmt.Errorf("test import error: %w", err) - } - } - - return nil -} - -// visitNode visits a package and its imports recursively. It only considers imports in PackageSource -func visitPackage(pkg testPkg, pkgs []testPkg, visited map[string]bool, stack []string) error { - if slices.Contains(stack, pkg.PkgPath) { - return fmt.Errorf("cycle detected: %s -> %s", strings.Join(stack, " -> "), pkg.PkgPath) - } - if visited[pkg.PkgPath] { - return nil - } - - visited[pkg.PkgPath] = true - stack = append(stack, pkg.PkgPath) - - if err := visitImports([]packages.FileKind{packages.FileKindPackageSource}, pkg, pkgs, visited, stack); err != nil { - return err - } - - return nil -} - -type testPkg struct { - Dir string - PkgPath string - Imports packages.ImportsMap -} - -// listPkgs lists all packages in rootMod -func listPkgs(rootMod gnomod.Pkg) ([]testPkg, error) { - res := []testPkg{} - rootDir := rootMod.Dir - visited := map[string]struct{}{} - if err := fs.WalkDir(os.DirFS(rootDir), ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - if !strings.HasSuffix(d.Name(), ".gno") { - return nil - } - subPath := filepath.Dir(path) - dir := filepath.Join(rootDir, subPath) - if _, ok := visited[dir]; ok { - return nil - } - visited[dir] = struct{}{} - - subPkgPath := pathlib.Join(rootMod.Name, subPath) - - pkg := testPkg{ - Dir: dir, - PkgPath: subPkgPath, - } - - memPkg, err := readPkg(pkg.Dir, pkg.PkgPath) - if err != nil { - return fmt.Errorf("read pkg %q: %w", pkg.Dir, err) - } - pkg.Imports, err = packages.Imports(memPkg, nil) - if err != nil { - return fmt.Errorf("list imports of %q: %w", memPkg.Path, err) - } - - res = append(res, pkg) - return nil - }); err != nil { - return nil, fmt.Errorf("walk dirs at %q: %w", rootDir, err) - } - return res, nil -} - -// readPkg reads the sources of a package. It includes all .gno files but ignores the package name -func readPkg(dir string, pkgPath string) (*gnovm.MemPackage, error) { - list, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - memPkg := &gnovm.MemPackage{Path: pkgPath} - for _, entry := range list { - fpath := filepath.Join(dir, entry.Name()) - if !strings.HasSuffix(fpath, ".gno") { - continue - } - fname := filepath.Base(fpath) - bz, err := os.ReadFile(fpath) - if err != nil { - return nil, err - } - memPkg.Files = append(memPkg.Files, - &gnovm.MemFile{ - Name: fname, - Body: string(bz), - }) - } - return memPkg, nil -} +package examples_test + +import ( + "fmt" + "io/fs" + "os" + pathlib "path" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/gnolang/gno/gnovm" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + "github.com/gnolang/gno/gnovm/pkg/gnomod" + "github.com/gnolang/gno/gnovm/pkg/packages" + "github.com/stretchr/testify/require" +) + +// XXX: move this into `gno lint` + +var injectedTestingLibs = []string{"encoding/json", "fmt", "os", "internal/os_test"} + +// TestNoCycles checks that there is no import cycles in stdlibs and non-draft examples +func TestNoCycles(t *testing.T) { + // find stdlibs + gnoRoot := gnoenv.RootDir() + pkgs, err := listPkgs(gnomod.Pkg{ + Dir: filepath.Join(gnoRoot, "gnovm", "stdlibs"), + Name: "", + }) + require.NoError(t, err) + + // find examples + examples, err := gnomod.ListPkgs(filepath.Join(gnoRoot, "examples")) + require.NoError(t, err) + for _, example := range examples { + if example.Draft { + continue + } + examplePkgs, err := listPkgs(example) + require.NoError(t, err) + pkgs = append(pkgs, examplePkgs...) + } + + // detect cycles + visited := make(map[string]bool) + for _, p := range pkgs { + require.NoError(t, detectCycles(p, pkgs, visited)) + } +} + +// detectCycles detects import cycles +// +// We need to check +// 3 kinds of nodes +// +// - normal pkg: compiled source +// +// - xtest pkg: external test source (include xtests and filetests), can be treated as their own package +// +// - test pkg: embedded test sources, +// these should not have their corresponding normal package in their dependencies tree +// +// The tricky thing is that we need to split test sources and normal source +// while not considering them as distincitive packages. +// Otherwise we will have false positive for example if we have these edges: +// +// - foo_pkg/foo_test.go imports bar_pkg +// +// - bar_pkg/bar_test.go import foo_pkg +// +// In go, the above example is allowed +// but the following is not +// +// - foo_pkg/foo.go imports bar_pkg +// +// - bar_pkg/bar_test.go imports foo_pkg +func detectCycles(root testPkg, pkgs []testPkg, visited map[string]bool) error { + // check cycles in package's sources + stack := []string{} + if err := visitPackage(root, pkgs, visited, stack); err != nil { + return fmt.Errorf("pkgsrc import: %w", err) + } + // check cycles in external tests' dependencies we might have missed + if err := visitImports([]packages.FileKind{packages.FileKindXTest, packages.FileKindFiletest}, root, pkgs, visited, stack); err != nil { + return fmt.Errorf("xtest import: %w", err) + } + + // check cycles in tests' imports by marking the current package as visited while visiting the tests' imports + // we also consider PackageSource imports here because tests can call package code + visited = map[string]bool{root.PkgPath: true} + stack = []string{root.PkgPath} + if err := visitImports([]packages.FileKind{packages.FileKindPackageSource, packages.FileKindTest}, root, pkgs, visited, stack); err != nil { + return fmt.Errorf("test import: %w", err) + } + + return nil +} + +// visitImports resolves and visits imports by kinds +func visitImports(kinds []packages.FileKind, root testPkg, pkgs []testPkg, visited map[string]bool, stack []string) error { + for _, imp := range root.Imports.Merge(kinds...) { + if slices.Contains(injectedTestingLibs, imp.PkgPath) { + continue + } + idx := slices.IndexFunc(pkgs, func(p testPkg) bool { return p.PkgPath == imp.PkgPath }) + if idx == -1 { + return fmt.Errorf("import %q not found for %q tests", imp.PkgPath, root.PkgPath) + } + if err := visitPackage(pkgs[idx], pkgs, visited, stack); err != nil { + return fmt.Errorf("test import error: %w", err) + } + } + + return nil +} + +// visitNode visits a package and its imports recursively. It only considers imports in PackageSource +func visitPackage(pkg testPkg, pkgs []testPkg, visited map[string]bool, stack []string) error { + if slices.Contains(stack, pkg.PkgPath) { + return fmt.Errorf("cycle detected: %s -> %s", strings.Join(stack, " -> "), pkg.PkgPath) + } + if visited[pkg.PkgPath] { + return nil + } + + visited[pkg.PkgPath] = true + stack = append(stack, pkg.PkgPath) + + if err := visitImports([]packages.FileKind{packages.FileKindPackageSource}, pkg, pkgs, visited, stack); err != nil { + return err + } + + return nil +} + +type testPkg struct { + Dir string + PkgPath string + Imports packages.ImportsMap +} + +// listPkgs lists all packages in rootMod +func listPkgs(rootMod gnomod.Pkg) ([]testPkg, error) { + res := []testPkg{} + rootDir := rootMod.Dir + visited := map[string]struct{}{} + if err := fs.WalkDir(os.DirFS(rootDir), ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(d.Name(), ".gno") { + return nil + } + subPath := filepath.Dir(path) + dir := filepath.Join(rootDir, subPath) + if _, ok := visited[dir]; ok { + return nil + } + visited[dir] = struct{}{} + + subPkgPath := pathlib.Join(rootMod.Name, subPath) + + pkg := testPkg{ + Dir: dir, + PkgPath: subPkgPath, + } + + memPkg, err := readPkg(pkg.Dir, pkg.PkgPath) + if err != nil { + return fmt.Errorf("read pkg %q: %w", pkg.Dir, err) + } + pkg.Imports, err = packages.Imports(memPkg, nil) + if err != nil { + return fmt.Errorf("list imports of %q: %w", memPkg.Path, err) + } + + res = append(res, pkg) + return nil + }); err != nil { + return nil, fmt.Errorf("walk dirs at %q: %w", rootDir, err) + } + return res, nil +} + +// readPkg reads the sources of a package. It includes all .gno files but ignores the package name +func readPkg(dir string, pkgPath string) (*gnovm.MemPackage, error) { + list, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + memPkg := &gnovm.MemPackage{Path: pkgPath} + for _, entry := range list { + fpath := filepath.Join(dir, entry.Name()) + if !strings.HasSuffix(fpath, ".gno") { + continue + } + fname := filepath.Base(fpath) + bz, err := os.ReadFile(fpath) + if err != nil { + return nil, err + } + memPkg.Files = append(memPkg.Files, + &gnovm.MemFile{ + Name: fname, + Body: string(bz), + }) + } + return memPkg, nil +}