diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1431bb1c1c6..a3f34b8a85e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,18 @@ To use *gofumpt* instead of *gofmt*, as hinted in the comment, you may either ha cexpr system('go run -modfile /misc/devdeps/go.mod mvdan.cc/gofumpt -w ' . expand('%')) ``` +##### ViM Linting Support + +To integrate GNO linting in Vim, you can use Vim's `:make` command with a custom `makeprg` and `errorformat` to run the GNO linter and parse its output. Add the following configuration to your `.vimrc` file: + +```vim +autocmd FileType gno setlocal makeprg=gno\ lint\ % +autocmd FileType gno setlocal errorformat=%f:%l:\ %m + +" Optional: Key binding to run :make on the current file +autocmd FileType gno nnoremap :make +``` + ### ViM Support (with LSP) There is an experimental and unofficial [Gno Language Server](https://github.com/jdkato/gnols) @@ -172,7 +184,31 @@ Additionally, it's not possible to use `gofumpt` for code formatting with 2. Add to your emacs configuration file: ```lisp -(add-to-list 'auto-mode-alist '("\\.gno\\'" . go-mode)) +(define-derived-mode gno-mode go-mode "GNO" + "Major mode for GNO files, an alias for go-mode." + (setq-local tab-width 8)) +(define-derived-mode gno-dot-mod-mode go-dot-mod-mode "GNO Mod" + "Major mode for GNO mod files, an alias for go-dot-mod-mode." + ) +``` + +3. To integrate GNO linting with Flycheck, add the following to your Emacs configuration: +```lisp +(require 'flycheck) + +(flycheck-define-checker gno-lint + "A GNO syntax checker using the gno lint tool." + :command ("gno" "lint" source-original) + :error-patterns (;; ./file.gno:32: error message (code=1) + (error line-start (file-name) ":" line ": " (message) " (code=" (id (one-or-more digit)) ")." line-end)) + ;; Ensure the file is saved, to work around + ;; https://github.com/python/mypy/issues/4746. + :predicate (lambda () + (and (not (bound-and-true-p polymode-mode)) + (flycheck-buffer-saved-p))) + :modes gno-mode) + +(add-to-list 'flycheck-checkers 'gno-lint) ``` #### Sublime Text diff --git a/gnovm/cmd/gno/lint.go b/gnovm/cmd/gno/lint.go index a90f432b7c9..68a5808b309 100644 --- a/gnovm/cmd/gno/lint.go +++ b/gnovm/cmd/gno/lint.go @@ -2,12 +2,17 @@ package main import ( "context" + "errors" "flag" "fmt" "os" "path/filepath" + "regexp" + "strings" "github.com/gnolang/gno/gnovm/pkg/gnoenv" + gno "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/gnolang/gno/gnovm/tests" "github.com/gnolang/gno/tm2/pkg/commands" osm "github.com/gnolang/gno/tm2/pkg/os" ) @@ -37,8 +42,10 @@ func newLintCmd(io commands.IO) *commands.Command { } func (c *lintCfg) RegisterFlags(fs *flag.FlagSet) { + rootdir := gnoenv.RootDir() + fs.BoolVar(&c.verbose, "verbose", false, "verbose output when lintning") - fs.StringVar(&c.rootDir, "root-dir", "", "clone location of github.com/gnolang/gno (gno tries to guess it)") + fs.StringVar(&c.rootDir, "root-dir", rootdir, "clone location of github.com/gnolang/gno (gno tries to guess it)") fs.IntVar(&c.setExitStatus, "set-exit-status", 1, "set exit status to 1 if any issues are found") } @@ -71,7 +78,7 @@ func execLint(cfg *lintCfg, args []string, io commands.IO) error { fmt.Fprintf(io.Err(), "Linting %q...\n", pkgPath) } - // 'gno.mod' exists? + // Check if 'gno.mod' exists gnoModPath := filepath.Join(pkgPath, "gno.mod") if !osm.FileExists(gnoModPath) { addIssue(lintIssue{ @@ -82,20 +89,131 @@ func execLint(cfg *lintCfg, args []string, io commands.IO) error { }) } - // TODO: add more checkers + // Handle runtime errors + catchRuntimeError(pkgPath, addIssue, func() { + stdout, stdin, stderr := io.Out(), io.In(), io.Err() + testStore := tests.TestStore( + rootDir, "", + stdin, stdout, stderr, + tests.ImportModeStdlibsOnly, + ) + + targetPath := pkgPath + info, err := os.Stat(pkgPath) + if err == nil && !info.IsDir() { + targetPath = filepath.Dir(pkgPath) + } + + memPkg := gno.ReadMemPackage(targetPath, targetPath) + tm := tests.TestMachine(testStore, stdout, memPkg.Name) + + // Check package + tm.RunMemPackage(memPkg, true) + + // Check test files + testfiles := &gno.FileSet{} + for _, mfile := range memPkg.Files { + if !strings.HasSuffix(mfile.Name, ".gno") { + continue // Skip non-GNO files + } + + n, _ := gno.ParseFile(mfile.Name, mfile.Body) + if n == nil { + continue // Skip empty files + } + + // XXX: package ending with `_test` is not supported yet + if strings.HasSuffix(mfile.Name, "_test.gno") && !strings.HasSuffix(string(n.PkgName), "_test") { + // Keep only test files + testfiles.AddFiles(n) + } + } + + tm.RunFiles(testfiles.Files...) + }) + + // TODO: Add more checkers } if hasError && cfg.setExitStatus != 0 { os.Exit(cfg.setExitStatus) } + return nil } +func guessSourcePath(pkg, source string) string { + if info, err := os.Stat(pkg); !os.IsNotExist(err) && !info.IsDir() { + pkg = filepath.Dir(pkg) + } + + sourceJoin := filepath.Join(pkg, source) + if _, err := os.Stat(sourceJoin); !os.IsNotExist(err) { + return filepath.Clean(sourceJoin) + } + + if _, err := os.Stat(source); !os.IsNotExist(err) { + return filepath.Clean(source) + } + + return filepath.Clean(pkg) +} + +// reParseRecover is a regex designed to parse error details from a string. +// It extracts the file location, line number, and error message from a formatted error string. +// XXX: Ideally, error handling should encapsulate location details within a dedicated error type. +var reParseRecover = regexp.MustCompile(`^([^:]+):(\d+)(?::\d+)?:? *(.*)$`) + +func catchRuntimeError(pkgPath string, addIssue func(issue lintIssue), action func()) { + defer func() { + // Errors catched here mostly come from: gnovm/pkg/gnolang/preprocess.go + r := recover() + if r == nil { + return + } + + var err error + switch verr := r.(type) { + case *gno.PreprocessError: + err = verr.Unwrap() + case error: + err = verr + case string: + err = errors.New(verr) + default: + panic(r) + } + + var issue lintIssue + issue.Confidence = 1 + issue.Code = lintGnoError + + parsedError := strings.TrimSpace(err.Error()) + parsedError = strings.TrimPrefix(parsedError, pkgPath+"/") + + matches := reParseRecover.FindStringSubmatch(parsedError) + if len(matches) == 4 { + sourcepath := guessSourcePath(pkgPath, matches[1]) + issue.Location = fmt.Sprintf("%s:%s", sourcepath, matches[2]) + issue.Msg = strings.TrimSpace(matches[3]) + } else { + issue.Location = fmt.Sprintf("%s:0", filepath.Clean(pkgPath)) + issue.Msg = err.Error() + } + + addIssue(issue) + }() + + action() +} + type lintCode int const ( lintUnknown lintCode = 0 lintNoGnoMod lintCode = iota + lintGnoError + // TODO: add new linter codes here. ) diff --git a/gnovm/cmd/gno/lint_test.go b/gnovm/cmd/gno/lint_test.go index 0a747a03778..d700467965d 100644 --- a/gnovm/cmd/gno/lint_test.go +++ b/gnovm/cmd/gno/lint_test.go @@ -10,6 +10,12 @@ func TestLintApp(t *testing.T) { }, { args: []string{"lint", "--set-exit-status=0", "../../tests/integ/run-main/"}, stderrShouldContain: "./../../tests/integ/run-main: missing 'gno.mod' file (code=1).", + }, { + args: []string{"lint", "--set-exit-status=0", "../../tests/integ/undefined-variable-test/undefined_variables_test.gno"}, + stderrShouldContain: "undefined_variables_test.gno:6: name toto not declared (code=2)", + }, { + args: []string{"lint", "--set-exit-status=0", "../../tests/integ/package-not-declared/main.gno"}, + stderrShouldContain: "main.gno:4: name fmt not declared (code=2).", }, { args: []string{"lint", "--set-exit-status=0", "../../tests/integ/run-main/"}, stderrShouldContain: "./../../tests/integ/run-main: missing 'gno.mod' file (code=1).", @@ -20,6 +26,7 @@ func TestLintApp(t *testing.T) { args: []string{"lint", "--set-exit-status=0", "../../tests/integ/invalid-module-name/"}, // TODO: raise an error because gno.mod is invalid }, + // TODO: 'gno mod' is valid? // TODO: is gno source valid? // TODO: are dependencies valid? diff --git a/gnovm/cmd/gno/run_test.go b/gnovm/cmd/gno/run_test.go index e439a6bad8d..575798a78dc 100644 --- a/gnovm/cmd/gno/run_test.go +++ b/gnovm/cmd/gno/run_test.go @@ -63,6 +63,10 @@ func TestRunApp(t *testing.T) { args: []string{"run", "-expr", "WithArg(-255)", "../../tests/integ/run-package"}, stdoutShouldContain: "out of range!", }, + { + args: []string{"run", "../../tests/integ/undefined-variable-test/undefined_variables_test.gno"}, + recoverShouldContain: "--- preprocess stack ---", // should contain preprocess debug stack trace + }, // TODO: a test file // TODO: args // TODO: nativeLibs VS stdlibs diff --git a/gnovm/cmd/gno/test_test.go b/gnovm/cmd/gno/test_test.go index b1dcfb21d29..98320f41cc9 100644 --- a/gnovm/cmd/gno/test_test.go +++ b/gnovm/cmd/gno/test_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "strconv" "testing" "github.com/gnolang/gno/gnovm/pkg/integration" @@ -9,8 +11,10 @@ import ( ) func Test_ScriptsTest(t *testing.T) { + updateScripts, _ := strconv.ParseBool(os.Getenv("UPDATE_SCRIPTS")) p := testscript.Params{ - Dir: "testdata/gno_test", + UpdateScripts: updateScripts, + Dir: "testdata/gno_test", } if coverdir, ok := integration.ResolveCoverageDir(); ok { diff --git a/gnovm/cmd/gno/testdata/gno_test/lint_bad_import.txtar b/gnovm/cmd/gno/testdata/gno_test/lint_bad_import.txtar new file mode 100644 index 00000000000..946e1bcba35 --- /dev/null +++ b/gnovm/cmd/gno/testdata/gno_test/lint_bad_import.txtar @@ -0,0 +1,19 @@ +# testing gno lint command: bad import error + +! gno lint ./bad_file.gno + +cmp stdout stdout.golden +cmp stderr stderr.golden + +-- bad_file.gno -- +package main + +import "python" + +func main() { + fmt.Println("Hello", 42) +} + +-- stdout.golden -- +-- stderr.golden -- +bad_file.gno:1: unknown import path python (code=2). diff --git a/gnovm/cmd/gno/testdata/gno_test/lint_file_error.txtar b/gnovm/cmd/gno/testdata/gno_test/lint_file_error.txtar new file mode 100644 index 00000000000..9482eeb1f4f --- /dev/null +++ b/gnovm/cmd/gno/testdata/gno_test/lint_file_error.txtar @@ -0,0 +1,20 @@ +# gno lint: test file error + +! gno lint ./i_have_error_test.gno + +cmp stdout stdout.golden +cmp stderr stderr.golden + +-- i_have_error_test.gno -- +package main + +import "fmt" + +func TestIHaveSomeError() { + i := undefined_variable + fmt.Println("Hello", 42) +} + +-- stdout.golden -- +-- stderr.golden -- +i_have_error_test.gno:6: name undefined_variable not declared (code=2). diff --git a/gnovm/cmd/gno/testdata/gno_test/lint_file_error_txtar b/gnovm/cmd/gno/testdata/gno_test/lint_file_error_txtar new file mode 100644 index 00000000000..9482eeb1f4f --- /dev/null +++ b/gnovm/cmd/gno/testdata/gno_test/lint_file_error_txtar @@ -0,0 +1,20 @@ +# gno lint: test file error + +! gno lint ./i_have_error_test.gno + +cmp stdout stdout.golden +cmp stderr stderr.golden + +-- i_have_error_test.gno -- +package main + +import "fmt" + +func TestIHaveSomeError() { + i := undefined_variable + fmt.Println("Hello", 42) +} + +-- stdout.golden -- +-- stderr.golden -- +i_have_error_test.gno:6: name undefined_variable not declared (code=2). diff --git a/gnovm/cmd/gno/testdata/gno_test/lint_no_error.txtar b/gnovm/cmd/gno/testdata/gno_test/lint_no_error.txtar new file mode 100644 index 00000000000..95356b1ba2b --- /dev/null +++ b/gnovm/cmd/gno/testdata/gno_test/lint_no_error.txtar @@ -0,0 +1,18 @@ +# testing simple gno lint command with any error + +gno lint ./good_file.gno + +cmp stdout stdout.golden +cmp stdout stderr.golden + +-- good_file.gno -- +package main + +import "fmt" + +func main() { + fmt.Println("Hello", 42) +} + +-- stdout.golden -- +-- stderr.golden -- diff --git a/gnovm/cmd/gno/testdata/gno_test/lint_no_gnomod.txtar b/gnovm/cmd/gno/testdata/gno_test/lint_no_gnomod.txtar new file mode 100644 index 00000000000..52daa6f0e9b --- /dev/null +++ b/gnovm/cmd/gno/testdata/gno_test/lint_no_gnomod.txtar @@ -0,0 +1,19 @@ +# gno lint: no gnomod + +! gno lint . + +cmp stdout stdout.golden +cmp stderr stderr.golden + +-- good_file.gno -- +package main + +import "fmt" + +func main() { + fmt.Println("Hello", 42) +} + +-- stdout.golden -- +-- stderr.golden -- +./.: missing 'gno.mod' file (code=1). diff --git a/gnovm/cmd/gno/testdata/gno_test/lint_not_declared.txtar b/gnovm/cmd/gno/testdata/gno_test/lint_not_declared.txtar new file mode 100644 index 00000000000..7bd74a34855 --- /dev/null +++ b/gnovm/cmd/gno/testdata/gno_test/lint_not_declared.txtar @@ -0,0 +1,20 @@ +# testing gno lint command: not declared error + +! gno lint ./bad_file.gno + +cmp stdout stdout.golden +cmp stderr stderr.golden + +-- bad_file.gno -- +package main + +import "fmt" + +func main() { + hello.Foo() + fmt.Println("Hello", 42) +} + +-- stdout.golden -- +-- stderr.golden -- +bad_file.gno:6: name hello not declared (code=2). diff --git a/gnovm/pkg/gnolang/debug.go b/gnovm/pkg/gnolang/debug.go index cb21da12ef2..c6e39dfaa5a 100644 --- a/gnovm/pkg/gnolang/debug.go +++ b/gnovm/pkg/gnolang/debug.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "os" + "strings" "time" // Ignore pprof import, as the server does not @@ -76,6 +77,40 @@ func (d debugging) Errorf(format string, args ...interface{}) { } } +// PreprocessError wraps a processing error along with its associated +// preprocessing stack for enhanced error reporting. +type PreprocessError struct { + err error + stack []BlockNode +} + +// Unwrap returns the encapsulated error message. +func (p *PreprocessError) Unwrap() error { + return p.err +} + +// Stack produces a string representation of the preprocessing stack +// trace that was associated with the error occurrence. +func (p *PreprocessError) Stack() string { + var stacktrace strings.Builder + for i := len(p.stack) - 1; i >= 0; i-- { + sbn := p.stack[i] + fmt.Fprintf(&stacktrace, "stack %d: %s\n", i, sbn.String()) + } + return stacktrace.String() +} + +// Error consolidates and returns the full error message, including +// the actual error followed by its associated preprocessing stack. +func (p *PreprocessError) Error() string { + var err strings.Builder + fmt.Fprintf(&err, "%s:\n", p.Unwrap()) + fmt.Fprintln(&err, "--- preprocess stack ---") + fmt.Fprint(&err, p.Stack()) + fmt.Fprintf(&err, "------------------------") + return err.String() +} + // ---------------------------------------- // Exposed errors accessors // File tests may access debug errors. diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index f4a42e1e583..c86edb0e515 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -154,24 +154,27 @@ func Preprocess(store Store, ctx BlockNode, n Node) Node { defer func() { if r := recover(); r != nil { - fmt.Println("--- preprocess stack ---") - for i := len(stack) - 1; i >= 0; i-- { - sbn := stack[i] - fmt.Printf("stack %d: %s\n", i, sbn.String()) - } - fmt.Println("------------------------") // before re-throwing the error, append location information to message. loc := last.GetLocation() if nline := n.GetLine(); nline > 0 { loc.Line = nline } - if rerr, ok := r.(error); ok { + + var err error + rerr, ok := r.(error) + if ok { // NOTE: gotuna/gorilla expects error exceptions. - panic(errors.Wrap(rerr, loc.String())) + err = errors.Wrap(rerr, loc.String()) } else { // NOTE: gotuna/gorilla expects error exceptions. - panic(errors.New(fmt.Sprintf("%s: %v", loc.String(), r))) + err = errors.New(fmt.Sprintf("%s: %v", loc.String(), r)) } + + // Re-throw the error after wrapping it with the preprocessing stack information. + panic(&PreprocessError{ + err: err, + stack: stack, + }) } }() if debug { diff --git a/gnovm/pkg/repl/repl_test.go b/gnovm/pkg/repl/repl_test.go index 09c350dd49a..fbb2efe8890 100644 --- a/gnovm/pkg/repl/repl_test.go +++ b/gnovm/pkg/repl/repl_test.go @@ -69,7 +69,7 @@ var fixtures = []struct { CodeSteps: []step{ { Line: "importasdasd", - Error: "recovered from panic: test/test1.gno:7: name importasdasd not declared", + Error: "test/test1.gno:7: name importasdasd not declared", }, { Line: "var a := 1", diff --git a/gnovm/tests/file.go b/gnovm/tests/file.go index a021628a385..70bed4eda50 100644 --- a/gnovm/tests/file.go +++ b/gnovm/tests/file.go @@ -241,15 +241,20 @@ func RunFileTest(rootDir string, path string, opts ...RunFileTestOption) error { if pnc == nil { panic(fmt.Sprintf("fail on %s: got nil error, want: %q", path, errWanted)) } + errstr := "" - if tv, ok := pnc.(*gno.TypedValue); ok { - errstr = tv.Sprint(m) - } else { + switch v := pnc.(type) { + case *gno.TypedValue: + errstr = v.Sprint(m) + case *gno.PreprocessError: + errstr = v.Unwrap().Error() + default: errstr = strings.TrimSpace(fmt.Sprintf("%v", pnc)) } if errstr != errWanted { panic(fmt.Sprintf("fail on %s: got %q, want: %q", path, errstr, errWanted)) } + // NOTE: ignores any gno.GetDebugErrors(). gno.ClearDebugErrors() return nil // nothing more to do. diff --git a/gnovm/tests/integ/package-not-declared/gno.mod b/gnovm/tests/integ/package-not-declared/gno.mod new file mode 100644 index 00000000000..8b4e0375297 --- /dev/null +++ b/gnovm/tests/integ/package-not-declared/gno.mod @@ -0,0 +1 @@ +module gno.land/tests/nodeclared \ No newline at end of file diff --git a/gnovm/tests/integ/package-not-declared/main.gno b/gnovm/tests/integ/package-not-declared/main.gno new file mode 100644 index 00000000000..bdbb2e7cfcb --- /dev/null +++ b/gnovm/tests/integ/package-not-declared/main.gno @@ -0,0 +1,5 @@ +package main + +func Main() { + fmt.Println("hello world") +} diff --git a/gnovm/tests/integ/undefined-variable-test/gno.mod b/gnovm/tests/integ/undefined-variable-test/gno.mod new file mode 100644 index 00000000000..0a75f00e83f --- /dev/null +++ b/gnovm/tests/integ/undefined-variable-test/gno.mod @@ -0,0 +1 @@ +module gno.land/tests/undefined-test \ No newline at end of file diff --git a/gnovm/tests/integ/undefined-variable-test/undefined_variables_test.gno b/gnovm/tests/integ/undefined-variable-test/undefined_variables_test.gno new file mode 100644 index 00000000000..0afdec36ef3 --- /dev/null +++ b/gnovm/tests/integ/undefined-variable-test/undefined_variables_test.gno @@ -0,0 +1,7 @@ +package main + +import "testing" + +func TestUndefinedVariables(t *testing.T) { + println("hello world: " + toto) +}