I enjoy hacking on Go tooling (Go programs that analyze and manipulate other Go programs), and a few weeks ago an interesting Stack Overflow question caught my attention. Given a function with a parameter of some structure type:

func foo(param SomeType) {}

How can we examine the fields of SomeType?

The reason I found this question interesting is because it's very representative of the problems Go tool writers typically face:

  1. Find something in the code (e.g. a function)
  2. Analyze this thing (e.g. check its parameters)
  3. Find something else based on the analysis (e.g. the fields of the parameters' types)

Let's see how to write such a tool; the post presents the tool in pieces, with the full code on GitHub.

Step 0: setting up

As usual, we'll be using the x/tools/go/packages package [1]. In this day an age, we expect the input to be a Go module, and the tool will look for a path to the module as its sole command-line argument:

var fset = token.NewFileSet()

func main() {
  const mode packages.LoadMode = packages.NeedName |
    packages.NeedTypes |
    packages.NeedSyntax |
    packages.NeedTypesInfo

  flag.Parse()
  if flag.NArg() != 1 {
    log.Fatal("Expecting a single argument: directory of module")
  }

  cfg := &packages.Config{Fset: fset, Mode: mode, Dir: flag.Args()[0]}
  pkgs, err := packages.Load(cfg, "./...")
  if err != nil {
    log.Fatal(err)
  }

  for _, pkg := range pkgs {
    processPackage(pkg)
  }
}

After it loads all the code, main iterates over all the packages found in the input module and invokes processPackage for each.

Step 1: finding functions

This part is very simple. A package in Go consists of multiple source files; each file has an AST representation, and we we can use ast.Inspect to find all the function declarations as follows [2]:

func processPackage(pkg *packages.Package) {
  for _, fileAst := range pkg.Syntax {
    ast.Inspect(fileAst, func(n ast.Node) bool {
      if funcDecl, ok := n.(*ast.FuncDecl); ok {
        processFuncDecl(funcDecl, pkg.TypesInfo)
      }

      return true
    })
  }
}

Note that we're extracting TypesInfo from the packages.Package struct. This is the type information populated by the type checker because we've set the packages.NeedTypesInfo flag in LoadMode. This is an important prerequisite for step 3.

Step 2: analyzing function parameters

With a function declaration in our hands, we can walk over its parameter list and examine the type of each parameter:

func processFuncDecl(fd *ast.FuncDecl, tinfo *types.Info) {
  fmt.Println("=== Function", fd.Name)
  for _, field := range fd.Type.Params.List {
    var names []string
    for _, name := range field.Names {
      names = append(names, name.Name)
    }
    fmt.Println("param:", strings.Join(names, ", "))
    processTypeExpr(field.Type, tinfo)
  }
}

Note that in Go, multiple parameters can share a type - as in func foo(a, b int); this code collects all the names of a field into a single slice for reporting. processTypeExpr is then invoked to process the actual field type.

Step 3: finding the types of struct fields

Analyzing the actual parameter type is the most involved part of our tool. There are two ways to reason about a type in Go source code - the basic syntax level, and a semantic level.

On the syntax level, a type is what the parser sees and is represented by ast.Expr. For example, for type *T, this would be an ast.StarExpr with whatever T is in its X field. Our processFuncDecl function focuses on this syntax level, and it passes an ast.Expr to processTypeExpr to represent the type.

On the semantic level, the tooling infrastructure runs a full type checker (from the stdlib go/types package) to determine a mapping between names and actual types in the code.

Thinking about it differently, the syntax level is what we see by just looking at the code - MyType is an identifier, but for the Go compiler (and tooling) it can carry a deeper (semantic) meaning - a reference to some type.

func processTypeExpr(e ast.Expr, tinfo *types.Info) {
  switch tyExpr := e.(type) {
  case *ast.StarExpr:
    fmt.Println("  pointer to...")
    processTypeExpr(tyExpr.X, tinfo)
  case *ast.ArrayType:
    fmt.Println("  slice or array of...")
    processTypeExpr(tyExpr.Elt, tinfo)
  default:
    switch ty := tinfo.Types[e].Type.(type) {
    case *types.Basic:
      fmt.Println("  basic =", ty.Name())
    case *types.Named:
      fmt.Println("  name =", ty)
      uty := ty.Underlying()
      fmt.Println("  type =", ty.Underlying())
      if sty, ok := uty.(*types.Struct); ok {
        fmt.Println("  fields:")
        processStructParamType(sty)
      }
      fmt.Println("  pos =", fset.Position(ty.Obj().Pos()))
    default:
      fmt.Println("  unnamed type =", ty)
      if sty, ok := ty.(*types.Struct); ok {
        fmt.Println("  fields:")
        processStructParamType(sty)
      }
    }
  }
}

This function is recursively peeling off pointers and arrays/slices off the type, but eventually ends up in the default clause of the switch. This is where it queries the types.Info that was passed in to see what type it's dealing with. There are "basic" types like string, named types like MyType and even unnamed types like struct {x int} [3].

The type mapping has some quirks that may be surprising when working with it for the first time. For example, ast.SelectorExpr types that are used to identify dot-separated names like http.Handler; these have mappings in types.Info (which maps AST nodes to their types). However, pointer types like *MyType do not have direct mappings, and the ast.StarExpr has to be peeled off first. With some experience and judicious use of AST dumping these rules aren't hard to figure out, though.

Finally, here's the helper for reporting all fields of a struct:

func processStructParamType(sty *types.Struct) {
  for i := 0; i < sty.NumFields(); i++ {
    field := sty.Field(i)
    fmt.Println("    ", field.Name(), field.Type())
  }
}

This is where the tool's final destination is. Whatever you need from these struct field types, you can do here.

Sample run

Suppose we have an input module with a single package main, with this file (main.go):

package main

import (
  "net/http"
  "strings"
)

func foo(x string, u User, up *User, h http.Handler, s struct{ b bool }) {
}

func bar(sb *strings.Builder) {
}

And an additional file defining the User type (user.go):

package main

type User struct {
  id   int
  name string
}

Now we can run our tool on this module, and get the following output:

=== Function foo
param: x
  basic = string
param: u
  name = example.com.User
  type = struct{id int; name string}
  fields:
     id int
     name string
  pos = 2022/go-tool-func-param-types/sample-module/user.go:3:6
param: up
  pointer to...
  name = example.com.User
  type = struct{id int; name string}
  fields:
     id int
     name string
  pos = 2022/go-tool-func-param-types/sample-module/user.go:3:6
param: h
  name = net/http.Handler
  type = interface{ServeHTTP(net/http.ResponseWriter, *net/http.Request)}
  pos = $GOROOT/src/net/http/server.go:86:1
param: s
  unnamed type = struct{b bool}
  fields:
     b bool
=== Function bar
param: sb
  pointer to...
  name = strings.Builder
  type = struct{addr *strings.Builder; buf []byte}
  fields:
     addr *strings.Builder
     buf []byte
  pos = $GOROOT/src/strings/builder.go:15:1

Notes:

  • The tool correctly sees through pointer types to get to the underlying type
  • For both named and unnamed struct types, the tool gets to the struct's fields and lists their names and types
  • For named types, the tool reports where they are defined (file, line and column) - both for user-defined types and types in the standard library

[1]For details on the basic usage of x/tools/go/packages, see my earlier post on Writing multi-package analysis tools.
[2]Alternatively, we could iterate directly over the Decl fields of the packages.Package. I prefer using the inherently recursive ast.Inspect because it works for a wider variety of scenarios and will find the declarations of unnamed functions within other functions as well.
[3]Note: this description does not intend to be complete in the sense of covering the entire Go spec. If you encounter additional cases not covered here, do an AST dump of the nodes you run into and consult the documentation to see how to deal with them.