Skip to content

Commit

Permalink
Add yamlgen
Browse files Browse the repository at this point in the history
Signed-off-by: Saswata Mukherjee <[email protected]>
  • Loading branch information
saswatamcode committed Jul 12, 2021
1 parent b5e4980 commit f006510
Show file tree
Hide file tree
Showing 5 changed files with 333 additions and 0 deletions.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ require (
github.com/Kunde21/markdownfmt/v2 v2.1.1-0.20210622145915-e6bf3dcd02de
github.com/antchfx/xmlquery v1.3.4 // indirect
github.com/charmbracelet/glamour v0.3.0
github.com/dave/jennifer v1.4.1
github.com/efficientgo/tools/core v0.0.0-20210609125236-d73259166f20
github.com/efficientgo/tools/extkingpin v0.0.0-20210609125236-d73259166f20
github.com/fatih/structtag v1.2.0
github.com/go-kit/kit v0.10.0
github.com/gobwas/glob v0.2.3
github.com/gocolly/colly/v2 v2.1.1-0.20201013153555-8252c346cfb0
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/dave/jennifer v1.4.1 h1:XyqG6cn5RQsTj3qlWQTKlRGAyrTcsk1kUmWdZBzRjDw=
github.com/dave/jennifer v1.4.1/go.mod h1:7jEdnm+qBcxl8PC0zyp7vxcpSRnzXSt9r39tpTVGlwA=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand Down Expand Up @@ -151,6 +153,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/evanw/esbuild v0.6.5/go.mod h1:mptxmSXIzBIKKCe4jo9A5SToEd1G+AKZ9JmY85dYRJ0=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/fortytw2/leaktest v1.2.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
Expand Down
41 changes: 41 additions & 0 deletions pkg/mdformatter/mdgen/mdgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ package mdgen

import (
"bytes"
"io/ioutil"
"os/exec"
"strconv"
"strings"

"github.com/bwplotka/mdox/pkg/mdformatter"
"github.com/bwplotka/mdox/pkg/yamlgen"
"github.com/mattn/go-shellwords"

"github.com/pkg/errors"
Expand All @@ -18,6 +20,7 @@ import (
const (
infoStringKeyExec = "mdox-exec"
infoStringKeyExitCode = "mdox-expect-exit-code"
infoStringKeyGoStruct = "mdox-gen-go-struct"
)

type genCodeBlockTransformer struct{}
Expand Down Expand Up @@ -55,6 +58,11 @@ func (t *genCodeBlockTransformer) TransformCodeBlock(ctx mdformatter.SourceConte
return nil, errors.Errorf("got %q without variable. Expected format is e.g ```yaml %s=\"<value1>\" but got %s", val[0], infoStringKeyExitCode, string(infoString))
}
infoStringAttr[val[0]] = val[1]
case infoStringKeyGoStruct:
if len(val) != 2 {
return nil, errors.Errorf("got %q without variable. Expected format is e.g ```yaml %s=\"<value1>\" but got %s", val[0], infoStringKeyGoStruct, string(infoString))
}
infoStringAttr[val[0]] = val[1]
}
}

Expand Down Expand Up @@ -87,6 +95,39 @@ func (t *genCodeBlockTransformer) TransformCodeBlock(ctx mdformatter.SourceConte
return b.Bytes(), nil
}

if fileWithStruct, ok := infoStringAttr[infoStringKeyGoStruct]; ok {
// This is like mdox-gen-go-struct=<filename>:structname for now.
fs := strings.Split(fileWithStruct, ":")
src, err := ioutil.ReadFile(fs[0])
if err != nil {
return nil, errors.Wrapf(err, "read file for yaml gen %v", fs[0])
}

generatedCode, err := yamlgen.GenGoCode(src)
if err != nil {
return nil, errors.Wrapf(err, "generate code for yaml gen %v", fs[0])
}

b, err := yamlgen.ExecGoCode(ctx, generatedCode)
if err != nil {
return nil, errors.Wrapf(err, "execute generated code for yaml gen %v", fs[0])
}

// TODO(saswatamcode): This feels sort of hacky, need better way of printing.
// Remove `---` and check struct name.
yamls := bytes.Split(b, []byte("---"))
for _, yaml := range yamls {
lines := bytes.Split(yaml, []byte("\n"))
if len(lines) > 1 {
if string(lines[1]) == fs[1] {
ret := bytes.Join(lines[2:len(lines)-1], []byte("\n"))
ret = append(ret, []byte("\n")...)
return ret, nil
}
}
}
}

panic("should never get here")
}

Expand Down
65 changes: 65 additions & 0 deletions pkg/yamlgen/cfggen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) Bartłomiej Płotka @bwplotka
// Licensed under the Apache License 2.0.

// Taken from Thanos project.
//
// Copyright (c) The Thanos Authors.
// Licensed under the Apache License 2.0.

package yamlgen

import (
"io"
"reflect"

"github.com/fatih/structtag"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)

func Generate(obj interface{}, w io.Writer) error {
// We forbid omitempty option. This is for simplification for doc generation.
if err := checkForOmitEmptyTagOption(obj); err != nil {
return errors.Wrap(err, "invalid type")
}
return yaml.NewEncoder(w).Encode(obj)
}

func checkForOmitEmptyTagOption(obj interface{}) error {
return checkForOmitEmptyTagOptionRec(reflect.ValueOf(obj))
}

func checkForOmitEmptyTagOptionRec(v reflect.Value) error {
switch v.Kind() {
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
tags, err := structtag.Parse(string(v.Type().Field(i).Tag))
if err != nil {
return errors.Wrapf(err, "%s: failed to parse tag %q", v.Type().Field(i).Name, v.Type().Field(i).Tag)
}

tag, err := tags.Get("yaml")
if err != nil {
return errors.Wrapf(err, "%s: failed to get tag %q", v.Type().Field(i).Name, v.Type().Field(i).Tag)
}

for _, opts := range tag.Options {
if opts == "omitempty" {
return errors.Errorf("omitempty is forbidden for config, but spotted on field '%s'", v.Type().Field(i).Name)
}
}

if err := checkForOmitEmptyTagOptionRec(v.Field(i)); err != nil {
return errors.Wrapf(err, "%s", v.Type().Field(i).Name)
}
}

case reflect.Ptr:
return errors.New("nil pointers are not allowed in configuration")

case reflect.Interface:
return checkForOmitEmptyTagOptionRec(v.Elem())
}

return nil
}
221 changes: 221 additions & 0 deletions pkg/yamlgen/yamlgen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// Copyright (c) Bartłomiej Płotka @bwplotka
// Licensed under the Apache License 2.0.

package yamlgen

import (
"bytes"
"context"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/ioutil"
"os"
"os/exec"
"path/filepath"

"github.com/dave/jennifer/jen"
"github.com/pkg/errors"
)

// TODO(saswatamcode): Add tests.
// TODO(saswatamcode): Check jennifer code for some safety.
// TODO(saswatamcode): Add mechanism for caching output from generated code.
// TODO(saswatamcode): Currently takes file names, need to make it module based(something such as https://golang.org/pkg/cmd/go/internal/list/).

// GenGoCode generates Go code for yaml gen from structs in src file.
func GenGoCode(src []byte) (string, error) {
// Create new main file.
fset := token.NewFileSet()
generatedCode := jen.NewFile("main")

// Parse source file.
f, err := parser.ParseFile(fset, "", src, parser.AllErrors)
if err != nil {
return "", err
}

// Add imports if needed(will not be used if not required in code).
for _, s := range f.Imports {
generatedCode.ImportName(s.Path.Value[1:len(s.Path.Value)-1], "")
}

// Init statements for structs.
var init []jen.Code
// Declare config map, i.e, `configs := map[string]interface{}{}`.
init = append(init, jen.Id("configs").Op(":=").Map(jen.String()).Interface().Values())

// Loop through declarations in file.
for _, decl := range f.Decls {
// Cast to generic declaration node.
if genericDecl, ok := decl.(*ast.GenDecl); ok {
// Check if declaration spec is `type`.
if typeDecl, ok := genericDecl.Specs[0].(*ast.TypeSpec); ok {
var structFields []jen.Code
// Cast to `type struct`.
structDecl := typeDecl.Type.(*ast.StructType)
fields := structDecl.Fields.List
// Loop and generate fields for each field.
for _, field := range fields {
// Each field might have multiple names.
names := field.Names
for _, n := range names {
pos := n.Obj.Decl.(*ast.Field)
structFields = append(structFields, jen.Id(n.Name).Id(string(src[pos.Type.Pos()-1:pos.Type.End()-1])).Id(string(src[pos.Tag.Pos()-1:pos.Tag.End()-1])))
}
}

// Add initialize statements for struct.
init = append(init, jen.Id("configs").Index(jen.Lit(typeDecl.Name.Name)).Op("=").Id(typeDecl.Name.Name+"{}"))
// Finally put struct inside generated code.
generatedCode.Type().Id(typeDecl.Name.Name).Struct(structFields...)
}
}
}

// Add for loop to iterate through map and return config YAML.
init = append(init, jen.For(
jen.List(jen.Id("k"), jen.Id("config")).Op(":=").Range().Id("configs"),
).Block(
// We import the cfggen Generate method directly to generate output.
jen.Qual("fmt", "Println").Call(jen.Lit("---")),
jen.Qual("fmt", "Println").Call(jen.Id("k")),
// TODO(saswatamcode): Replace with import from mdox itself once merged.
// jen.Qual("github.com/bwplotka/mdox/yamlgen", "Generate").Call(jen.Id("config"), jen.Qual("os", "Stderr")),
jen.Qual("structgen/cfggen", "Generate").Call(jen.Id("config"), jen.Qual("os", "Stderr")),
))

// Generate main function in new module.
generatedCode.Func().Id("main").Params().Block(
init...,
)
return fmt.Sprintf("%#v", generatedCode), nil
}

// execGoCode executes and returns output from generated Go code.
func ExecGoCode(ctx context.Context, mainGo string) ([]byte, error) {
tmpDir, err := ioutil.TempDir("", "structgen")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmpDir)

// TODO(saswatamcode): Remove once merged.
// This is weird but need it for getting Generate function inside tmpDir in PR.
// Once merged this can be removed and can be replaced with just an import in tmpDir/main.go.
err = os.Mkdir(filepath.Join(tmpDir, "cfggen"), 0700)
if err != nil {
return nil, err
}
code, err := os.Create(filepath.Join(tmpDir, "cfggen/cfggen.go"))
if err != nil {
return nil, err
}
defer code.Close()
_, err = code.Write([]byte(cfggenFile))
if err != nil {
return nil, err
}

// Copy generated code to main.go.
main, err := os.Create(filepath.Join(tmpDir, "main.go"))
if err != nil {
return nil, err
}
defer main.Close()

_, err = main.Write([]byte(mainGo))
if err != nil {
return nil, err
}

// Create go.mod in temp dir.
cmd := exec.CommandContext(ctx, "go", "mod", "init", "structgen")
cmd.Dir = tmpDir
if err := cmd.Run(); err != nil {
return nil, errors.Wrapf(err, "run %v", cmd)
}

// Import required packages(generate go.sum).
cmd = exec.CommandContext(ctx, "go", "mod", "tidy")
cmd.Dir = tmpDir
if err := cmd.Run(); err != nil {
return nil, errors.Wrapf(err, "run %v", cmd)
}

// Execute generate code and return output.
b := bytes.Buffer{}
cmd = exec.CommandContext(ctx, "go", "run", "main.go")
cmd.Dir = tmpDir
cmd.Stderr = &b
cmd.Stdout = &b
if err := cmd.Run(); err != nil {
return nil, errors.Wrapf(err, "run %v out %v", cmd, b.String())
}

return b.Bytes(), nil
}

// TODO(saswatamcode): Remove once merged.
// This is weird but need it for getting Generate function inside tmpDir in PR.
// Could also do with commit hash and go.mod, but it would change on each commit in PR).
// Once merged this can be removed and can be replaced with just an import in tmpDir/main.go.
const cfggenFile = `package cfggen
import (
"io"
"reflect"
"github.com/fatih/structtag"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
func Generate(obj interface{}, w io.Writer) error {
// We forbid omitempty option. This is for simplification for doc generation.
if err := checkForOmitEmptyTagOption(obj); err != nil {
return errors.Wrap(err, "invalid type")
}
return yaml.NewEncoder(w).Encode(obj)
}
func checkForOmitEmptyTagOption(obj interface{}) error {
return checkForOmitEmptyTagOptionRec(reflect.ValueOf(obj))
}
func checkForOmitEmptyTagOptionRec(v reflect.Value) error {
switch v.Kind() {
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
tags, err := structtag.Parse(string(v.Type().Field(i).Tag))
if err != nil {
return errors.Wrapf(err, "%s: failed to parse tag %q", v.Type().Field(i).Name, v.Type().Field(i).Tag)
}
tag, err := tags.Get("yaml")
if err != nil {
return errors.Wrapf(err, "%s: failed to get tag %q", v.Type().Field(i).Name, v.Type().Field(i).Tag)
}
for _, opts := range tag.Options {
if opts == "omitempty" {
return errors.Errorf("omitempty is forbidden for config, but spotted on field '%s'", v.Type().Field(i).Name)
}
}
if err := checkForOmitEmptyTagOptionRec(v.Field(i)); err != nil {
return errors.Wrapf(err, "%s", v.Type().Field(i).Name)
}
}
case reflect.Ptr:
return errors.New("nil pointers are not allowed in configuration")
case reflect.Interface:
return checkForOmitEmptyTagOptionRec(v.Elem())
}
return nil
}
`

0 comments on commit f006510

Please sign in to comment.