github.com/gnolang/gno@v0.0.0-20240520182011-228e9d0192ce/gnovm/pkg/transpiler/transpiler.go (about)

     1  package transpiler
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"go/ast"
     7  	"go/format"
     8  	"go/parser"
     9  	goscanner "go/scanner"
    10  	"go/token"
    11  	"os"
    12  	"os/exec"
    13  	"path/filepath"
    14  	"regexp"
    15  	"sort"
    16  	"strconv"
    17  	"strings"
    18  
    19  	"golang.org/x/tools/go/ast/astutil"
    20  )
    21  
    22  const (
    23  	GnoRealmPkgsPrefixBefore = "gno.land/r/"
    24  	GnoRealmPkgsPrefixAfter  = "github.com/gnolang/gno/examples/gno.land/r/"
    25  	GnoPackagePrefixBefore   = "gno.land/p/demo/"
    26  	GnoPackagePrefixAfter    = "github.com/gnolang/gno/examples/gno.land/p/demo/"
    27  	GnoStdPkgBefore          = "std"
    28  	GnoStdPkgAfter           = "github.com/gnolang/gno/gnovm/stdlibs/stdshim"
    29  )
    30  
    31  var stdlibWhitelist = []string{
    32  	// go
    33  	"bufio",
    34  	"bytes",
    35  	"compress/gzip",
    36  	"context",
    37  	"crypto/md5",
    38  	"crypto/sha1",
    39  	"crypto/chacha20",
    40  	"crypto/cipher",
    41  	"crypto/sha256",
    42  	"encoding/base64",
    43  	"encoding/binary",
    44  	"encoding/hex",
    45  	"encoding/json",
    46  	"encoding/xml",
    47  	"errors",
    48  	"hash",
    49  	"hash/adler32",
    50  	"internal/bytealg",
    51  	"internal/os",
    52  	"flag",
    53  	"fmt",
    54  	"io",
    55  	"io/util",
    56  	"math",
    57  	"math/big",
    58  	"math/bits",
    59  	"math/rand",
    60  	"net/url",
    61  	"path",
    62  	"regexp",
    63  	"sort",
    64  	"strconv",
    65  	"strings",
    66  	"text/template",
    67  	"time",
    68  	"unicode",
    69  	"unicode/utf8",
    70  	"unicode/utf16",
    71  
    72  	// gno
    73  	"std",
    74  }
    75  
    76  var importPrefixWhitelist = []string{
    77  	"github.com/gnolang/gno/_test",
    78  }
    79  
    80  const ImportPrefix = "github.com/gnolang/gno"
    81  
    82  type transpileResult struct {
    83  	Imports    []*ast.ImportSpec
    84  	Translated string
    85  }
    86  
    87  // TODO: func TranspileFile: supports caching.
    88  // TODO: func TranspilePkg: supports directories.
    89  
    90  func guessRootDir(fileOrPkg string, goBinary string) (string, error) {
    91  	abs, err := filepath.Abs(fileOrPkg)
    92  	if err != nil {
    93  		return "", err
    94  	}
    95  	args := []string{"list", "-m", "-mod=mod", "-f", "{{.Dir}}", ImportPrefix}
    96  	cmd := exec.Command(goBinary, args...)
    97  	cmd.Dir = abs
    98  	out, err := cmd.CombinedOutput()
    99  	if err != nil {
   100  		return "", fmt.Errorf("can't guess --root-dir")
   101  	}
   102  	rootDir := strings.TrimSpace(string(out))
   103  	return rootDir, nil
   104  }
   105  
   106  // GetTranspileFilenameAndTags returns the filename and tags for transpiled files.
   107  func GetTranspileFilenameAndTags(gnoFilePath string) (targetFilename, tags string) {
   108  	nameNoExtension := strings.TrimSuffix(filepath.Base(gnoFilePath), ".gno")
   109  	switch {
   110  	case strings.HasSuffix(gnoFilePath, "_filetest.gno"):
   111  		tags = "gno && filetest"
   112  		targetFilename = "." + nameNoExtension + ".gno.gen.go"
   113  	case strings.HasSuffix(gnoFilePath, "_test.gno"):
   114  		tags = "gno && test"
   115  		targetFilename = "." + nameNoExtension + ".gno.gen_test.go"
   116  	default:
   117  		tags = "gno"
   118  		targetFilename = nameNoExtension + ".gno.gen.go"
   119  	}
   120  	return
   121  }
   122  
   123  func Transpile(source string, tags string, filename string) (*transpileResult, error) {
   124  	fset := token.NewFileSet()
   125  	f, err := parser.ParseFile(fset, filename, source, parser.ParseComments)
   126  	if err != nil {
   127  		return nil, fmt.Errorf("parse: %w", err)
   128  	}
   129  
   130  	isTestFile := strings.HasSuffix(filename, "_test.gno") || strings.HasSuffix(filename, "_filetest.gno")
   131  	shouldCheckWhitelist := !isTestFile
   132  
   133  	transformed, err := transpileAST(fset, f, shouldCheckWhitelist)
   134  	if err != nil {
   135  		return nil, fmt.Errorf("transpileAST: %w", err)
   136  	}
   137  
   138  	var out bytes.Buffer
   139  	// Write file header
   140  	out.WriteString("// Code generated by github.com/gnolang/gno. DO NOT EDIT.\n\n")
   141  	if tags != "" {
   142  		fmt.Fprintf(&out, "//go:build %s\n\n", tags)
   143  	}
   144  	// Add a //line directive so the go compiler outputs the original gno
   145  	// filename and the file's position that corresponds to it.
   146  	// See https://pkg.go.dev/cmd/compile#hdr-Compiler_Directives
   147  	fmt.Fprintf(&out, "//line %s:1:1\n", filepath.Base(filename))
   148  
   149  	// Write file content and format it.
   150  	err = format.Node(&out, fset, transformed)
   151  	if err != nil {
   152  		return nil, fmt.Errorf("format.Node: %w", err)
   153  	}
   154  
   155  	res := &transpileResult{
   156  		Imports:    f.Imports,
   157  		Translated: out.String(),
   158  	}
   159  	return res, nil
   160  }
   161  
   162  // TranspileVerifyFile tries to run `go fmt` against a transpiled .go file.
   163  //
   164  // This is fast and won't look the imports.
   165  func TranspileVerifyFile(path string, gofmtBinary string) error {
   166  	// TODO: use cmd/parser instead of exec?
   167  
   168  	args := strings.Split(gofmtBinary, " ")
   169  	args = append(args, []string{"-l", "-e", path}...)
   170  	cmd := exec.Command(args[0], args[1:]...)
   171  	out, err := cmd.CombinedOutput()
   172  	if err != nil {
   173  		fmt.Fprintln(os.Stderr, string(out))
   174  		return fmt.Errorf("%s: %w", gofmtBinary, err)
   175  	}
   176  	return nil
   177  }
   178  
   179  // TranspileBuildPackage tries to run `go build` against the transpiled .go files.
   180  //
   181  // This method is the most efficient to detect errors but requires that
   182  // all the import are valid and available.
   183  func TranspileBuildPackage(fileOrPkg, goBinary string) error {
   184  	// TODO: use cmd/compile instead of exec?
   185  	// TODO: find the nearest go.mod file, chdir in the same folder, rim prefix?
   186  	// TODO: temporarily create an in-memory go.mod or disable go modules for gno?
   187  	// TODO: ignore .go files that were not generated from gno?
   188  	// TODO: automatically transpile if not yet done.
   189  
   190  	files := []string{}
   191  
   192  	info, err := os.Stat(fileOrPkg)
   193  	if err != nil {
   194  		return fmt.Errorf("invalid file or package path %s: %w", fileOrPkg, err)
   195  	}
   196  	if !info.IsDir() {
   197  		file := fileOrPkg
   198  		files = append(files, file)
   199  	} else {
   200  		pkgDir := fileOrPkg
   201  		goGlob := filepath.Join(pkgDir, "*.go")
   202  		goMatches, err := filepath.Glob(goGlob)
   203  		if err != nil {
   204  			return fmt.Errorf("glob %s: %w", goGlob, err)
   205  		}
   206  		for _, goMatch := range goMatches {
   207  			switch {
   208  			case strings.HasPrefix(goMatch, "."): // skip
   209  			case strings.HasSuffix(goMatch, "_filetest.go"): // skip
   210  			case strings.HasSuffix(goMatch, "_filetest.gno.gen.go"): // skip
   211  			case strings.HasSuffix(goMatch, "_test.go"): // skip
   212  			case strings.HasSuffix(goMatch, "_test.gno.gen.go"): // skip
   213  			default:
   214  				files = append(files, goMatch)
   215  			}
   216  		}
   217  	}
   218  
   219  	sort.Strings(files)
   220  	args := append([]string{"build", "-v", "-tags=gno"}, files...)
   221  	cmd := exec.Command(goBinary, args...)
   222  	rootDir, err := guessRootDir(fileOrPkg, goBinary)
   223  	if err == nil {
   224  		cmd.Dir = rootDir
   225  	}
   226  	out, err := cmd.CombinedOutput()
   227  	if _, ok := err.(*exec.ExitError); ok {
   228  		// exit error
   229  		return parseGoBuildErrors(string(out))
   230  	}
   231  	return err
   232  }
   233  
   234  var errorRe = regexp.MustCompile(`(?m)^(\S+):(\d+):(\d+): (.+)$`)
   235  
   236  // parseGoBuildErrors returns a scanner.ErrorList filled with all errors found
   237  // in out, which is supposed to be the output of the `go build` command.
   238  //
   239  // TODO(tb): update when `go build -json` is released to replace regexp usage.
   240  // See https://github.com/golang/go/issues/62067
   241  func parseGoBuildErrors(out string) error {
   242  	var errList goscanner.ErrorList
   243  	matches := errorRe.FindAllStringSubmatch(out, -1)
   244  	for _, match := range matches {
   245  		filename := match[1]
   246  		line, err := strconv.Atoi(match[2])
   247  		if err != nil {
   248  			return fmt.Errorf("parse line go build error %s: %w", match, err)
   249  		}
   250  
   251  		column, err := strconv.Atoi(match[3])
   252  		if err != nil {
   253  			return fmt.Errorf("parse column go build error %s: %w", match, err)
   254  		}
   255  		msg := match[4]
   256  		errList.Add(token.Position{
   257  			Filename: filename,
   258  			Line:     line,
   259  			Column:   column,
   260  		}, msg)
   261  	}
   262  	return errList.Err()
   263  }
   264  
   265  func transpileAST(fset *token.FileSet, f *ast.File, checkWhitelist bool) (ast.Node, error) {
   266  	var errs goscanner.ErrorList
   267  
   268  	imports := astutil.Imports(fset, f)
   269  
   270  	// import whitelist
   271  	if checkWhitelist {
   272  		for _, paragraph := range imports {
   273  			for _, importSpec := range paragraph {
   274  				importPath := strings.TrimPrefix(strings.TrimSuffix(importSpec.Path.Value, `"`), `"`)
   275  
   276  				if strings.HasPrefix(importPath, GnoRealmPkgsPrefixBefore) {
   277  					continue
   278  				}
   279  
   280  				if strings.HasPrefix(importPath, GnoPackagePrefixBefore) {
   281  					continue
   282  				}
   283  
   284  				valid := false
   285  				for _, whitelisted := range stdlibWhitelist {
   286  					if importPath == whitelisted {
   287  						valid = true
   288  						break
   289  					}
   290  				}
   291  				if valid {
   292  					continue
   293  				}
   294  
   295  				for _, whitelisted := range importPrefixWhitelist {
   296  					if strings.HasPrefix(importPath, whitelisted) {
   297  						valid = true
   298  						break
   299  					}
   300  				}
   301  				if valid {
   302  					continue
   303  				}
   304  
   305  				errs.Add(fset.Position(importSpec.Pos()), fmt.Sprintf("import %q is not in the whitelist", importPath))
   306  			}
   307  		}
   308  	}
   309  
   310  	// rewrite imports
   311  	for _, paragraph := range imports {
   312  		for _, importSpec := range paragraph {
   313  			importPath := strings.TrimPrefix(strings.TrimSuffix(importSpec.Path.Value, `"`), `"`)
   314  
   315  			// std package
   316  			if importPath == GnoStdPkgBefore {
   317  				if !astutil.RewriteImport(fset, f, GnoStdPkgBefore, GnoStdPkgAfter) {
   318  					errs.Add(fset.Position(importSpec.Pos()), fmt.Sprintf("failed to replace the %q package with %q", GnoStdPkgBefore, GnoStdPkgAfter))
   319  				}
   320  			}
   321  
   322  			// p/pkg packages
   323  			if strings.HasPrefix(importPath, GnoPackagePrefixBefore) {
   324  				target := GnoPackagePrefixAfter + strings.TrimPrefix(importPath, GnoPackagePrefixBefore)
   325  
   326  				if !astutil.RewriteImport(fset, f, importPath, target) {
   327  					errs.Add(fset.Position(importSpec.Pos()), fmt.Sprintf("failed to replace the %q package with %q", importPath, target))
   328  				}
   329  			}
   330  
   331  			// r/realm packages
   332  			if strings.HasPrefix(importPath, GnoRealmPkgsPrefixBefore) {
   333  				target := GnoRealmPkgsPrefixAfter + strings.TrimPrefix(importPath, GnoRealmPkgsPrefixBefore)
   334  
   335  				if !astutil.RewriteImport(fset, f, importPath, target) {
   336  					errs.Add(fset.Position(importSpec.Pos()), fmt.Sprintf("failed to replace the %q package with %q", importPath, target))
   337  				}
   338  			}
   339  		}
   340  	}
   341  
   342  	// custom handler
   343  	node := astutil.Apply(f,
   344  		// pre
   345  		func(c *astutil.Cursor) bool {
   346  			// do things here
   347  			return true
   348  		},
   349  		// post
   350  		func(c *astutil.Cursor) bool {
   351  			// and here
   352  			return true
   353  		},
   354  	)
   355  	return node, errs.Err()
   356  }