Walking the AST to find generic calls

Go's go/types package exposes new fields on the Info struct for working with generics, including Instances:

// Instances maps identifiers denoting generic types or functions to their
// type arguments and instantiated type.
//
// For example, Instances will map the identifier for 'T' in the type
// instantiation T[int, string] to the type arguments [int, string] and
// resulting instantiated *Named type. Given a generic function
// func F[A any](A), Instances will map the identifier for 'F' in the call
// expression F(int(1)) to the inferred type arguments [int], and resulting
// instantiated *Signature.
//
// Invariant: Instantiating Uses[id].Type() with Instances[id].TypeArgs
// results in an equivalent of Instances[id].Type.
Instances map[*ast.Ident]Instance

At first glance, this looks like the perfect tool for finding calls to generic functions and their instantiated type arguments. The map keys are *ast.Ident nodes pointing directly at the call target in the AST. But that's also where the trouble starts: given an identifier, how do you find the enclosing CallExpr to get the arguments?

If we inspect the AST for a call like handle inside foo, the situation becomes clearer:

AST fragment for generic call

The Ident is a child of the CallExpr, but there's no child-to-parent pointer in the AST. You can't just keep a manual parent stack or use Inspector.WithStack because you're not traversing the tree—you already have the identifier and need to work backwards. The astutil.PathEnclosingInterval helper is technically viable but awkward: it requires a root *ast.File, which you don't necessarily have, and it takes position intervals as input, which feels roundabout when you already hold the node.

This suggests Instances wasn't designed for this kind of direct lookup. A more conventional approach is to traverse the AST ourselves and query Instances when we hit a call expression:

func processPackage(pkg *packages.Package) {
  if len(pkg.Errors) > 0 {
    for _, e := range pkg.Errors {
      fmt.Printf("Error: %s\n", e)
    }
    os.Exit(1)
  }

  walkAstAndJoinInstances(pkg)
}

When walking with Inspect, there's a wrinkle to be aware of: the Fun field of an ast.CallExpr isn't always a plain identifier. Qualified calls like slices.Clone produce a SelectorExpr, and calls with explicit type parameters—like the second handle call in the example—produce an IndexListExpr. The analysis must handle all three forms:

func walkAstAndJoinInstances(pkg *packages.Package) {
  for _, fileAst := range pkg.Syntax {
    ast.Inspect(fileAst, func(n ast.Node) bool {
      if cexpr, ok := n.(*ast.CallExpr); ok {
        var id *ast.Ident
        switch fn := cexpr.Fun.(type) {
        case *ast.Ident:
          id = fn
        case *ast.SelectorExpr:
          id = fn.Sel
        case *ast.IndexListExpr:
          if sel, ok := fn.X.(*ast.SelectorExpr); ok {
            id = sel.Sel
          } else {
            id = fn.X.(*ast.Ident)
          }
        }

        if id != nil {
          if v, ok := pkg.TypesInfo.Instances[id]; ok {
            fmt.Println("call", id)
            fmt.Printf("  instantiation type=%v args=%v\n", v.Type, getListOfTypes(v.TypeArgs))
          }
        }
      }
      return true
    })
  }
}

To extract the actual type arguments from a types.TypeList, a small utility function is handy:

func getListOfTypes(tl *types.TypeList) []types.Type {
  var sl []types.Type
  for i := 0; i < tl.Len(); i++ {
    sl = append(sl, tl.At(i))
  }
  return sl
}

Running the resulting tool on a small sample that mixes inferred and explicit type arguments yields output like this:

$ go run find-generic-calls.go -- samplemodules/use-generics/
call Clone
  instantiation type=func(s []float64) []float64 args=[[]float64 float64]
call handle
  instantiation type=func(k []float64, n int) args=[float64 int]
call handle
  instantiation type=func(k []string, n int) args=[string int]

That identifies every call to a generic function, whether the type arguments were inferred from the value arguments or supplied explicitly, and gives direct access to both the arguments and the instantiated types.

Where to go from here

The tool shown here is deliberately minimal, but it covers the core mechanics for analyzing generic code with Go's standard tooling APIs. Writers of more sophisticated analyzers will find a thorough reference in the example directory of the golang.org/x/exp/typeparams package, particularly around handling index expressions and type parameters across multiple Go versions.