A formatter for Go text/template files (.go.tmpl) that applies gofmt to the Go code while preserving template actions intact.
{{ if .WithImports }}
import "fmt"
{{ end }}
type {{ .Name }} struct {
{{ range .Fields }}
{{ .Name }} {{ .Type }}
{{ end }}
}After gotmplfmt:
{{ if .WithImports }}
import "fmt"
{{ end }}
type {{ .Name }} struct {
{{ range .Fields }}
{{ .Name }} {{ .Type }}
{{ end }}
}Struct fields are aligned, if / range bodies indented, and every template action ({{ .Name }}, {{ range .Fields }}, …) is preserved byte-for-byte.
- gofmt-backed -- Go code inside the template is normalized by
go/format.Source, so the resulting template renders code that's already gofmt-clean. - Actions preserved -- trim markers (
{{- ... -}}), pipelines, variable declarations ({{ $x := ... }}), template invocations ({{ template "x" . }}), and comments ({{/* ... */}}) all survive unchanged. - Any Go formatter -- plug in
goimports,gofumpt, or your own via a callback. The library depends on the standard library only. - Multi-file templates -- top-level
{{ define "X" }} ... {{ end }}blocks whose bodies carry their ownpackageclauses are formatted independently, so one template file can produce multiple Go source files. - Custom delimiters --
{{/}},<%/%>,[[/]],<</>>,((/)); or pass your own. - CLI with gofmt-compatible flags --
-w,-l,-dbehave as you'd expect, plus a few template-specific options. - Idempotent --
Format(Format(x)) == Format(x)is verified on every fixture and corpus template.
go get github.com/goccy/gotmplfmt
go install github.com/goccy/gotmplfmt/cmd/gotmplfmt@latest
Flags follow gofmt conventions:
| Flag | Description |
|---|---|
| (no args) | read stdin, write stdout |
-w |
rewrite files in place |
-l |
list files that would be reformatted |
-d |
print a minimal unified-style diff |
-ldelim / -rdelim |
custom delimiters (default {{ / }}) |
-indent |
indent unit (\t by default, or spaces) |
-annotate-end |
append {{/* open-action */}} after each {{ end }} for readability |
-imports |
run goimports after gofmt (uses FormatOnly=true so placeholder imports aren't stripped) |
# stdin → stdout
gotmplfmt < tmpl/foo.go.tmpl
# rewrite every .go.tmpl under a tree
gotmplfmt -w ./templates
# check what would change (useful in CI)
gotmplfmt -l ./templates
# with goimports instead of gofmt
gotmplfmt -imports -w ./templatesimport "github.com/goccy/gotmplfmt"
// Simplest: use go/format.Source as the Go formatter.
out, err := gotmplfmt.Source(src)
// Customize delimiters, indent, or plug in goimports/gofumpt.
out, err := gotmplfmt.Format(src, &gotmplfmt.Options{
LeftDelim: "<%",
RightDelim: "%>",
GoFormat: func(b []byte) ([]byte, error) {
return imports.Process("", b, &imports.Options{
Fragment: true, TabIndent: true, FormatOnly: true,
})
},
})
// Inherit the delimiters of an already-configured *template.Template.
t := template.New("x").Delims("<%", "%>")
out, err := gotmplfmt.FormatWith(t, src, nil)type Options struct {
LeftDelim string // default "{{"
RightDelim string // default "}}"
IndentUnit string // default "\t"
GoFormat Formatter // default go/format.Source
AnnotateEnd bool // {{ end }}{{/* if .X */}} annotation
}
type Formatter func(text []byte) ([]byte, error)Go templates aren't Go — gofmt can't parse them directly. gotmplfmt runs a round-trip that lets gofmt do the heavy lifting:
source bytes
│
▼
[1] parse.Parse (SkipFuncCheck | ParseComments) — template tree + raw action ranges
│
▼
[2] substitute: action → Go placeholder — identifier / marker comment / opaque
│ token depending on syntactic context
▼
[3] repair (gopls-style fixSrc) — wrap bare { } blocks, mark regions,
│ fix trailing commas, etc.
▼
[4] Formatter callback (go/format.Source default) — runs gofmt on valid-looking Go
│
▼
[5] restore: AST-walk formatted Go, splice — look up placeholders by AST node
│ placeholders back to actions type + byte offset
▼
[6] post-process — strip synthetic wrappers, dedent,
│ re-separate `{{{` / `}}}`,
│ collapse blank-line runs
▼
formatted bytes
Each template action becomes a placeholder whose Go syntactic shape matches its surrounding context. The table below shows the main cases:
| Template | Go placeholder | Rationale |
|---|---|---|
{{ .X }} in expression position |
_gtxNN (identifier) |
valid in almost every Go expression context |
{{ .X }} trailing key: value, ... |
/*_gtxNN*/ (block comment) |
avoids gofmt inserting a spurious trailing comma |
{{ .Decl }} standalone at decl scope |
// _gtxNN (line comment) |
bare identifier wouldn't parse between declarations |
{{ if X }} / {{ range X }} / {{ with X }} |
// _gt(if|rv|w)NN_begin / ..._end |
pure line-comment markers — no indent fighting |
{{ else }} / {{ else if }} |
// _gtelseN_<open> |
ditto |
{{ end }} |
// <open>_end |
paired to its open in an entry table |
{{ template "x" . }} (expr/stmt) |
_gttmplNN() (function call) |
valid in any expression or statement position |
{{ template "x" . }} standalone decl scope |
// _gttmplNN |
call form invalid between declarations |
{{/* c */}} |
// _gtcmNN |
preserves as a line comment |
{{ break }} / {{ continue }} |
line-comment markers | prevents gofmt from seeing {{ / }} as Go braces |
{{ $x := ... }} (variable decl) |
line-comment marker | no rendered output |
Actions inside a Go /* ... */ block comment |
whole comment replaced by /*_gtblkNN*/ |
prevents gofmt from reindenting comment content |
| One-liner template blocks sharing a line with code | opaque identifier (_gtblkNN) |
structure too fragile for piecewise restoration |
Template files in the wild often contain multiple top-level {{ define "X" }} ... {{ end }} blocks where each body is meant to produce an independent Go source file (each with its own package clause). Concatenating them would yield invalid Go (multiple package declarations), so Format:
- For each top-level define/block whose body has balanced Go braces / brackets / parens, recursively calls
Formaton the body. - Builds a reduced source with each such body replaced by an empty region, formats the outer, then splices the recursively-formatted bodies back in at the restored action positions.
Bodies whose braces don't balance (e.g. templates that use package _p\nfunc _() { as an editor-friendly wrapper without closing }) fall through to whole-file formatting and are preserved verbatim.
After substitute, the "Go source" may not actually parse — for example, a marker region at top level containing statements, or a trailing comma missing between stray tokens. A gopls-inspired fixSrc pass iterates:
go/parser.ParseFile(..., AllErrors)on the current source.- For each error with a recognizable pattern (
missing ',' before newline,expected declaration, found '{',expected ';', found _gt, …), apply a minimal textual fix. - Re-parse. Repeat until parse succeeds or we hit a safety cap.
Two noteworthy fixes:
- Bare
{ ... }at top level -- wrap infunc _gotmplfmt_wrap_N() { ... }. - Top-level marker region containing statements -- wrap in
func _gotmplfmt_region_N() { ... }. Tracked by signature so we don't re-wrap on later iterations.
These wrappers are stripped in post-processing.
The formatted Go is re-parsed with go/parser.ParseFile purely for byte-offset lookup — go/printer is never invoked, so whitespace, alignment, and comment positions stay exactly as gofmt emitted them. Each placeholder kind has a dedicated AST node shape:
*ast.IfStmtwithCond=_gtifN/_gtwN*ast.RangeStmtwithX=_gtrvN*ast.CallExprwithFun=_gttmplN*ast.Identfor inline expression placeholders (including token-split cases like{{ .Prefix }}Handler)*ast.Commentfor line-comment markers*ast.BasicLitfor strings containing placeholders
A reverse-sorted op list splices the original template text back into the formatted bytes.
A few small passes clean up round-trip artifacts:
stripSyntheticWrapsremovesfunc _gotmplfmt_*() { ... }lines and dedents their bodies.stripWrapperPrefix/stripWrapperSuffixstrip thepackage _p/func _() {wrappers that coax partial Go fragments throughgofmt; fall back to a whitespace-flexible regex whengofmtreformatted the wrapper itself.reseparateBracesFromActionsinserts a space between a Go{/}and an adjacent action delimiter so{{{/}}}never appears in the output.collapseBlankLinescaps consecutive newlines at two, preserving idempotency.
- Idempotent --
Format(Format(x)) == Format(x)on every fixture. - Render-equivalent --
TestRenderEquivalenceruns both the original and formatted templates throughtext/templateon the same data and compares the outputs aftergofmtnormalization. - Fuzz-covered --
FuzzFormatexercises the substitute/restore round-trip. - Thread-safe --
Formathas no shared mutable state; verified by a-raceconcurrent-call test.
{{{in source -- Go templates nominally permit{{{(literal{followed by{{ ... }}), buttext/template/parserejects it. The output is guaranteed never to produce{{{; any pre-existing{{{in input must be hand-fixed first.- Three adjacent actions in a struct field --
{{ .Name }} {{ .Type }} {{ .Tag }}where{{ .Tag }}renders to a raw-string struct tag can't be parsed by Go as a single field (three identifiers). The tag must render as`json:"..."`syntactically, or be moved to its own line. - Custom delimiters auto-detection -- only
{{/}},<%/%>,[[/]],<</>>,((/))are probed byFormatWith's delim inference; other delimiter pairs must be passed throughOptions.
make test # go test -v -race ./...
make lint # golangci-lint via tools/go.modLibrary test coverage is 90%+ and the linter reports 0 issues on the current tree.
MIT