github.com/gnolang/gno@v0.0.0-20240520182011-228e9d0192ce/gnovm/cmd/gno/lint.go (about) 1 package main 2 3 import ( 4 "context" 5 "errors" 6 "flag" 7 "fmt" 8 "os" 9 "path/filepath" 10 "regexp" 11 "strings" 12 13 "github.com/gnolang/gno/gnovm/pkg/gnoenv" 14 gno "github.com/gnolang/gno/gnovm/pkg/gnolang" 15 "github.com/gnolang/gno/gnovm/tests" 16 "github.com/gnolang/gno/tm2/pkg/commands" 17 osm "github.com/gnolang/gno/tm2/pkg/os" 18 ) 19 20 type lintCfg struct { 21 verbose bool 22 rootDir string 23 setExitStatus int 24 // min_confidence: minimum confidence of a problem to print it (default 0.8) 25 // auto-fix: apply suggested fixes automatically. 26 } 27 28 func newLintCmd(io commands.IO) *commands.Command { 29 cfg := &lintCfg{} 30 31 return commands.NewCommand( 32 commands.Metadata{ 33 Name: "lint", 34 ShortUsage: "lint [flags] <package> [<package>...]", 35 ShortHelp: "runs the linter for the specified packages", 36 }, 37 cfg, 38 func(_ context.Context, args []string) error { 39 return execLint(cfg, args, io) 40 }, 41 ) 42 } 43 44 func (c *lintCfg) RegisterFlags(fs *flag.FlagSet) { 45 rootdir := gnoenv.RootDir() 46 47 fs.BoolVar(&c.verbose, "v", false, "verbose output when lintning") 48 fs.StringVar(&c.rootDir, "root-dir", rootdir, "clone location of github.com/gnolang/gno (gno tries to guess it)") 49 fs.IntVar(&c.setExitStatus, "set-exit-status", 1, "set exit status to 1 if any issues are found") 50 } 51 52 func execLint(cfg *lintCfg, args []string, io commands.IO) error { 53 if len(args) < 1 { 54 return flag.ErrHelp 55 } 56 57 var ( 58 verbose = cfg.verbose 59 rootDir = cfg.rootDir 60 ) 61 if rootDir == "" { 62 rootDir = gnoenv.RootDir() 63 } 64 65 pkgPaths, err := gnoPackagesFromArgs(args) 66 if err != nil { 67 return fmt.Errorf("list packages from args: %w", err) 68 } 69 70 hasError := false 71 addIssue := func(issue lintIssue) { 72 hasError = true 73 fmt.Fprint(io.Err(), issue.String()+"\n") 74 } 75 76 for _, pkgPath := range pkgPaths { 77 if verbose { 78 fmt.Fprintf(io.Err(), "Linting %q...\n", pkgPath) 79 } 80 81 // Check if 'gno.mod' exists 82 gnoModPath := filepath.Join(pkgPath, "gno.mod") 83 if !osm.FileExists(gnoModPath) { 84 addIssue(lintIssue{ 85 Code: lintNoGnoMod, 86 Confidence: 1, 87 Location: pkgPath, 88 Msg: "missing 'gno.mod' file", 89 }) 90 } 91 92 // Handle runtime errors 93 catchRuntimeError(pkgPath, addIssue, func() { 94 stdout, stdin, stderr := io.Out(), io.In(), io.Err() 95 testStore := tests.TestStore( 96 rootDir, "", 97 stdin, stdout, stderr, 98 tests.ImportModeStdlibsOnly, 99 ) 100 101 targetPath := pkgPath 102 info, err := os.Stat(pkgPath) 103 if err == nil && !info.IsDir() { 104 targetPath = filepath.Dir(pkgPath) 105 } 106 107 memPkg := gno.ReadMemPackage(targetPath, targetPath) 108 tm := tests.TestMachine(testStore, stdout, memPkg.Name) 109 110 // Check package 111 tm.RunMemPackage(memPkg, true) 112 113 // Check test files 114 testfiles := &gno.FileSet{} 115 for _, mfile := range memPkg.Files { 116 if !strings.HasSuffix(mfile.Name, ".gno") { 117 continue // Skip non-GNO files 118 } 119 120 n, _ := gno.ParseFile(mfile.Name, mfile.Body) 121 if n == nil { 122 continue // Skip empty files 123 } 124 125 // XXX: package ending with `_test` is not supported yet 126 if strings.HasSuffix(mfile.Name, "_test.gno") && !strings.HasSuffix(string(n.PkgName), "_test") { 127 // Keep only test files 128 testfiles.AddFiles(n) 129 } 130 } 131 132 tm.RunFiles(testfiles.Files...) 133 }) 134 135 // TODO: Add more checkers 136 } 137 138 if hasError && cfg.setExitStatus != 0 { 139 os.Exit(cfg.setExitStatus) 140 } 141 142 return nil 143 } 144 145 func guessSourcePath(pkg, source string) string { 146 if info, err := os.Stat(pkg); !os.IsNotExist(err) && !info.IsDir() { 147 pkg = filepath.Dir(pkg) 148 } 149 150 sourceJoin := filepath.Join(pkg, source) 151 if _, err := os.Stat(sourceJoin); !os.IsNotExist(err) { 152 return filepath.Clean(sourceJoin) 153 } 154 155 if _, err := os.Stat(source); !os.IsNotExist(err) { 156 return filepath.Clean(source) 157 } 158 159 return filepath.Clean(pkg) 160 } 161 162 // reParseRecover is a regex designed to parse error details from a string. 163 // It extracts the file location, line number, and error message from a formatted error string. 164 // XXX: Ideally, error handling should encapsulate location details within a dedicated error type. 165 var reParseRecover = regexp.MustCompile(`^([^:]+):(\d+)(?::\d+)?:? *(.*)$`) 166 167 func catchRuntimeError(pkgPath string, addIssue func(issue lintIssue), action func()) { 168 defer func() { 169 // Errors catched here mostly come from: gnovm/pkg/gnolang/preprocess.go 170 r := recover() 171 if r == nil { 172 return 173 } 174 175 var err error 176 switch verr := r.(type) { 177 case *gno.PreprocessError: 178 err = verr.Unwrap() 179 case error: 180 err = verr 181 case string: 182 err = errors.New(verr) 183 default: 184 panic(r) 185 } 186 187 var issue lintIssue 188 issue.Confidence = 1 189 issue.Code = lintGnoError 190 191 parsedError := strings.TrimSpace(err.Error()) 192 parsedError = strings.TrimPrefix(parsedError, pkgPath+"/") 193 194 matches := reParseRecover.FindStringSubmatch(parsedError) 195 if len(matches) == 4 { 196 sourcepath := guessSourcePath(pkgPath, matches[1]) 197 issue.Location = fmt.Sprintf("%s:%s", sourcepath, matches[2]) 198 issue.Msg = strings.TrimSpace(matches[3]) 199 } else { 200 issue.Location = fmt.Sprintf("%s:0", filepath.Clean(pkgPath)) 201 issue.Msg = err.Error() 202 } 203 204 addIssue(issue) 205 }() 206 207 action() 208 } 209 210 type lintCode int 211 212 const ( 213 lintUnknown lintCode = 0 214 lintNoGnoMod lintCode = iota 215 lintGnoError 216 217 // TODO: add new linter codes here. 218 ) 219 220 type lintIssue struct { 221 Code lintCode 222 Msg string 223 Confidence float64 // 1 is 100% 224 Location string // file:line, or equivalent 225 // TODO: consider writing fix suggestions 226 } 227 228 func (i lintIssue) String() string { 229 // TODO: consider crafting a doc URL based on Code. 230 return fmt.Sprintf("%s: %s (code=%d).", i.Location, i.Msg, i.Code) 231 }