Quill Extensions¶
Quill's standard library is the floor, not the ceiling. A host application adds
its own filters, functions, and tests (and its own constants and enumerations)
through the ext package, layers them over the core library when it builds an
Environment, and (optionally) teaches the sandbox and the type checker about
them. This document is the full reference for that surface.
The three callable kinds map to the three syntactic positions:
- a filter is invoked through the pipe,
x | name(args), which isname(x, args); - a function is invoked as
name(args), every argument explicit; - a test is applied as
x is nameorx is name(arg)and yields a boolean.
A name resolves to at most one filter, one function, and one test; the position selects which family is consulted, so the same name may denote a filter and a function without collision (the standard library uses this).
1. The callable value objects¶
Every callable is a small struct in package ext. The struct form gives you full
control: you receive the already-flattened argument slice and return a
runtime.Value.
type Filter struct {
Name string
Fn func(ctx context.Context, args []runtime.Value) (runtime.Value, error)
// Fn1 is the optional arity-known fast call for a bare pipe (x | name
// with no explicit arguments); see Section 1.1.
Fn1 func(ctx context.Context, v runtime.Value) (runtime.Value, error)
NeedsEnvironment bool
NeedsContext bool
NeedsCharset bool
}
type Function struct {
Name string
Fn func(ctx context.Context, args []runtime.Value) (runtime.Value, error)
NeedsEnvironment bool
NeedsContext bool
NeedsCharset bool
}
type Test struct {
Name string
Fn func(ctx context.Context, args []runtime.Value) (bool, error)
}
Every Fn takes the render's context.Context as its first parameter: the
cancellation and deadline context threaded from the Render call, which a
long-running callable should honor. It is distinct from the NeedsContext
injection flag (Section 6), which prepends the template's variable scope into
args. After ctx, a filter's interpreter flattens the piped value and the
explicit arguments (positional, named, and spread) into one []runtime.Value in
call order, so "ab" | repeat(3) reaches Fn as
[]runtime.Value{Str("ab"), Int(3)}. A function receives just its explicit
arguments; a test receives the tested value first, then any argument.
The struct form is the right tool when you need to inspect argument kinds, accept a variable shape, or build the result value by hand:
set := ext.NewSet()
set.AddFilter(&ext.Filter{
Name: "reverse_str",
Fn: func(ctx context.Context, args []runtime.Value) (runtime.Value, error) {
s, _ := runtime.ToText(args[0])
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return runtime.Str(string(r)), nil
},
})
1.1 The unary fast call (Fn1)¶
A filter may additionally publish Fn1, its behavior for a bare pipe with no
explicit arguments. The engine consults Fn1 only when the call site proved
zero explicit arguments syntactically and none of the Needs* flags is set;
every other invocation (explicit arguments, spreads including ones that expand
to nothing, injection flags, or a registration without Fn1) builds the usual
fresh argument slice and goes through Fn. Which of the two runs is an engine
dispatch choice the template author never observes, so a registration that sets
Fn1 must keep Fn's zero-extra-argument behavior identical; the easiest way
is NewFilter1, which builds both from one unary function:
set.AddFilter(ext.NewFilter1("shout", func(ctx context.Context, v runtime.Value) (runtime.Value, error) {
s, err := runtime.ToText(v)
if err != nil {
return runtime.Null(), err
}
return runtime.Str(strings.ToUpper(s) + "!"), nil
}))
A NewFilter1-built filter must keep its Needs* flags unset: the wrapped Fn
reads the piped value at position zero, where an injected engine value would
land. The audited core filters (upper, lower, trim, capitalize,
title, length, first, last, reverse, keys, raw) register this
way, which is why a bare pipe through them allocates no argument slice.
Because Fn1 sits between Fn and the flags, unkeyed ext.Filter
composite literals from before the field existed no longer compile; use keyed
literals (as every example in this document does) or NewFilter1.
2. The typed helpers¶
Most callables are ordinary Go functions over ordinary Go types. The typed
helpers NewFilter, NewFunction, and NewTest take such a function and wrap it
in the []runtime.Value-based Fn the engine calls, so you never touch a
runtime.Value in the body:
set := ext.NewSet()
set.AddFilter(ext.NewFilter("repeat", func(s string, n int64) string {
return strings.Repeat(s, int(n))
}))
set.AddFunction(ext.NewFunction("clamp", func(x, lo, hi int64) int64 {
switch {
case x < lo:
return lo
case x > hi:
return hi
default:
return x
}
}))
set.AddTest(ext.NewTest("even", func(n int64) bool { return n%2 == 0 }))
The helpers inspect the function's signature once, at registration time, and resolve one converter per parameter and one for the result. The wrapper the render loop invokes marshals values through those pre-resolved converters and does no further signature inspection.
2.1 Marshaling rules¶
Each parameter and result type marshals between a Go type and a runtime.Value:
| Go type | runtime.Value |
|---|---|
string |
Str (and Safe) |
bool |
Bool |
int, int8 .. int64 |
Int |
float32, float64 |
Float (an Int widens to float on input) |
[]T |
*Array (element-wise) |
runtime.Value |
passed through unchanged |
A parameter typed runtime.Value is the escape hatch inside a typed helper: that
argument arrives unconverted, so a single callable can mix marshaled and raw
parameters.
2.2 Variadics, results, and errors¶
- A variadic final parameter (
func(parts ...string) string) is supported: the fixed leading parameters convert as usual and every trailing argument converts to the variadic element type. - The function may return nothing (the result is
Null), a single value, or a value followed by anerror. A non-nil error returned by the body surfaces as a positioned render error. NewTestrequires the function to return a leadingbool(optionally followed by anerror).
Arity and argument-type mismatches at call time produce a clear typed error
naming the callable and the offending argument, for example
filter repeat: expected 2 argument(s), got 1. An unsupported function shape
is a registration-time programming error, not a template fault, so the helper
panics when you register it: a non-func value, an unsupported parameter or
result type, too many results, a second result that is not error, or a test
that does not return a bool.
The struct form (Section 1) and the typed helpers interoperate freely (a single
Set can hold both), so reach for the helper by default and drop to the
struct form only where you need the raw slice.
3. The Set registry¶
A Set is the callable registry: name-keyed maps for filters,
functions, and tests, plus the host constant and enumeration tables. Build one
with NewSet and add to it:
set := ext.NewSet()
set.AddFilter(f) // register or shadow a filter by name
set.AddFunction(fn) // ... a function
set.AddTest(t) // ... a test
set.AddConstant("PI", runtime.Float(3.14159))
set.AddEnum("Color", []runtime.Value{runtime.Str("red"), runtime.Str("green")})
Adding a name that already exists shadows the earlier entry; this is exactly
how a host overrides a built-in of the same kind and name. All filter, function,
and test registration must complete before rendering begins: renders read the
registry without synchronization, so mutating a Set mid-render is
unsupported. Lookups
(Filter/Function/Test) and existence checks (HasFilter/HasFunction/
HasTest, which back the @guard statement) are by exact name. Clone returns a
shallow copy with independent maps, so you can layer additions over a base set
without mutating it.
Constants back the constant() function and the is constant test; enumerations
(ordered case lists) back enum() and enum_cases() (see
Standard Library).
4. Bundles¶
A Bundle is a cohesive collection of callables and host tables a third party
ships as a single unit:
type Bundle interface {
Filters() []*Filter
Functions() []*Function
Tests() []*Test
Constants() map[string]runtime.Value
Enums() map[string][]runtime.Value
}
A bundle implements only the families it provides. BaseExtension supplies a
nil-returning implementation of every method, so a bundle embeds it and overrides
just the ones it ships:
type MathExt struct{ ext.BaseExtension }
func (MathExt) Functions() []*ext.Function {
return []*ext.Function{
ext.NewFunction("clamp", func(x, lo, hi int64) int64 {
switch {
case x < lo:
return lo
case x > hi:
return hi
default:
return x
}
}),
}
}
func (MathExt) Filters() []*ext.Filter {
return []*ext.Filter{ext.NewFilter("times", func(x, n int64) int64 { return x * n })}
}
Set.Register(bundle) folds a bundle in: each filter, function, and test
is added by name (later wins), and every constant and enumeration is merged into
the set's host tables. Register returns the receiver so calls chain.
5. Composition and shadow order¶
Two primitives compose registries, both following the uniform later wins rule:
Set.Merge(other)folds another set's callables and tables into the receiver, withothershadowing the receiver on every name collision. A nilotheris a no-op.Set.Register(bundle)folds a bundle in with the same rule.
When you build an Environment, the registry is layered bottom-up:
- the core standard library is the floor;
- the engine-bound composition callables (the include/block family);
- each host layer supplied via
WithExtensionsandWithExtension, in the order the options were passed.
So a later host layer shadows an earlier one, and every host layer shadows core.
A host can override any built-in without editing the engine. WithExtensions
takes one or more *Set layers; WithExtension takes one or more
Bundle values; multiple options accumulate, and sets and bundles interleave
in option order.
env := quill.New(ldr,
quill.WithExtensions(baseSet), // over core
quill.WithExtension(MathExt{}), // over baseSet
quill.WithExtensions(overrides), // over MathExt
)
6. Injection flags¶
A filter or function may need an engine value the template author never passes.
The three flags on Filter and Function request them:
NeedsEnvironment: a handle back into the engine, so the callable can load, render, or read the source of another template.NeedsContext: the live call-site scope, materialized as an*Arrayof the variables visible where the callable was called.NeedsCharset: the active charset.
When a flag is set, the interpreter prepends the requested value(s) into
args ahead of the piped value and the user arguments, in the fixed order
environment, context, charset. These injected values are separate from the
ctx context.Context first parameter, which is always present regardless of the
flags: a callable that sets NeedsContext receives the template-scope *Array
as the first entry of args, then the piped value, then the explicit arguments,
while ctx still carries the Go cancellation context. Set only the flags you
use; the built-in include/dump/source family sets them, and they are
available to host callables for the same reasons.
The typed helpers do not set these flags; a callable that needs injection uses
the struct form (or sets the flags on the struct the helper returns) and reads
the prepended values off the front of args.
7. Custom callables and the sandbox¶
When the sandbox is active, a host callable is gated exactly like a built-in: there is no grandfathering. A filter or function is denied unless the policy allowlists it by name.
pol := sandbox.NewPolicy(
sandbox.AllowFilters("times"), // allow the custom filter
)
env := quill.New(ldr,
quill.WithExtensions(set),
quill.WithSandboxPolicy(pol),
quill.WithSandboxActive(true),
)
A custom filter the policy allowlists (here, times, via AllowFilters) passes;
one that is not raises a host-catchable *errors.Security naming the offending
filter. Any host object a
custom callable exposes is gated by the same per-type method/property rules and
the type-graph as every other host value (see
Escaping & Safety).
8. Custom callables and the type checker¶
The gradual type checker runs at template load. A host callable with no
registered signature types as any: an annotated template that calls it loads
and renders without a type error, and the call's result flows on as any (the
dynamic floor host calls already use).
To have the checker verify a call against a custom callable, register its
signature in a check.Registry and install it with quill.WithTypes:
reg := check.NewRegistry()
reg.AddSignature("clamp", check.NewSignature(
[]*check.Type{check.Int, check.Int, check.Int}, // params
0, false, nil, // optional count, variadic, variadic elem
check.Int, // return type
))
env := quill.New(ldr, quill.WithExtensions(set), quill.WithTypes(reg))
With the signature registered, a call to clamp with the wrong arity or an
argument of the wrong type is rejected at load time, before any byte is rendered.
Registering a signature never changes runtime behavior; it only moves an error
earlier in time, so a template renders identical bytes with or without the
registry.
9. A complete example¶
The runnable examples/extension
registers a custom filter and function with the typed helpers, layers them over core with
WithExtensions, and renders a template that uses both:
func callables() *ext.Set {
set := ext.NewSet()
set.AddFilter(ext.NewFilter("repeat", func(s string, n int64) string {
return strings.Repeat(s, int(n))
}))
set.AddFunction(ext.NewFunction("clamp", func(x, lo, hi int64) int64 {
switch {
case x < lo:
return lo
case x > hi:
return hi
default:
return x
}
}))
return set
}
env := quill.NewFromMap(
map[string]string{"demo.quill": `{{ "ab" | repeat(3) }} {{ clamp(42, 0, 10) }}`},
quill.WithExtensions(callables()),
)
out, _ := env.Render(context.Background(), "demo.quill", nil) // ababab 10
Run it with go run ./examples/extension.
10. Composable loaders¶
A loader.Loader resolves a template name to its source. Beyond the in-memory
ArrayLoader and the directory-rooted FilesystemLoader, four composable
loaders let a host assemble the exact resolution strategy it needs. Each
satisfies loader.Loader fully (a Get(name) returning the source or a
not-found error, and a cheap Exists(name) probe), so any of them, in any
combination, plugs into quill.New.
ChainLoader¶
loader.NewChainLoader(loaders ...Loader) consults its loaders in order and
serves the first hit. An earlier loader shadows a later one for the same name,
which is the base-plus-override pattern: ship defaults in the last loader and let
earlier loaders replace individual templates. A name absent from every loader is
reported not-found; a non-not-found error from any loader stops the chain and
propagates, so a genuine I/O failure is never masked by a later miss.
env := quill.New(loader.NewChainLoader(
loader.NewFilesystemLoader("overrides"), // project overrides win
loader.NewFilesystemLoader("defaults"), // shipped defaults
))
PrefixLoader¶
loader.NewPrefixLoader(routes map[string]Loader) routes a name by its leading
prefix to a sub-loader, stripping the prefix (and the / delimiter after it)
before delegating. It composes several independently rooted loaders into one
namespace: lang/header reaches the loader registered for lang as plain
header. Routes match longest-prefix first, so lang/de wins over lang for a
name both could serve. The returned source keeps the fully-qualified name, so a
diagnostic points at the name the engine asked for. loader.NewPrefixLoaderDelim
takes an explicit delimiter for a non-slash scheme such as admin::page.
env := quill.New(loader.NewPrefixLoader(map[string]loader.Loader{
"lang": loader.NewFilesystemLoader("i18n"),
"mail": loader.NewFilesystemLoader("emails"),
}))
FSLoader¶
loader.NewFSLoader(fsys fs.FS, root ...string) serves templates from any
fs.FS, most usefully an embed.FS compiled into the binary so a program ships
its templates with no filesystem at runtime. An optional root scopes lookups to a
sub-tree; names are cleaned to a slash-separated, root-relative form so a ../
segment stays confined to the root.
//go:embed templates
var templatesFS embed.FS
env := quill.New(loader.NewFSLoader(templatesFS, "templates"))
FuncLoader¶
loader.NewFuncLoader(fn func(name string) (source string, ok bool, err error))
adapts a plain callback into a loader. The callback returns the template source,
a boolean reporting whether the name is known, and an error: a false ok with a
nil error becomes the not-found error, while a non-nil error propagates unchanged
so a database- or network-backed lookup can report an I/O failure distinctly from
a miss. It is the lightest way to source templates from a database, a config
object, or any lookup a host already owns.