gotmpl
A Rust port of Go's text/template.
Supports the full template syntax (pipelines, control flow, custom functions, template
composition, whitespace trimming) with Go compatible output. no_std compatible (with alloc).
The crate forbids unsafe code and denies the panic-family lints (panic!, unwrap,
expect, unreachable!, todo!, unimplemented!).
User-provided functions that panic are caught under std; see no_std notes below.
Quick start
use ;
let data = tmap! ;
let result = new
.parse
.unwrap
.execute_to_string
.unwrap;
assert_eq!;
For one-shot renders with no configuration, use gotmpl::execute (source
string) or gotmpl::execute_file (reads from disk):
use ;
let result = execute.unwrap;
assert_eq!;
HTML auto-escaping (html feature)
With the html feature, gotmpl::html::Template is a drop-in analog of
Template that context-aware auto-escapes its output, exactly like Go's
html/template. It is a distinct type on
purpose: escaping can't be forgotten, and passing a non-escaping Template
where escaped output is required won't compile.
use Template;
use tmap;
let data = tmap! ;
let out = new
.parse
.unwrap
.execute_to_string
.unwrap;
assert_eq!;
Each interpolation is escaped for its surrounding context — HTML text, tag and
attribute names, quoted/unquoted attribute values, RCDATA (<textarea>,
<title>), URLs, srcset, JavaScript (value/string/regexp/template-literal),
and CSS. An unsafe URL scheme becomes #ZgotmplZ, and other rejected values
become ZgotmplZ, matching Go.
Trusted content that should bypass escaping is wrapped in one of the
content types — html::HTML, HTMLAttr, JS, JSStr, CSS, URL, Srcset
(analogs of Go's template.HTML etc.), which produce a Value::Safe:
use ;
use tmap;
let data = tmap! ;
let out = new
.parse
.unwrap
.execute_to_string
.unwrap;
assert_eq!; // emitted verbatim in HTML text context
Escaping runs once, lazily, on the first execute* call (so {{template "x"}}
can reference templates added beforehand); after that the template can no longer
be parsed into. The feature is no_std-compatible. See examples/html.rs
(cargo run --example html --features html).
Serde integration (serde feature)
With the serde feature, any serde::Serialize type
becomes template data with no hand-written conversion. gotmpl::to_value
(mirroring serde_json::to_value) turns it into a Value, and the
ToSerdeValue extension trait adds an equivalent .to_serde_value() method:
use ;
use Serialize;
// Go templates access exported PascalCase fields (`{{.Name}}`), but serde
// derives field names verbatim, so add `rename_all` to line the keys up.
let user = User ;
let tmpl = new
.parse
.unwrap;
let data = to_value.unwrap; // or: user.to_serde_value().unwrap()
assert_eq!;
Both entry points return Result<Value> (serde serialization can fail on a
non-string map key or an out-of-range 128-bit integer) and, like ToValue,
materialize the whole value eagerly. Structs and maps become Value::Map, so
field access uses map semantics: a missing field yields <no value> by
default. The feature is no_std-compatible. See examples/serde.rs
(cargo run --example serde --features serde) and the ser module docs for the
rest of the mapping (map-key stringification and ordering-vs-Go).
Template syntax
Actions are delimited by {{ and }} (configurable via .delims()).
Data access
{{.}} Current context (dot)
{{.Name}} Field access on dot
{{.User.Email}} Nested field access
{{$}} Top-level data (root context)
{{$x}} Variable access
{{$x.Name}} Field access on variable
Pipelines
{{.Name | printf "%s!"}}
{{"hello" | len | printf "%d chars"}}
Control flow
{{if .Condition}}...{{end}}
{{if .Cond}}...{{else}}...{{end}}
{{if eq .X 1}}...{{else if eq .X 2}}...{{else}}...{{end}}
{{range .Items}}...{{end}}
{{range .Items}}...{{else}}empty{{end}}
{{range $i, $v := .Items}}{{$i}}: {{$v}}{{end}}
{{range .Items}}{{if eq . 3}}{{break}}{{end}}{{.}}{{end}}
{{range .Items}}{{if eq . 3}}{{continue}}{{end}}{{.}}{{end}}
{{range 5}}{{.}} {{end}}
{{with .Value}}...{{end}}
{{with .Value}}...{{else}}fallback{{end}}
Variables
{{$x := .Name}} Declare variable
{{$x = "new value"}} Assign to existing variable
{{$i, $v := range .List}} Range with index and value (declaration)
{{$i, $v = range .List}} Range with index and value (assignment)
Template composition
{{define "name"}}...{{end}} Define a named template
{{template "name" .}} Invoke a named template
{{block "name" .}}default{{end}} Define and invoke (with default)
Comments and whitespace trimming
{{/* This is a comment */}}
{{- .X}} Trim whitespace before
{{.X -}} Trim whitespace after
{{- .X -}} Trim both sides
Built-in functions
| Function | Description |
|---|---|
print |
Concatenate args (spaces between non-string adjacent args) |
printf |
Formatted output (see below) |
println |
Print with spaces between args, trailing newline |
len |
Length of string, list, or map |
index |
Index into list or map: index .List 0, index .Map "key" |
slice |
Slice a list or string: slice .List 1 3 |
call |
Call a function value: call .Func arg1 arg2 |
eq, ne, lt, le, gt, ge |
Comparison operators. eq supports multi-arg: eq .X 1 2 3 |
and, or |
Short-circuit logic, return the deciding value |
not |
Boolean negation |
html, js, urlquery |
Escape for HTML, JavaScript, URL query |
printf verbs and flags
Format strings follow Go's fmt syntax:
%[flags][width][.precision][argument index]verb.
| Verb | Applies to | Output |
|---|---|---|
%s |
string | The string itself |
%q |
string, int(rune) | Go-quoted string, or single-quoted rune literal |
%v |
any | Default formatted value |
%d |
int | Decimal |
%b |
int | Binary |
%o |
int | Octal |
%x, %X |
int, string | Lower/upper hex (on strings: hex of each byte) |
%c |
int | Unicode scalar |
%U |
int | Unicode U+XXXX (with #: appends 'c') |
%f |
float | Decimal, no exponent |
%e, %E |
float | Scientific notation (lower/upper e) |
%g, %G |
float | %e/%E for large exponents, else %f |
%t |
bool | true / false |
%% |
— | Literal % |
The integer verbs (%d, %b, %o, %x/%X, %c, %U, and %q for runes) accept
both signed (int) and unsigned (uint) values; uint values above i64::MAX print
their true magnitude.
Flags: - (left-align), + (always sign numerics), (leading space for non-negative
numerics), # (alternate form: 0b/0o/0x/0X prefix, or quoted rune for %U),
0 (zero-pad numerics).
Width and .precision accept either a literal number or * / .* to read the value
from the next argument (which must be an integer — floats and other types yield
%!(BADWIDTH) / %!(BADPREC)).
Argument indexing with %[N]verb selects the N-th (1-based) argument and resets the
sequential cursor. Out-of-range or malformed indices produce %!verb(BADINDEX).
Mismatched verb/argument pairs produce Go's error markers rather than panicking:
%!v(BADVERB), %!v(MISSING), %!(EXTRA …) for unconsumed args, and the
BADWIDTH / BADPREC / BADINDEX forms above.
Custom functions
Register functions before parsing:
use ;
use Value;
let result = new
.func
.parse
.unwrap
.execute_to_string
.unwrap;
assert_eq!;
Function values and call
Value::Function allows passing callable values through templates:
extern crate alloc;
use Arc;
use ;
use ;
let adder: ValueFunc = new;
let result = new
.func
.parse
.unwrap
.execute_to_string
.unwrap;
assert_eq!;
Options
use ;
let tmpl = new
.missing_key // error on missing map keys
.delims // custom delimiters
.parse
.unwrap;
MissingKey implements FromStr, so you can parse from strings (useful for
config files or CLI args):
use MissingKey;
let mk: MissingKey = "error".parse.unwrap;
MissingKey variant |
FromStr value |
Behavior |
|---|---|---|
Invalid (default) |
"invalid", "default" |
Return <no value> |
ZeroValue |
"zero" |
Return <no value> |
Error |
"error" |
Return an error |
ZeroValue exists for parity with Go's {{options "missingkey=zero"}} directive.
Since Value is untyped, it behaves the same as Invalid; the variant is there so
callers can still opt in to the named option.
Number literals
Go-compatible number literal syntax:
{{42}} Decimal
{{3.14}} Float
{{0xFF}} Hexadecimal
{{0o77}} Octal
{{0377}} Octal (legacy leading zero)
{{0b1010}} Binary
{{1_000_000}} Underscore separators
{{'a'}} Character literal (97)
{{0x1.ep+2}} Hex float (7.5)
Data model
Template data uses the Value enum:
| Variant | Rust type | Go equivalent |
|---|---|---|
Nil |
n/a | nil |
Bool(bool) |
bool |
bool |
Int(i64) |
i64 |
int |
Uint(u64) |
u64 |
uint |
Float(f64) |
f64 |
float64 |
String(Arc<str>) |
String |
string |
List(Arc<[Value]>) |
Vec<Value> |
[]any |
Map(Arc<BTreeMap<Arc<str>, Value>>) |
BTreeMap |
map[string]any |
Function(ValueFunc) |
Arc<dyn Fn> |
func(...) |
The tmap! macro builds data maps:
use ;
let data = tmap! ;
Custom types reach the engine two ways: implement ToValue by hand, or enable
the serde feature, derive
serde::Serialize, and convert via gotmpl::to_value / .to_serde_value().
no_std support
The crate works in no_std environments (requires alloc). Disable the default std
feature:
[]
= { = "0.3", = false }
Without std, execute_fmt and execute_to_string are available. The io::Write-based
execute/execute_template methods and parse_files require the std feature.
User-defined functions that panic will propagate instead of being caught.
Differences from Go
Rust has no runtime reflection, so:
- No struct field access: use
Value::Mapinstead - No method calls:
.Foois always a field lookup against aValue::Map, never a method invocation. Register the callable via.func(), or store it asValue::Functionand dispatch with thecallbuiltin. - No pointer/interface indirection:
Valueis always concrete - No complex numbers, channels, or
iter.Seq - No typed-nil:
Option::<T>::Nonecollapses to untypedValue::Nil. Go's(*Foo)(nil) != nildistinction does not exist here. - NaN comparisons return an error instead of Go's silently wrong results
API shape is also a bit different:
Template::lookupreturns the parsed body (Option<&ListNode>) rather than a re-executable*Template. To run a named definition, useexecute_template_to_string("name", ...)on the parent.Template::templatesreturns the list of defined names, notTemplateobjects — definitions share the parent's func map and options, so there's no per-definition handle to hand back.parse_filesrequires the files to be valid UTF-8. Go'sos.ReadFile+string(b)is a zero-copy reinterpret and accepts any bytes; we usestd::fs::read_to_string, which validates.parse_globis gated behind theglobcargo feature (on by default, pulls in theglobcrate). It accepts a strict superset of Go'sfilepath.Match:*,?,[abc]/[!abc]classes, and**for recursive descent (Go does not support**). Disabling the feature drops the dependency and removes the API. Match ordering follows theglobcrate (sorted within each directory, depth-first across directories) and may differ from Go'sfilepath.Globwhen the pattern crosses multiple directories — relevant if templates redefine each other (last-wins). Matching is case-sensitive and*matches leading-dot files (theglob::globdefault —MatchOptions::new(),case_sensitive = true,require_literal_leading_dot = false), so*.tmplmatches.hidden.tmpl. Both match Go'sfilepath.Match/filepath.Glob, which also match dotfiles (unlike shell globbing).- Empty / identical custom delimiters:
delims("", "}}")anddelims("{{", "")produce a parse error rather than substituting the defaults (Go silently substitutes);delims("##", "##")is accepted (Go forbids identical left/right). These are pinned by tests so changes to validation are intentional.
Error reporting differs in a couple of places:
- Runtime errors carry no source position.
MissingKey,UndefinedFunction, and other exec-time errors print just the message; Go prefixes them withtemplate: NAME:LINE:COL:. Tracked as a known gap. - The parser stops at the first error. Go's parser collects multiple parse errors per pass; we surface exactly one.
A few data and formatting edge cases diverge too:
\NNNoctal escapes with value ≥ 0x80 encode as the Unicode codepoint U+0080..U+00FF (a 2-byte UTF-8 sequence) where Go emits the single byte 0xNN. Same root cause as theslicedivergence below:Value::Stringis UTF-8, Go strings are byte-slices.%#vfalls back to%v. Go's Go-syntax output ([]interface {}{1, 2, 3},map[string]interface {}{"a":1}) needs concrete-type info we don't carry.%#Uquotes some non-ASCII codepoints that Go skips. Our gate ischar::is_control; Go uses the fullstrconv.IsPrinttable, which also rejects non-ASCII spacing characters like U+00A0.sliceon a string at a mid-codepoint offset returns an error. Go would hand back the raw bytes (potentially invalid UTF-8); we can't store invalid UTF-8 inValue::String, so we reject the slice instead.
Go cross-check
The test suite can optionally run every template through Go's text/template and assert
output parity:
A Go helper (tests/testdata/go_crosscheck.go) is compiled once per test run. It
reads templates and typed data from stdin as JSON, executes them via Go's
text/template, and prints the result.