Tommy
A TOML library for Go that preserves comments, formatting, and whitespace on
round-trip.
Most TOML libraries parse into a map or struct and discard everything else.
Tommy keeps a concrete syntax tree (CST) so that reading a config, modifying a
value, and writing it back produces minimal diffs --- comments stay where they
were, blank lines don't move, and whitespace around = signs is untouched.
Features
- Round-trip fidelity --- parse and serialize without losing comments or
formatting
- Code generator --- annotate a struct with
//go:generate tommy generate
and get type-safe Decode/Encode methods with undecoded-key detection
- Reflection-based marshal ---
UnmarshalDocument/MarshalDocument for
quick use without codegen
- Document API --- read and write values by dotted key path while preserving
the surrounding document
- Formatter ---
tommy fmt normalizes whitespace, comment spacing, and
blank lines
Install
go install github.com/amarbel-llc/tommy/cmd/tommy@latest
Or with Nix:
nix build github:amarbel-llc/tommy
How It Works
Tommy parses TOML into a concrete syntax tree where comments, whitespace, and
blank lines are first-class nodes --- not discarded during parsing. When you
modify a value through any of Tommy's APIs, only the value node in the tree is
updated. Everything else --- comments above, inline comments after values, blank
line separators between sections --- stays exactly where it was.
input.toml after Encode()
───────────── ──────────────
# Server configuration # Server configuration
[server] [server]
port = 8080 # default port port = 9090 # default port
host = "localhost" host = "localhost"
This is automatic. There is no flag to enable or annotation to add --- every
path through Tommy (codegen, reflection marshal, document API) preserves the
full document structure. The only thing that changes in the output is the value
you changed.
The recommended way to use Tommy is through its code generator. Add a
//go:generate tommy generate directive above your struct and you get type-safe
Decode/Encode methods that handle all of this transparently --- you just
read and write normal Go struct fields. No CST manipulation, no special APIs, no
awareness of the preservation machinery needed.
The code generator is not yet fully type-exhaustive --- support for additional
Go type patterns is being added as needed. See the open
issues for what's planned and in
progress.
This matters for config files that humans maintain: version-controlled TOML with
explanatory comments, hand-tuned formatting, or sections separated by blank
lines. A programmatic update to one field should not rewrite the entire file.
Quick Start
Code Generator (recommended)
Annotate your struct and run go generate:
//go:generate tommy generate
type Config struct {
Title string `toml:"title"`
Port int `toml:"port"`
Debug bool `toml:"debug,omitempty"`
}
Two things to get right:
- Directive placement. Put
//go:generate tommy generate immediately
above each struct you want codegen for. A package-level directive will
exit with no structs with //go:generate tommy generate found.
- Tag name. Tommy reads the standard Go
toml:"..." tag, not a tommy:
tag. Fields without a toml tag are silently skipped.
This produces a config_tommy.go file with:
func DecodeConfig(input []byte) (*ConfigDocument, error)
func (d *ConfigDocument) Data() *Config
func (d *ConfigDocument) Encode() ([]byte, error)
func (d *ConfigDocument) Undecoded() []string
Usage:
doc, err := DecodeConfig(input)
cfg := doc.Data()
cfg.Port = 9090
output, err := doc.Encode()
// output preserves all comments and formatting from input
Reflection-based Marshal
For simpler cases without code generation:
import "github.com/amarbel-llc/tommy/pkg/marshal"
var cfg Config
handle, err := marshal.UnmarshalDocument(input, &cfg)
cfg.Port = 9090
output, err := marshal.MarshalDocument(handle, &cfg)
Document API
For direct key-value manipulation:
import "github.com/amarbel-llc/tommy/pkg/document"
doc, err := document.Parse(input)
port, err := document.Get[int](doc, "server.port")
err = doc.Set("server.port", 9090)
output := doc.Bytes()
Supported Field Types
The code generator handles:
Type TOML Representation
string, int, int64, float64, bool Scalar values
*string, *int, *bool, etc. Optional scalars
Nested structs [table] sections
*Struct [table] or flat dotted keys
Cross-package structs [table] via delegation
[]int, []string Arrays
[]Struct [[array-of-tables]]
map[string]string [table] with string values
map[string]Struct Sub-tables ([parent.key])
TOMLMarshaler/TOMLUnmarshaler Custom marshal via any
TextMarshaler/TextUnmarshaler Custom marshal via string
Cross-Package Structs
When a struct field references a type from another package, the generated code
delegates to that package's DecodeInto/EncodeFrom functions instead of
inlining field-by-field decoding. This means the external package must also use
//go:generate tommy generate on its structs.
// In package "options":
//go:generate tommy generate
type PrintOptions struct {
Abbreviations *abbreviations `toml:"abbreviations"`
PrintColors *bool `toml:"print-colors"`
}
// In your package:
//go:generate tommy generate
type Config struct {
Name string `toml:"name"`
PrintOptions options.PrintOptions `toml:"cli-output"`
}
This delegation enables cross-package structs that contain unexported types ---
the external package handles its own internals, and the consumer just delegates.
Validation
If your struct implements Validate() error, the generated Decode and
Encode methods call it automatically. Decode validates after all fields are
set; Encode validates before writing to the CST.
//go:generate tommy generate
type Config struct {
Port int `toml:"port"`
Name string `toml:"name"`
}
func (c Config) Validate() error {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("port must be 1-65535, got %d", c.Port)
}
return nil
}
No interface import is required --- just add the method and re-run
go generate. This also works with newtypes to validate individual values while
preserving their native TOML types (no string coercion).
Struct Tag Options
`toml:"key"` // required — maps field to TOML key
`toml:"key,omitempty"` // omit zero-value fields on encode
`toml:"key,multiline"` // use """ multiline string syntax
`toml:"-"` // skip this field
Unsupported Field Types
The following will fail at tommy generate time:
interface{} / any --- no static type to dispatch on
map[string]any, map[string]interface{} --- same reason
- Anonymous structs in field types --- name the struct and reference it
- Channels, functions --- not representable in TOML
For untyped or schema-free TOML, use the lower-level pkg/document or
pkg/marshal APIs instead of codegen.
Common Errors
no structs with //go:generate tommy generate found in <file> ---
the directive is on the package or a file-level comment. Move it to
immediately above each struct.
struct any not found in package --- a field has type any/
interface{} (often via map[string]any). See Unsupported Field
Types above.
field <Name>.<Field>: ... --- codegen couldn't classify the field.
Check its Go type against Supported Field Types; the man page
tommy-struct-tags(7) enumerates every kind tommy recognizes.
See Also
tommy(1) --- top-level CLI
tommy-fmt(1) --- formatter subcommand
tommy-generate(1) --- codegen subcommand
tommy-struct-tags(7) --- complete struct-tag and field-kind reference
CLI
# Format TOML files in-place
tommy fmt config.toml settings.toml
# Check formatting without modifying (exits non-zero if unformatted)
tommy fmt --check config.toml
# Format from stdin
cat config.toml | tommy fmt -
# Generate code (typically via go generate, not called directly)
tommy generate
License
MIT