Skip to content

goccy/gotmplfmt

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gotmplfmt

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.

Features

  • 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 own package clauses 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, -d behave as you'd expect, plus a few template-specific options.
  • Idempotent -- Format(Format(x)) == Format(x) is verified on every fixture and corpus template.

Installation

Library

go get github.com/goccy/gotmplfmt

CLI

go install github.com/goccy/gotmplfmt/cmd/gotmplfmt@latest

How to use

CLI

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 ./templates

Library

import "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)

Options

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)

How it works

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

Substitution strategy

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

Top-level {{ define }} splitting

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:

  1. For each top-level define/block whose body has balanced Go braces / brackets / parens, recursively calls Format on the body.
  2. 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.

Repair (fixSrc)

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:

  1. go/parser.ParseFile(..., AllErrors) on the current source.
  2. For each error with a recognizable pattern (missing ',' before newline, expected declaration, found '{', expected ';', found _gt, …), apply a minimal textual fix.
  3. Re-parse. Repeat until parse succeeds or we hit a safety cap.

Two noteworthy fixes:

  • Bare { ... } at top level -- wrap in func _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.

Restoration

The formatted Go is re-parsed with go/parser.ParseFile purely for byte-offset lookupgo/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.IfStmt with Cond = _gtifN / _gtwN
  • *ast.RangeStmt with X = _gtrvN
  • *ast.CallExpr with Fun = _gttmplN
  • *ast.Ident for inline expression placeholders (including token-split cases like {{ .Prefix }}Handler)
  • *ast.Comment for line-comment markers
  • *ast.BasicLit for strings containing placeholders

A reverse-sorted op list splices the original template text back into the formatted bytes.

Post-processing

A few small passes clean up round-trip artifacts:

  • stripSyntheticWraps removes func _gotmplfmt_*() { ... } lines and dedents their bodies.
  • stripWrapperPrefix / stripWrapperSuffix strip the package _p / func _() { wrappers that coax partial Go fragments through gofmt; fall back to a whitespace-flexible regex when gofmt reformatted the wrapper itself.
  • reseparateBracesFromActions inserts a space between a Go {/} and an adjacent action delimiter so {{{ / }}} never appears in the output.
  • collapseBlankLines caps consecutive newlines at two, preserving idempotency.

Safety properties

  • Idempotent -- Format(Format(x)) == Format(x) on every fixture.
  • Render-equivalent -- TestRenderEquivalence runs both the original and formatted templates through text/template on the same data and compares the outputs after gofmt normalization.
  • Fuzz-covered -- FuzzFormat exercises the substitute/restore round-trip.
  • Thread-safe -- Format has no shared mutable state; verified by a -race concurrent-call test.

Limitations

  • {{{ in source -- Go templates nominally permit {{{ (literal { followed by {{ ... }}), but text/template/parse rejects 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 by FormatWith's delim inference; other delimiter pairs must be passed through Options.

Development

make test    # go test -v -race ./...
make lint    # golangci-lint via tools/go.mod

Library test coverage is 90%+ and the linter reports 0 issues on the current tree.

License

MIT

About

Go's template formatter

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors