Skip to content

Commit

Permalink
feat: add gnoffee PoC
Browse files Browse the repository at this point in the history
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
  • Loading branch information
moul committed Sep 3, 2023
1 parent 64f0fd0 commit a63ca1b
Show file tree
Hide file tree
Showing 11 changed files with 905 additions and 0 deletions.
133 changes: 133 additions & 0 deletions gnovm/cmd/gnoffee/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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...)
}

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 = printer.Fprint(f, fset, newFile)
if err != nil {
return fmt.Errorf("writing to file %q: %w", filename, err)
}
} else {
_ = printer.Fprint(os.Stdout, fset, newFile)
}
return nil
}

func processPackageOrFileOrStdin(arg string) (*token.FileSet, map[string]*ast.File, error) {
var fset = token.NewFileSet()

Check failure on line 74 in gnovm/cmd/gnoffee/main.go

View workflow job for this annotation

GitHub Actions / lint

File is not `gofumpt`-ed (gofumpt)
var 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: %v", filename, err)

Check failure on line 83 in gnovm/cmd/gnoffee/main.go

View workflow job for this annotation

GitHub Actions / lint

non-wrapping format verb for fmt.Errorf. Use `%w` to format errors (errorlint)
}
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: %v", path, err)

Check failure on line 111 in gnovm/cmd/gnoffee/main.go

View workflow job for this annotation

GitHub Actions / lint

non-wrapping format verb for fmt.Errorf. Use `%w` to format errors (errorlint)
}
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,
}
}
60 changes: 60 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,60 @@
# 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 --
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

0 comments on commit a63ca1b

Please sign in to comment.