Parser & AST — Tasks¶
Sixteen hands-on exercises plus stretch goals. Each builds a small real tool with go/parser, go/ast, and go/token. Start from this skeleton:
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil { log.Fatal(err) }
Do them in order — later tasks reuse patterns from earlier ones.
1. Dump a tree¶
Parse one of your .go files and print it with ast.Print(fset, file). Identify the *ast.File, its Decls, and one *ast.FuncDecl. Find the *ast.BlockStmt body in the dump.
2. Count functions and methods¶
Walk with ast.Inspect; for each *ast.FuncDecl, print the name and whether Recv != nil (method vs function). Report a final count of each.
funcs, methods := 0, 0
ast.Inspect(file, func(n ast.Node) bool {
if fn, ok := n.(*ast.FuncDecl); ok {
if fn.Recv != nil { methods++ } else { funcs++ }
}
return true
})
3. Find all TODO/FIXME comments¶
Parse with parser.ParseComments. Range over file.Comments; for each *ast.CommentGroup whose .Text() contains TODO or FIXME, print file:line: text using fset.Position(group.Pos()).
for _, g := range file.Comments {
if strings.Contains(g.Text(), "TODO") {
p := fset.Position(g.Pos())
fmt.Printf("%s:%d: %s", p.Filename, p.Line, g.Text())
}
}
Expected output is one line per TODO group, sorted by file position.
4. List every imported package¶
Range over file.Imports ([]*ast.ImportSpec); print each imp.Path.Value (the quoted path) and imp.Name if an alias is present. Bonus: flag dot-imports (.) and blank imports (_).
for _, imp := range file.Imports {
path, _ := strconv.Unquote(imp.Path.Value)
alias := ""
if imp.Name != nil { alias = imp.Name.Name } // "_", ".", or a real alias
fmt.Printf("%-8s %s\n", alias, path)
}
5. List all called function names¶
Match *ast.CallExpr; type-switch on call.Fun to handle *ast.Ident (bare calls) and *ast.SelectorExpr (pkg.Func / recv.Method). Print each name with its position.
switch fn := call.Fun.(type) {
case *ast.Ident: report(fn.Name, fn.Pos())
case *ast.SelectorExpr: report(fn.Sel.Name, fn.Sel.Pos())
}
Verify you also see calls inside go, defer, and function literals.
6. Build a call-graph sketch¶
For each *ast.FuncDecl, walk its body and collect the names it calls (reuse task 5). Print caller -> callee edges. Note in a comment why this is approximate (no type info, so you can't tell two methods named Run apart).
7. Detect functions over N statements¶
For each *ast.FuncDecl with a body, count statements in Body.List (top level only). Flag any function with more than, say, 40 statements. Print name, count, position.
8. Detect unused function parameters¶
For each *ast.FuncDecl, collect parameter names from Type.Params (*ast.FieldList), then walk the body counting *ast.Ident uses. Flag params (other than _) never referenced. Caveat: this is heuristic without go/types scope resolution — write down two cases where it could be wrong.
params := map[string]bool{}
for _, field := range fn.Type.Params.List {
for _, name := range field.Names { // one field can name several params
if name.Name != "_" { params[name.Name] = false }
}
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if id, ok := n.(*ast.Ident); ok {
if _, isParam := params[id.Name]; isParam { params[id.Name] = true }
}
return true
})
9. Find empty if / for bodies¶
Match *ast.IfStmt and *ast.ForStmt; flag any whose Body.List is empty (a likely bug or stray ;). Report positions.
10. Rename one identifier (syntactic)¶
Given an old and new name, walk with ast.Inspect, set id.Name = new on every matching *ast.Ident, then re-emit with format.Node. Then write a note: why is this unsafe across scopes, and what would go/types add (object identity)?
ast.Inspect(file, func(n ast.Node) bool {
if id, ok := n.(*ast.Ident); ok && id.Name == old {
id.Name = newName
}
return true
})
format.Node(os.Stdout, fset, file)
11. Add an import cleanly¶
Use astutil.AddImport(fset, file, "strings") and re-print. Confirm the import block stays sorted and grouped. Then astutil.DeleteImport it back out.
astutil.AddImport(fset, file, "strings")
format.Node(os.Stdout, fset, file)
// later:
astutil.DeleteImport(fset, file, "strings")
Observe that adding an already-present import is a no-op — AddImport is idempotent.
12. Wrap every fmt.Println argument list (codemod)¶
With astutil.Apply, find calls to fmt.Println and rewrite them to log.Println (swap the selector's X ident from fmt to log, add the log import). Re-emit and diff. Verify the output still parses by re-running parser.ParseFile on it.
astutil.Apply(file, func(c *astutil.Cursor) bool {
if sel, ok := c.Node().(*ast.SelectorExpr); ok {
if x, ok := sel.X.(*ast.Ident); ok && x.Name == "fmt" && sel.Sel.Name == "Println" {
x.Name = "log"
}
}
return true
}, nil)
astutil.AddImport(fset, file, "log")
Then re-parse the printed bytes and fail loudly if they don't compile.
13. Position round-trip¶
For a chosen node, print Pos() and End(), resolve both via fset.Position, and slice the original source bytes [offsetStart:offsetEnd] to confirm you recovered the exact text the node spans.
14. Comment-preserving rename with dst¶
Repeat task 10 using github.com/dave/dst (decorator.Parse → mutate → decorator.Print). Rename a documented function and confirm its doc comment survives. Compare the diff quality against your go/ast version from task 10.
15. Count node kinds¶
In one ast.Inspect, tally fmt.Sprintf("%T", n) into a map and print the distribution sorted by count. This builds intuition for which node types dominate real code (spoiler: *ast.Ident).
16. Find the deepest nesting¶
Walk while tracking depth (increment on entry, decrement on the nil post-visit call) and report the maximum block-nesting depth and where it occurs — a simple complexity signal.
Stretch goals¶
- Mini-linter as an
analysis.Analyzer: wrap task 12's check usinggolang.org/x/tools/go/analysiswith aSuggestedFix, and run it undersinglechecker. - Whole-module scan: load with
go/packages(./...) and run task 3 (TODOs) across every file, sorted by location. - Type-aware call graph: redo task 6 with
pkg.TypesInfo.Usesso twoRunmethods on different types become distinct edges.
How to check your work¶
For each task:
- Start small. Run against a single file you wrote and fully understand, where you can predict the exact output by hand.
- Then scale. Point it at a real package (e.g. something in the standard library or your own module) and sanity-check the volume and shape of results.
- Dump when confused. If a walk does something surprising,
ast.Print(fset, node)on the offending subtree shows exactly what the parser built. - For codemods, always re-parse the output before trusting it, and diff against the input to confirm only intended lines changed.
These four habits — predict, scale, dump, verify — turn AST exercises into reliable tools.