Code generation in Go - constructors

A code generation is an interesting concept, where a program generates another program. It’s very popular in the Java world, for instance, tools like Dagger rely heavily on it. In the Go ecosystem, the code generation hasn’t gained a lot of attention so far. The list of code generators can be found on the official repository https://github.com/golang/go/wiki/GoGenerateTools

Building a generator is quite a straight forward task, I wrote https://github.com/domsu/goconstruct to prove that. It’s a simple tool, that generates constructors for strut types. IDEs such as Goland can already generate constructors, so it’s mainly for educational purposes.

I started by looking for go files in the specified directory and processing each file in the processFile function.

	fileNames := getGoFileNamesInDirectory(flag.Args()[0])
	if len(fileNames) == 0 {
		log.Println("No files to process")
		return
	}

	for _, fileName := range fileNames {
		processFile(fileName, typeFilter)
	}

The go file is parsed into an AST by calling parser.ParseFile which returns ast.File node.

func processFile(fileName string, typeFilter []string) {
	fSet := token.NewFileSet()
	node, err := parser.ParseFile(fSet, fileName, nil, parser.ParseComments)
	if err != nil {
		log.Fatal(err)
	}
	(...)
}

The ast.File represents a parsed content of the go source file, giving the access to all declarations, documentation, imports etc.

type File struct {
	Doc        *CommentGroup   // associated documentation; or nil
	Package    token.Pos       // position of "package" keyword
	Name       *Ident          // package name
	Decls      []Decl          // top-level declarations; or nil
	Scope      *Scope          // package scope (this file only)
	Imports    []*ImportSpec   // imports in this file
	Unresolved []*Ident        // unresolved identifiers in this file
	Comments   []*CommentGroup // list of all comments in the source file
}

At this point, my goal comes down to analyzing the ast.File node.

Looking at the template of the output file, it’s clear that I’ve three main problems to solve: getting a package name, getting correct imports, generating constructors

const genFileContent = `// Code generated by goconstruct. DO NOT EDIT

package %s

import (
%s
)
 
%s`

Getting the package name

The generated file is located in the same directory as the source file. I simply use node.Name.Name to get the package name.

Getting imports

The file being processed most likely contains a list of imports used in functions, variables, structs etc. The output file though, must only contain a list of imports used in struct types. There should be no missing or unused imports.

It’s done by utilising ast.Inspect(node Node, f func(Node) bool), it traverses an AST in depth-first order.

As it traverses the AST, it calls func(Node) bool function notifying about the current node. The nodes are declared in the ast package, for instance there are nodes such as StructType, FuncType, ForStmt.

The following code inspects the nodes, visiting only struct types and their content, returning a map of package names used in struct fields.

I use the inspectingStruct and depth variables to check whether or not the program is currently inspecting struct’s content.

func getPackageNamesUsedInStructFields(node *ast.File, structTypeSpecs []*ast.TypeSpec) map[string]bool {
	result := make(map[string]bool)
	var inspectingStruct = false
	var depth = 0

	ast.Inspect(node, func(n ast.Node) bool {
		var shouldInspect = false
		if n == nil && inspectingStruct {
			depth--
			if depth == 0 {
				inspectingStruct = false
			}
		}

		if inspectingStruct {
			shouldInspect = true
		} else if genDecl, genDeclOk := n.(*ast.GenDecl); genDeclOk {
			if typeSpec, typeSpecOk := genDecl.Specs[0].(*ast.TypeSpec); typeSpecOk {
				for _, v := range structTypeSpecs {
					if typeSpec == v {
						shouldInspect = true
						inspectingStruct = true
						break
					}
				}
			}
		} else if _, fileOk := n.(*ast.File); fileOk {
			shouldInspect = true
		}

		if selExpr, selExprOk := n.(*ast.SelectorExpr); selExprOk {
			if ident, identOk := selExpr.X.(*ast.Ident); identOk {
				result[ident.Name] = true
			}
		}

		if n != nil && inspectingStruct {
			depth++
		}

		return shouldInspect
	})
	return result
}

Generating constructors

A similar approach is used for the constructor generation. For each *ast.TypeSpec in structTypeSpecs there is one constructor to generate.

I choose the right function prefix - New for an exported struct, new otherwise.

func generateConstructors(fSet *token.FileSet, structTypeSpecs []*ast.TypeSpec) []string {
	var constructors []string
	for _, structTypeSpec := range structTypeSpecs {
		structName := structTypeSpec.Name.Name

		var exported = structName[0] == strings.ToUpper(structName)[0]
		var functionPrefix = "new"
		if exported {
			functionPrefix = "New"
		}
	(...)
}

*ast.TypeSpec contains all data I need to generate constructor’s arguments and the body.

func generateConstructors(fSet *token.FileSet, structTypeSpecs []*ast.TypeSpec) []string {
		(...)
		var args []string
		var body []string
		body = append(body, fmt.Sprintf("\ts := %s{}", structName))

		structType := structTypeSpec.Type.(*ast.StructType)
		for _, field := range structType.Fields.List {
			var buf bytes.Buffer
			if err := printer.Fprint(&buf, fSet, field.Type); err != nil {
				log.Fatal(err)
			}
			for _, name := range field.Names {
				fieldArg := fmt.Sprintf("%s %s", name, buf.String())
				args = append(args, fieldArg)
				body = append(body, fmt.Sprintf("\ts.%s = %s", name, name))
			}
		}
		(...)
}

Having the function name, arguments and the body is all I need to generate the constructor.

func generateConstructors(fSet *token.FileSet, structTypeSpecs []*ast.TypeSpec) []string {
		(...)
		constructorStructName := strings.ToUpper(structName)[0:1] + structName[1:]
		constructor := fmt.Sprintf("func %s%s(%s) *%s {\n%s\n}\n", functionPrefix, constructorStructName, strings.Join(args, ","), structName, strings.Join(body, "\n"))
		constructors = append(constructors, constructor)
	}
	return constructors
}

The output file

Getting back to the processFile function. I simply use the genFileContent template, combining the package name, imports and constructors - creating the final file.

func processFile(fileName string, typeFilter []string) {
	(...)
	constructors := generateConstructors(fSet, structTypeSpecsToProcess)
	imports := generateImports(node, structTypeSpecsToProcess)

	ext := path.Ext(fileName)
	outFile := fileName[0:len(fileName)-len(ext)] + "_gen.go"
	genFile, err := os.OpenFile(outFile, os.O_RDWR|os.O_CREATE, 0666)
	if err != nil {
		log.Fatal(err)
	}
	defer genFile.Close()

	if _, err = genFile.WriteString(fmt.Sprintf(genFileContent, node.Name.Name, strings.Join(imports, "\n"), strings.Join(constructors, "\n"))); err != nil {
		log.Fatal(err)
	}
}

Conclusion

This only touches a surface of a code generation with AST traversal. Go has a pretty good support for building code generators. I didn’t have to use any external tools and I was surprised how straightforward it was.

Hopefully my article along with the library is a good blueprint to help others building more advanced code generators. Do you see in your projects blocks of code that could be generated?


Go

981 Words

2020-03-29 17:00 +1100