skip to content

Your go/ast codemod rewrites 400 files, but the printed output drops some comments and attaches others to the wrong declaration. Why, and how do you fix it?

level: seniorimportance: should knowfreq 28%

answer

  1. the grammar has nowhere to put them
  2. one flat list, sorted by position
  3. the printer interleaves by coordinates
  4. your edit invalidated the coordinates
  5. either re-key the association or never reprint

basics

~20 s

Comments are not children of the nodes they document. They sit in a flat, position-sorted list on ast.File, and the printer places them by position. Editing the tree makes those positions stale. Use an ast.CommentMap, or splice the original bytes.

solid answer

~40 s

In `go/ast`, comment text lives in `ast.File.Comments` — a flat slice of `*ast.CommentGroup` sorted by position — and the `Doc` fields on declarations merely point into it. When `go/format.Node` prints, it interleaves that list with the tree by comparing `token.Pos` values. A rewrite breaks that: moved nodes keep old positions, synthesised nodes have `token.NoPos`, and comment groups still carry their original offsets, so groups land beside the wrong node or vanish. Two fixes. Build an `ast.CommentMap` with `ast.NewCommentMap(fset, file, file.Comments)` before editing, edit, then set `file.Comments = cmap.Filter(file).Comments()`. Or — better for a 400-file mechanical change — do not reprint: use `fset.Position(n.Pos()).Offset` and `n.End()` to splice text into the original bytes, back to front. Untouched lines stay byte-identical, which is what makes the diff reviewable.

code

go · 10 lines
go
cmap := ast.NewCommentMap(fset, file, file.Comments)

// ... move, replace or delete declarations in file.Decls ...

file.Comments = cmap.Filter(file).Comments()

var buf bytes.Buffer
if err := format.Node(&buf, fset, file); err != nil {
	return err
}

go deeper

for a junior

Know that parser.ParseComments is what keeps comments at all, and that they end up in ast.File.Comments rather than hanging off the declarations they describe.

for a middle

Explain that the printer places comment groups by comparing recorded positions with node positions, and that editing the tree makes those positions lie — which is the mechanism behind both losses and misplacements.

for a senior

Show the two real remedies and when each applies: an ast.CommentMap captured before the edits for structural rewrites, or byte-offset splicing of the original source when the priority is a diff that only touches the intended lines.

for a principal

Own the reviewability tradeoff on a repo-wide mechanical change: decide whether a reformat rides along or lands as its own commit, what evidence (pilot files, idempotence, green build) is required before merge, and who reviews a diff no human can read end to end.

## Why comments float Go's grammar has no place for comments, so the parser cannot make them children of anything. With `parser.ParseComments`, the parser puts **every** comment group into `ast.File.Comments`, a flat slice ordered by position. Separately, when a comment group sits immediately before a declaration, spec or struct field, the parser also stores a pointer to it in that node's `Doc` field (and trailing same-line comments in a `Comment` field). Those pointers are a convenience view; the authoritative list is `File.Comments`. The printer works off the authoritative list. `go/format.Node(w, fset, file)` walks the tree emitting nodes, and in parallel walks `File.Comments`, flushing each group at the point where its recorded `token.Pos` falls relative to the positions of the nodes being emitted. Comment placement is therefore **a function of positions, not of tree structure**. ## What the rewrite breaks Once you start editing, positions stop describing the tree: - A declaration you **move** keeps the positions it was parsed with, so relative order by position no longer matches order in `Decls`. The printer flushes a comment group when it passes the group's position, which may now be next to something else entirely. - A node you **synthesise** has `token.NoPos` everywhere. It sorts before everything, so comments cluster oddly around it. - A node you **delete** takes no comments with it; the group that documented it is still in `File.Comments` and gets flushed against whatever node now occupies that stretch of the position line. - Comments whose positions fall outside anything the printer emits can be **dropped silently**. The symptom in review is exactly what the question describes: a doc comment sitting above the wrong function, a licence header duplicated or lost, and — the second-order damage — unrelated lines reformatted because reprinting the whole file re-canonicalises everything, including alignment and blank-line grouping the author chose. ## Fix one: maintain the association explicitly `go/ast` ships `ast.CommentMap` for exactly this. Build it **before** editing, while positions still describe the tree: ```go cmap := ast.NewCommentMap(fset, file, file.Comments) ``` The map is keyed by node, so it survives moving a node around: the association is now structural rather than positional. After the edits, project it back: ```go file.Comments = cmap.Filter(file).Comments() ``` `Filter` drops entries whose node is no longer in the tree — that is what stops a deleted function's doc comment reappearing — and `Comments()` flattens what remains back into the sorted slice the printer wants. You can also move a comment deliberately by re-keying it: `cmap[newNode] = cmap[oldNode]; delete(cmap, oldNode)`. For printing a single node with its comments, `printer.CommentedNode{Node: n, Comments: groups}` is the form the printer understands. This works, but it is fiddly, and it does not address the reformatting of untouched lines. ## Fix two: never reprint the file For a large mechanical change the better answer is usually to treat the AST as a **finder**, not a rewriter. Parse to locate the exact byte ranges you want to change, then splice the original source: ```go start := fset.Position(node.Pos()).Offset end := fset.Position(node.End()).Offset ``` Apply the edits in **descending offset order** so earlier offsets stay valid, and write the result. Everything you did not touch is byte-identical — comments included, because you never asked the printer to place them. Run `format.Source` on the result only if your replacement text could disturb formatting, and accept that doing so re-canonicalises the file. The reason this matters is the review, not the machine. A 400-file diff that changes only the lines it meant to change can be read; the same change wrapped in a whole-repo reformat cannot, and reviewers rubber-stamp it. If a reformat is genuinely wanted, land it as a **separate, mechanical commit** so each half is reviewable on its own. ## Diagnosing it when it has already happened - Print positions as you go: `fset.Position(n.Pos())` for every node you touch, mapped back to `file:line:col`, tells you immediately when a node you moved is still claiming its old coordinates. - Dump the tree with `ast.Print(fset, file)` to see which `Doc` fields are populated and where each group sits. - Diff a single file both ways — reprinted versus spliced — on a file you know well before running across the repo. The pilot file is where you discover the licence header moved. - Re-run the tool on its own output. A correct codemod is idempotent; if the second run produces another diff, comment or formatting handling is wrong. ## The rule to remember Comments are attached by coordinates, and every edit invalidates coordinates. Either you take over the association explicitly with a `CommentMap` before you edit, or you avoid the printer altogether and let the original bytes carry the comments through untouched.

  • What does ast.CommentMap.Filter do that matters after a rewrite?
    It returns a map containing only the entries whose node is still present in the tree you pass it. That is what prevents a deleted declaration's doc comment from being flushed back into the output next to whatever now occupies that region. Calling Comments() on the filtered map gives the sorted slice to assign back to ast.File.Comments.
  • Why does reprinting with go/format.Node change lines your codemod never touched?
    Because it regenerates the whole file from the tree in gofmt's canonical form. Anything the tree does not record — the author's blank-line grouping, alignment choices, how a long call was wrapped — is reconstructed by the printer's own rules. On a file that was not already in that exact shape, the diff picks up lines the change had nothing to do with.
  • How do you convince yourself a whole-repo rewrite is safe before landing it?
    Pilot it on a handful of files you know well and read the diff line by line. Then check idempotence — a second run over the output should produce no diff. Then confirm the tree still builds and its tests pass, since a codemod that compiles is not necessarily one that preserved intent. Land the mechanical change alone, with no hand edits mixed in.
  • A node you synthesised prints in a strange place. What is different about it?
    Its positions are token.NoPos, the zero value, so it sorts ahead of every real position in the file. The printer uses those positions to decide where comments and blank lines go, so a synthesised subtree tends to attract or repel comments unpredictably. Either build the node inside a region you fully control, or splice text instead of printing.

The comments are sticky notes placed on a page by grid coordinates, not stapled to the paragraphs. Rearrange the paragraphs and the notes stay where the coordinates put them.

saying these in an interview costs you the question

  • Believes comments are child nodes of the declaration they document
  • Thinks setting a node's Doc field is enough for printing
  • Reprints every file and calls the reformat noise unavoidable
  • Applies byte-offset edits front to back, invalidating later offsets
  • Never checks that the rewrite is idempotent