gotmpl 0.4.0

A Rust reimplementation of Go's text/template library
Documentation

gotmpl

Test GitHub License Crates.io Version docs.rs Crates.io MSRV

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 gotmpl::{Template, tmap};

let data = tmap! { "Name" => "World" };
let result = Template::new("hello")
    .parse("Hello, {{.Name}}!")
    .unwrap()
    .execute_to_string(&data)
    .unwrap();
assert_eq!(result, "Hello, World!");

For one-shot renders with no configuration, use gotmpl::execute (source string) or gotmpl::execute_file (reads from disk):

use gotmpl::{execute, tmap};

let result = execute("Hello, {{.Name}}!", &tmap! { "Name" => "World" }).unwrap();
assert_eq!(result, "Hello, World!");

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 %

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 int — 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 gotmpl::{Template, tmap};
use gotmpl::Value;

let result = Template::new("test")
    .func("upper", |args| {
        match args.first() {
            Some(Value::String(s)) => Ok(Value::String(s.to_uppercase().into())),
            _ => Ok(Value::Nil),
        }
    })
    .parse("{{.Name | upper}}")
    .unwrap()
    .execute_to_string(&tmap! { "Name" => "hello" })
    .unwrap();
assert_eq!(result, "HELLO");

Function values and call

Value::Function allows passing callable values through templates:

extern crate alloc;
use alloc::sync::Arc;
use gotmpl::{Template, tmap};
use gotmpl::{Value, ValueFunc};

let adder: ValueFunc = Arc::new(|args| {
    let sum: i64 = args.iter().filter_map(|a| a.as_int()).sum();
    Ok(Value::Int(sum))
});

let result = Template::new("test")
    .func("getAdder", move |_| Ok(Value::Function(adder.clone())))
    .parse("{{call (getAdder) 3 4}}")
    .unwrap()
    .execute_to_string(&tmap!{})
    .unwrap();
assert_eq!(result, "7");

Options

use gotmpl::{Template, MissingKey};

let tmpl = Template::new("t")
    .missing_key(MissingKey::Error)   // error on missing map keys
    .delims("<<", ">>")              // custom delimiters
    .parse("<< .Name >>")
    .unwrap();

MissingKey implements FromStr, so you can parse from strings (useful for config files or CLI args):

use gotmpl::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
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 gotmpl::{tmap, ToValue};

let data = tmap! {
    "Name" => "Alice",
    "Age" => 30i64,
    "Scores" => vec![95i64, 87, 92],
    "Address" => tmap! {
        "City" => "Paris",
    },
};

no_std support

The crate works in no_std environments (requires alloc). Disable the default std feature:

[dependencies]
gotmpl = { version = "0.3", default-features = 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::Map instead
  • No method calls: register functions via .func()
  • No pointer/interface indirection: Value is always concrete
  • No complex numbers, channels, or iter.Seq
  • NaN comparisons return an error instead of Go's silently wrong results

API shape is also a bit different:

  • Template::lookup returns the parsed body (Option<&ListNode>) rather than a re-executable *Template. To run a named definition, use execute_template_to_string("name", ...) on the parent.
  • Template::templates returns the list of defined names, not Template objects — definitions share the parent's func map and options, so there's no per-definition handle to hand back.
  • parse_files requires the files to be valid UTF-8. Go's os.ReadFile + string(b) is a zero-copy reinterpret and accepts any bytes; we use std::fs::read_to_string, which validates.

A few formatting and slicing edge cases diverge too:

  • %#v falls 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.
  • %#U quotes some non-ASCII codepoints that Go skips. Our gate is char::is_control; Go uses the full strconv.IsPrint table, which also rejects non-ASCII spacing characters like U+00A0.
  • slice on 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 in Value::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:

cargo test --features go-crosscheck

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.