Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add gnoffee PoC #1092

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions gnovm/cmd/gnoffee/main.go
Original file line number Diff line number Diff line change
@@ -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] <package-path or file.gnoffee or '-'>")
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
}
50 changes: 50 additions & 0 deletions gnovm/cmd/gnoffee/main_test.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
61 changes: 61 additions & 0 deletions gnovm/cmd/gnoffee/testdata/valid_sample_with_export.txtar
Original file line number Diff line number Diff line change
@@ -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()
}
30 changes: 30 additions & 0 deletions gnovm/pkg/gnoffee/doc.go
Original file line number Diff line number Diff line change
@@ -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 <structName> as <interfaceName>`:
// 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
76 changes: 76 additions & 0 deletions gnovm/pkg/gnoffee/gnoffee_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 18 additions & 0 deletions gnovm/pkg/gnoffee/stage1.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading