diff --git a/gnovm/cmd/gnoffee/main.go b/gnovm/cmd/gnoffee/main.go new file mode 100644 index 00000000000..29a03a6187e --- /dev/null +++ b/gnovm/cmd/gnoffee/main.go @@ -0,0 +1,141 @@ +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "io/ioutil" + "os" + "path/filepath" + + "github.com/gnolang/gno/gnovm/pkg/gnoffee" +) + +var writeFlag bool + +func init() { + flag.BoolVar(&writeFlag, "w", false, "write result to gnoffee.gen.go file instead of stdout") +} + +func main() { + flag.Parse() + args := flag.Args() + + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "Usage: gnoffee [-w] ") + return + } + + err := doMain(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +func doMain(arg string) error { + fset, pkg, err := processPackageOrFileOrStdin(arg) + if err != nil { + return fmt.Errorf("parse error: %w", err) + } + + newFile, err := gnoffee.Stage2(pkg) + if err != nil { + return fmt.Errorf("processing the AST: %w", err) + } + + // combine existing files into newFile to generate a unique file for the whole package. + for _, file := range pkg { + newFile.Decls = append(newFile.Decls, file.Decls...) + } + + // Create a new package comment. + commentText := "// Code generated by \"gnoffee\". DO NOT EDIT." + + if writeFlag { + filename := "gnoffee.gen.go" + f, err := os.Create(filename) + if err != nil { + return fmt.Errorf("creating file %q: %w", filename, err) + } + defer f.Close() + + _, err = fmt.Fprintln(f, commentText) + if err != nil { + return fmt.Errorf("writing to file %q: %w", filename, err) + } + err = printer.Fprint(f, fset, newFile) + if err != nil { + return fmt.Errorf("writing to file %q: %w", filename, err) + } + } else { + _, _ = fmt.Println(commentText) + _ = printer.Fprint(os.Stdout, fset, newFile) + } + return nil +} + +func processPackageOrFileOrStdin(arg string) (*token.FileSet, map[string]*ast.File, error) { + fset := token.NewFileSet() + pkg := map[string]*ast.File{} + + processFile := func(data []byte, filename string) error { + source := string(data) + source = gnoffee.Stage1(source) + + parsedFile, err := parser.ParseFile(fset, filename, source, parser.ParseComments) + if err != nil { + return fmt.Errorf("parsing file %q: %w", filename, err) + } + pkg[filename] = parsedFile + return nil + } + + // process arg + if arg == "-" { + // Read from stdin and process + data, err := ioutil.ReadAll(os.Stdin) + if err != nil { + return nil, nil, fmt.Errorf("reading from stdin: %w", err) + } + if err := processFile(data, "stdin.gnoffee"); err != nil { + return nil, nil, err + } + } else { + // If it's a directory, gather all .go and .gnoffee files and process accordingly + if info, err := os.Stat(arg); err == nil && info.IsDir() { + err := filepath.Walk(arg, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + ext := filepath.Ext(path) + if ext == ".gnoffee" { + data, err := ioutil.ReadFile(path) + if err != nil { + return fmt.Errorf("reading file %q: %w", path, err) + } + if err := processFile(data, path); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, nil, err + } + } else { + data, err := ioutil.ReadFile(arg) + if err != nil { + return nil, nil, fmt.Errorf("reading file %q: %w", arg, err) + } + if err := processFile(data, arg); err != nil { + return nil, nil, err + } + } + } + return fset, pkg, nil +} diff --git a/gnovm/cmd/gnoffee/main_test.go b/gnovm/cmd/gnoffee/main_test.go new file mode 100644 index 00000000000..5cee00d1530 --- /dev/null +++ b/gnovm/cmd/gnoffee/main_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "os/exec" + "path/filepath" + "testing" + + "github.com/jaekwon/testify/require" + "github.com/rogpeppe/go-internal/testscript" +) + +func TestTest(t *testing.T) { + testscript.Run(t, setupTestScript(t, "testdata")) +} + +func setupTestScript(t *testing.T, txtarDir string) testscript.Params { + t.Helper() + // Get root location of github.com/gnolang/gno + goModPath, err := exec.Command("go", "env", "GOMOD").CombinedOutput() + require.NoError(t, err) + rootDir := filepath.Dir(string(goModPath)) + // Build a fresh gno binary in a temp directory + gnoffeeBin := filepath.Join(t.TempDir(), "gnoffee") + err = exec.Command("go", "build", "-o", gnoffeeBin, filepath.Join(rootDir, "gnovm", "cmd", "gnoffee")).Run() + require.NoError(t, err) + // Define script params + return testscript.Params{ + Setup: func(env *testscript.Env) error { + return nil + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + // add a custom "gnoffee" command so txtar files can easily execute "gno" + // without knowing where is the binary or how it is executed. + "gnoffee": func(ts *testscript.TestScript, neg bool, args []string) { + err := ts.Exec(gnoffeeBin, args...) + if err != nil { + ts.Logf("[%v]\n", err) + if !neg { + ts.Fatalf("unexpected gnoffee command failure") + } + } else { + if neg { + ts.Fatalf("unexpected gnoffee command success") + } + } + }, + }, + Dir: txtarDir, + } +} diff --git a/gnovm/cmd/gnoffee/testdata/valid_sample_with_export.txtar b/gnovm/cmd/gnoffee/testdata/valid_sample_with_export.txtar new file mode 100644 index 00000000000..5d07e7e31e6 --- /dev/null +++ b/gnovm/cmd/gnoffee/testdata/valid_sample_with_export.txtar @@ -0,0 +1,61 @@ +# Test with a valid sample.gnoffee + +gnoffee -w . + +! stderr .+ +! stdout .+ + +cmp gen.golden gnoffee.gen.go + +-- sample.gnoffee -- +package sample + +type foo struct{} + +export baz as Bar + +var baz = foo{} + +func (f *foo) Hello() string { + return "Hello from foo!" +} + +func (f *foo) Bye() { + println("Goodbye from foo!") +} + +type Bar interface { + Hello() string + Bye() +} + +-- gen.golden -- +// Code generated by "gnoffee". DO NOT EDIT. +package sample + +// This function was generated by gnoffee due to the export directive. +func Hello() string { + return baz.Hello() +} + +// This function was generated by gnoffee due to the export directive. +func Bye() { + baz.Bye() +} + +type foo struct{} + +var baz = foo{} + +func (f *foo) Hello() string { + return "Hello from foo!" +} + +func (f *foo) Bye() { + println("Goodbye from foo!") +} + +type Bar interface { + Hello() string + Bye() +} diff --git a/gnovm/pkg/gnoffee/doc.go b/gnovm/pkg/gnoffee/doc.go new file mode 100644 index 00000000000..f2c17cbace5 --- /dev/null +++ b/gnovm/pkg/gnoffee/doc.go @@ -0,0 +1,30 @@ +// Package gnoffee provides a transpiler that extends the Go language +// with additional, custom keywords. These keywords offer enhanced +// functionality, aiming to make Go programming even more efficient +// and expressive. +// +// Current supported keywords and transformations: +// - `export as `: +// This allows for the automatic generation of top-level functions +// in the package that call methods on a specific instance of the struct. +// It's a way to "expose" or "proxy" methods of a struct via free functions. +// +// How Gnoffee Works: +// Gnoffee operates in multiple stages. The first stage transforms +// gnoffee-specific keywords into their comment directive equivalents, +// paving the way for the second stage to handle the transpiling logic. +// +// The Package Path: +// Gnoffee is currently housed under the gnovm namespace, with the +// package path being: github.com/gnolang/gno/gnovm/pkg/gnoffee. +// +// However, it's important to note that while gnoffee resides in the gnovm +// namespace, it operates independently from the gnovm. There's potential +// for gnoffee to be relocated in the future based on its evolving role +// and development trajectory. +// +// Future Changes: +// As the Go and Gno ecosystems and requirements evolve, gnoffee might see the +// introduction of new keywords or alterations to its current functionality. +// Always refer to the package documentation for the most up-to-date details. +package gnoffee diff --git a/gnovm/pkg/gnoffee/gnoffee_test.go b/gnovm/pkg/gnoffee/gnoffee_test.go new file mode 100644 index 00000000000..57ae3aaa580 --- /dev/null +++ b/gnovm/pkg/gnoffee/gnoffee_test.go @@ -0,0 +1,76 @@ +package gnoffee + +import ( + "bytes" + "go/ast" + "go/format" + "go/parser" + "go/token" + "testing" +) + +func TestPackage(t *testing.T) { + inputCode := ` +package sample + +export foo as Bar + +type foo struct{} + +func (f *foo) Hello() string { + return "Hello from foo!" +} + +func (f *foo) Bye() { + println("Goodbye from foo!") +} + +type Bar interface { + Hello() string + Bye() +} +` + expectedOutput := ` +package sample + +// This function was generated by gnoffee due to the export directive. +func Hello() string { + return foo.Hello() +} + +// This function was generated by gnoffee due to the export directive. +func Bye() { + foo.Bye() +} +` + + // Stage 1 + inputCode = Stage1(inputCode) + + // Stage 2 + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "sample.go", inputCode, parser.ParseComments) + if err != nil { + t.Fatalf("Failed to parse input: %v", err) + } + + files := map[string]*ast.File{ + "sample.go": file, + } + + generatedFile, err := Stage2(files) + if err != nil { + t.Fatalf("Error during Stage2 generation: %v", err) + } + + var buf bytes.Buffer + if err := format.Node(&buf, fset, generatedFile); err != nil { + t.Fatalf("Failed to format generated output: %v", err) + } + + generatedCode := normalizeGoCode(buf.String()) + expected := normalizeGoCode(expectedOutput) + if generatedCode != expected { + t.Errorf("Generated code does not match expected output.\nExpected:\n\n%v\n\nGot:\n\n%v", expected, generatedCode) + } +} diff --git a/gnovm/pkg/gnoffee/stage1.go b/gnovm/pkg/gnoffee/stage1.go new file mode 100644 index 00000000000..07b3437eca3 --- /dev/null +++ b/gnovm/pkg/gnoffee/stage1.go @@ -0,0 +1,18 @@ +package gnoffee + +import ( + "regexp" +) + +// Stage1 converts the gnoffee-specific keywords into their comment directive equivalents. +func Stage1(src string) string { + // Handling the 'export' keyword + exportRegex := regexp.MustCompile(`(?m)^export\s+`) + src = exportRegex.ReplaceAllString(src, "//gnoffee:export ") + + // Handling the 'invar' keyword + invarRegex := regexp.MustCompile(`(?m)^invar\s+([\w\d_]+)\s+(.+)`) + src = invarRegex.ReplaceAllString(src, "//gnoffee:invar $1\nvar $1 $2") + + return src +} diff --git a/gnovm/pkg/gnoffee/stage1_test.go b/gnovm/pkg/gnoffee/stage1_test.go new file mode 100644 index 00000000000..b05c24455a1 --- /dev/null +++ b/gnovm/pkg/gnoffee/stage1_test.go @@ -0,0 +1,104 @@ +package gnoffee + +import ( + "testing" +) + +func TestStage1(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Basic Export Functionality", + input: ` +export Foo as FooInstance +invar BarInterface = Baz +`, + expected: ` +//gnoffee:export Foo as FooInstance +//gnoffee:invar BarInterface +var BarInterface = Baz +`, + }, + { + name: "Complex Input with Mixed Code", + input: ` +func someFunction() { + println("Hello, World!") +} + +export Baz as BazInstance +invar QuxInterface = Baz + +func anotherFunction() bool { + return true +} + +export Quux as QuuxInstance +`, + expected: ` +func someFunction() { + println("Hello, World!") +} + +//gnoffee:export Baz as BazInstance +//gnoffee:invar QuxInterface +var QuxInterface = Baz + +func anotherFunction() bool { + return true +} + +//gnoffee:export Quux as QuuxInstance +`, + }, + { + name: "Input with No Changes", + input: ` +func simpleFunction() { + println("Just a simple function!") +} +`, + expected: ` +func simpleFunction() { + println("Just a simple function!") +} +`, + }, + { + name: "Already Annotated Source", + input: ` +// Some comment +//gnoffee:export AlreadyExported as AlreadyInstance +func someFunction() { + println("This function is already annotated!") +} + +//gnoffee:invar AlreadyInterface +var AlreadyInterface Already +`, + expected: ` +// Some comment +//gnoffee:export AlreadyExported as AlreadyInstance +func someFunction() { + println("This function is already annotated!") +} + +//gnoffee:invar AlreadyInterface +var AlreadyInterface Already +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := Stage1(tt.input) + + if output != tt.expected { + t.Errorf("Expected:\n%s\nGot:\n%s\n", tt.expected, output) + } + }) + } +} diff --git a/gnovm/pkg/gnoffee/stage2.go b/gnovm/pkg/gnoffee/stage2.go new file mode 100644 index 00000000000..ed8bbbc7245 --- /dev/null +++ b/gnovm/pkg/gnoffee/stage2.go @@ -0,0 +1,160 @@ +package gnoffee + +import ( + "errors" + "fmt" + "go/ast" + "strings" +) + +// Stage2 transforms the given AST files based on gnoffee directives +// and returns an AST for a new generated file based on the provided files. +func Stage2(files map[string]*ast.File) (*ast.File, error) { + return generateFile(files) +} + +func generateFile(pkg map[string]*ast.File) (*ast.File, error) { + exportMapping := make(map[string]string) + var packageName string + + for _, f := range pkg { + if packageName == "" && f.Name != nil { + packageName = f.Name.Name + } + + // Iterate over all comments in the file. + for _, commentGroup := range f.Comments { + for _, comment := range commentGroup.List { + // Make sure the comment starts a new line. + if strings.HasPrefix(comment.Text, "//") { + parts := strings.Fields(comment.Text) + switch parts[0] { + case "//gnoffee:export": + if len(parts) == 4 && parts[2] == "as" { + k, v := parts[1], parts[3] + exportMapping[k] = v + } else { + return nil, errors.New("invalid gnoffee:export syntax") + } + case "//gnoffee:invar": + return nil, errors.New("unimplemented: invar keyword") + default: + if strings.HasPrefix(parts[0], "//gnoffee:") { + return nil, fmt.Errorf("unknown gnoffee keyword: %s", parts[0]) + } + } + } + } + } + } + + newFile := &ast.File{ + Name: &ast.Ident{Name: packageName}, + Decls: make([]ast.Decl, 0), + } + + // Now, populate the newFile with the necessary declarations based on the exportMapping. + for k, v := range exportMapping { + for _, f := range pkg { + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + iface, ok := typeSpec.Type.(*ast.InterfaceType) + if !ok { + continue + } + + if typeSpec.Name.Name != v { + continue + } + + for _, method := range iface.Methods.List { + fnDecl := &ast.FuncDecl{ + Name: method.Names[0], + Doc: &ast.CommentGroup{ + List: []*ast.Comment{ + { + Text: "\n// This function was generated by gnoffee due to the export directive.", + }, + }, + }, + Type: method.Type.(*ast.FuncType), + Body: &ast.BlockStmt{ + List: make([]ast.Stmt, 0), + }, + } + + callExpr := &ast.CallExpr{ + Fun: &ast.SelectorExpr{ + X: ast.NewIdent(k), + Sel: method.Names[0], + }, + Args: funcTypeToIdentList(method.Type.(*ast.FuncType).Params), + } + + // Check if the method has return values + if method.Type.(*ast.FuncType).Results != nil && len(method.Type.(*ast.FuncType).Results.List) > 0 { + retStmt := &ast.ReturnStmt{ + Results: []ast.Expr{callExpr}, + } + fnDecl.Body.List = append(fnDecl.Body.List, retStmt) + } else { + exprStmt := &ast.ExprStmt{X: callExpr} + fnDecl.Body.List = append(fnDecl.Body.List, exprStmt) + } + + newFile.Decls = append(newFile.Decls, fnDecl) + } + } + } + } + } + + return newFile, nil +} + +func funcTypeToIdentList(fields *ast.FieldList) []ast.Expr { + var idents []ast.Expr + for _, field := range fields.List { + for _, name := range field.Names { + idents = append(idents, ast.NewIdent(name.Name)) + } + } + return idents +} + +func findObjectByName(file *ast.File, objectName string) ast.Node { + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.GenDecl: + for _, spec := range d.Specs { + switch s := spec.(type) { + case *ast.TypeSpec: + if s.Name.Name == objectName { + return s.Type + } + case *ast.ValueSpec: + for _, name := range s.Names { + if name.Name == objectName { + return s.Type + } + } + } + } + case *ast.FuncDecl: + if d.Name.Name == objectName { + return d.Type + } + } + } + return nil +} diff --git a/gnovm/pkg/gnoffee/stage2_test.go b/gnovm/pkg/gnoffee/stage2_test.go new file mode 100644 index 00000000000..cc20d64097e --- /dev/null +++ b/gnovm/pkg/gnoffee/stage2_test.go @@ -0,0 +1,139 @@ +package gnoffee + +import ( + "bytes" + "go/ast" + "go/format" + "go/parser" + "go/token" + "testing" +) + +func TestStage2(t *testing.T) { + tests := []struct { + name string + input string + wantOutput string + wantErr bool + }{ + { + name: "Basic Test", + input: ` + package test + + //gnoffee:export baz as Helloer + + type Helloer interface { + Hello() string + } + + type foo struct{} + + func (f *foo) Hello() string { + return "Hello from foo!" + } + + func (f *foo) Bye() { } + + var baz = foo{} + + var _ Helloer = &foo{} + `, + wantOutput: ` + package test + + // This function was generated by gnoffee due to the export directive. + func Hello() string { + return baz.Hello() + } + `, + wantErr: false, + }, + { + name: "Invalid Export Syntax", + input: ` + package test + + var foo struct{} + //gnoffee:export foo MyInterface3 + type MyInterface3 interface { + Baz() + } + `, + wantErr: true, + }, + { + name: "Already Annotated With gnoffee Comment", + input: ` + package test + + var foo = struct{} + + //gnoffee:export foo as MyInterface4 + type MyInterface4 interface { + Qux() + } + `, + wantOutput: ` + package test + + // This function was generated by gnoffee due to the export directive. + func Qux() { + foo.Qux() + } + `, + wantErr: false, + }, + { + name: "No Export Directive", + input: ` + package test + + type SimpleInterface interface { + Moo() + } + `, + wantOutput: ` + package test + `, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "", tt.input, parser.ParseComments) + if err != nil { + t.Fatalf("Failed to parse input: %v", err) + } + + files := map[string]*ast.File{ + "test.go": file, + } + + generatedFile, err := Stage2(files) + switch { + case err == nil && tt.wantErr: + t.Fatalf("Expected an error") + case err != nil && !tt.wantErr: + t.Fatalf("Error during Stage2 generation: %v", err) + case err != nil && tt.wantErr: + return + case err == nil && !tt.wantErr: + // noop + } + + var buf bytes.Buffer + if err := format.Node(&buf, fset, generatedFile); err != nil { + t.Fatalf("Failed to format generated output: %v", err) + } + + generatedCode := normalizeGoCode(buf.String()) + expected := normalizeGoCode(tt.wantOutput) + if generatedCode != expected { + t.Errorf("Transformed code does not match expected output.\nExpected:\n\n%v\n\nGot:\n\n%v", expected, generatedCode) + } + }) + } +} diff --git a/gnovm/pkg/gnoffee/utils.go b/gnovm/pkg/gnoffee/utils.go new file mode 100644 index 00000000000..9675ad2a541 --- /dev/null +++ b/gnovm/pkg/gnoffee/utils.go @@ -0,0 +1,51 @@ +package gnoffee + +import ( + "strings" +) + +// normalizeGoCode normalizes a multi-line Go code string by +// trimming the common leading white spaces from each line while preserving indentation. +func normalizeGoCode(code string) string { + code = strings.ReplaceAll(code, "\t", " ") + + lines := strings.Split(code, "\n") + + const defaultMax = 1337 // Initialize max with an arbitrary value + + // Determine the minimum leading whitespace across all lines + minLeadingSpaces := defaultMax + for _, line := range lines { + // skip empty lines + if len(strings.TrimSpace(line)) == 0 { + continue + } + + leadingSpaces := len(line) - len(strings.TrimLeft(line, " ")) + // println(len(line), len(strings.TrimLeft(line, " ")), "AAA", strings.TrimLeft(line, " "), "BBB") + if leadingSpaces < minLeadingSpaces { + minLeadingSpaces = leadingSpaces + } + } + // println(minLeadingSpaces) + // println() + + if minLeadingSpaces == defaultMax { + return code + } + + // Trim the determined number of leading whitespaces from all lines + var normalizedLines []string + for _, line := range lines { + if len(line) > minLeadingSpaces { + normalizedLines = append(normalizedLines, line[minLeadingSpaces:]) + } else { + normalizedLines = append(normalizedLines, strings.TrimSpace(line)) + } + } + + normalizedCode := strings.Join(normalizedLines, "\n") + normalizedCode = strings.ReplaceAll(normalizedCode, " ", "\t") + normalizedCode = strings.TrimSpace(normalizedCode) + return normalizedCode +} diff --git a/gnovm/pkg/gnoffee/utils_test.go b/gnovm/pkg/gnoffee/utils_test.go new file mode 100644 index 00000000000..f090c49512f --- /dev/null +++ b/gnovm/pkg/gnoffee/utils_test.go @@ -0,0 +1,85 @@ +package gnoffee + +import ( + "testing" +) + +func TestNormalizeGoCode(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Basic normalization", + input: ` + func main() { + println("Hello, World!") + } + `, + expected: `func main() { + println("Hello, World!") +}`, + }, + { + name: "No indentation", + input: `func main() { +println("Hello, World!") +}`, + expected: `func main() { +println("Hello, World!") +}`, + }, + { + name: "Mixed indentation 1", + input: ` + func main() { + println("Hello, World!") + }`, + expected: `func main() { + println("Hello, World!") +}`, + }, + { + name: "Mixed indentation 2", + input: ` + func main() { + println("Hello, World!") + }`, + expected: `func main() { + println("Hello, World!") + }`, + }, + { + name: "Only one line with spaces", + input: " single line with spaces", + expected: "single line with spaces", + }, + { + name: "Empty lines", + input: ` + + func main() { + + println("Hello!") + + } + + `, + expected: `func main() { + + println("Hello!") + +}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + normalized := normalizeGoCode(test.input) + if normalized != test.expected { + t.Errorf("Expected:\n%s\nGot:\n%s", test.expected, normalized) + } + }) + } +}