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  }