1 Commits
Author SHA1 Message Date
LixenandClaude Fable 5.1 5f0e9f30a4 v0.5.0 macro: expand macros in a parsed document, moved from vif's pkg/tomlmacro
Test / test (push) Waiting to run
A macro is a [macros.<Name>] table with typed params, defaults, a doc line
and a body; any table with a "macro" key invokes one, as a value or spliced
into an array, once per zipped element under "each". Expansion runs on the
parsed tree between Parse and Decode, is bounded, and reads nothing but the
documents it is given. The package is the expander vif's scenarios, bot
graphs and audio banks already use, placed here so that a module outside
vif, the audio engine's coming spin-off first, can share it.


Claude-Session: https://claude.ai/code/session_01RP6NsvbSaLTt1UbBXHNfL4

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:23:23 -04:00
3 changed files with 914 additions and 2 deletions

No files matched your search

+62 -2
View File
@@ -1,7 +1,7 @@
# toml # toml
A deterministic TOML subset encoder, decoder and formatter using Go standard library. A deterministic TOML subset encoder, decoder and formatter using Go standard library,
Requires Go 1.27.1. with a macro expander for parsed documents (`macro`, below). Requires Go 1.27.1.
```go ```go
type Settings struct { type Settings struct {
@@ -171,6 +171,66 @@ would not parse to the same values is refused.
`Parse`, `Unmarshal` and `Decode` never format: they skip a comment without `Parse`, `Unmarshal` and `Decode` never format: they skip a comment without
reading its text. reading its text.
## Macros
```go
import "github.com/lixenwraith/toml/macro"
doc, err := toml.NewParser(data).Parse()
doc, err = macro.ExpandDocument(doc) // a document that defines its own macros
err = toml.Decode(doc, &target)
set := macro.NewSet() // one set shared by several documents
err = set.Define(library) // takes library's [macros]; a name defined twice is an error
doc, err = set.Expand(doc) // doc defines none; its invocations use the set
```
A macro is data in the document, expanded on the parsed tree between `Parse`
and `Decode`, so nothing downstream of decoding sees anything but plain tables.
A macro is a `[macros.<Name>]` table:
- `params`: parameter name to type: `int`, `float` (an integer is accepted),
`bool`, `string`, `table`, `array` or `any`; `macro` and `each` are reserved.
- `defaults`: values for optional parameters; every other parameter is required.
- `doc`: one line for authors.
- `body`: any value: a table, an array, a string or a number.
Inside a body, `${name}` names a parameter, in a string or in a key. A string
that is exactly one reference becomes the argument itself, of any type;
elsewhere the argument is spliced into the text and must be a string, an integer
or a boolean. `$$` is a literal `$`; any other `$` in a body is an error. Only
bodies are templates: an argument's own text is never scanned.
Any table with a `macro` key is an invocation; its other keys are arguments,
checked against the parameters' types. As a value it is replaced by the
expansion. As an array element, an array expansion is spliced into the array and
any other is one element; with `each`, a table of parameter = array, all of one
length, it expands once per zipped element. Bodies may invoke other macros and
arguments may be invocations: arguments are expanded where they are written,
then bound, then the body is substituted and expanded in turn.
```toml
[macros.Note]
doc = "One note: pos, deg, dur in steps, vel"
params = { pos = "int", deg = "int", dur = "int", vel = "float" }
defaults = { dur = 1, vel = 0.5 }
body = { pos = "${pos}", deg = "${deg}", dur = "${dur}", vel = "${vel}" }
[[track]]
instr = "lead"
event = [{ macro = "Note", each = { pos = [0, 4, 8], deg = [0, 2, 4] } }]
```
Definitions are checked when read: an unknown key, an unknown type, a default of
the wrong type and a `${name}` naming no parameter are errors before any use.
Expansion is a pure function of the documents given, so equal bytes expand to
equal trees. A macro reached again through its own expansion, nesting deeper
than 32, and more than 2^18 values produced through one set are errors, and an
error names the invocation path through every macro involved. `Expand` refuses
a document holding `[macros]` that `Define` has not taken, so a file meant only
to invoke cannot define. Arrays come back as `[]any`, whatever form the parser
gave them.
## Deliberate subset and deviations ## Deliberate subset and deviations
- Datetime syntax is unsupported. Use quoted strings and parse them in the - Datetime syntax is unsupported. Use quoted strings and parse them in the
+584
View File
@@ -0,0 +1,584 @@
// Package macro expands macros in a parsed TOML document. A macro is data
// under the document's [macros] table; any table carrying a "macro" key invokes
// one and is replaced by its body with the arguments substituted. Expansion reads
// nothing but the documents it is given, so equal bytes expand to equal trees on
// every peer. Syntax: the Macros section of the module README.
package macro
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
)
// Reserved keys; keyMacros is read at a document's root only.
const (
keyMacros = "macros"
keyInvoke = "macro"
keyEach = "each"
)
// Bounds a peer-supplied document cannot exceed: invocation nesting, and the
// values every expansion through one Set may produce together.
const (
maxDepth = 32
maxNodes = 1 << 18
)
var errDefinitions = errors.New("[macros] is not allowed in this document")
// Parameter types an argument is checked against. A float parameter takes an
// integer too, as the decoder does.
const (
typeAny = "any"
typeArray = "array"
typeBool = "bool"
typeFloat = "float"
typeInt = "int"
typeString = "string"
typeTable = "table"
)
var paramTypes = []string{typeAny, typeArray, typeBool, typeFloat, typeInt, typeString, typeTable}
type param struct {
typ string
def any
optional bool
}
type macro struct {
params map[string]param
body any
}
// Set holds the macros a group of documents may invoke.
type Set struct {
macros map[string]*macro
nodes int // values substitution has produced, across every Expand
}
func NewSet() *Set { return &Set{macros: make(map[string]*macro)} }
// ExpandDocument expands a document that defines its own macros.
func ExpandDocument(doc map[string]any) (map[string]any, error) {
s := NewSet()
if err := s.Define(doc); err != nil {
return nil, err
}
return s.Expand(doc)
}
// Define moves doc's [macros] into the set. A name defined twice is an error
// across documents too, so a library cannot silently shadow its user.
func (s *Set) Define(doc map[string]any) error {
raw, ok := doc[keyMacros]
if !ok {
return nil
}
delete(doc, keyMacros)
table, ok := raw.(map[string]any)
if !ok {
return errors.New("macros must be a table")
}
for _, name := range sortedKeys(table) {
if _, dup := s.macros[name]; dup {
return fmt.Errorf("macro %q is defined twice", name)
}
m, err := define(table[name])
if err != nil {
return fmt.Errorf("macro %q: %w", name, err)
}
s.macros[name] = m
}
return nil
}
func define(raw any) (*macro, error) {
def, ok := raw.(map[string]any)
if !ok {
return nil, errors.New("definition must be a table")
}
for _, key := range sortedKeys(def) {
if !slices.Contains([]string{"body", "defaults", "doc", "params"}, key) {
return nil, fmt.Errorf("unknown key %q", key)
}
}
if _, ok := def["doc"].(string); def["doc"] != nil && !ok {
return nil, errors.New("doc must be a string")
}
body, ok := def["body"]
if !ok {
return nil, errors.New("body is missing")
}
m := &macro{params: make(map[string]param), body: body}
params, ok := def["params"].(map[string]any)
if def["params"] != nil && !ok {
return nil, errors.New("params must be a table of name = type")
}
for _, name := range sortedKeys(params) {
typ, _ := params[name].(string)
switch {
case !validName(name) || name == keyInvoke || name == keyEach:
return nil, fmt.Errorf("parameter %q: not a usable name", name)
case !slices.Contains(paramTypes, typ):
return nil, fmt.Errorf("parameter %q: type must be one of %s", name, strings.Join(paramTypes, ", "))
}
m.params[name] = param{typ: typ}
}
defaults, ok := def["defaults"].(map[string]any)
if def["defaults"] != nil && !ok {
return nil, errors.New("defaults must be a table")
}
for _, name := range sortedKeys(defaults) {
p, ok := m.params[name]
if !ok {
return nil, fmt.Errorf("default for undeclared parameter %q", name)
}
if !typeHolds(p.typ, defaults[name]) {
return nil, fmt.Errorf("default for %q: want %s, got %s", name, p.typ, kindOf(defaults[name]))
}
p.def, p.optional = defaults[name], true
m.params[name] = p
}
// A reference to an undeclared parameter is a typo; catch it before any use
if err := m.checkReferences(body, "body"); err != nil {
return nil, err
}
return m, nil
}
func (m *macro) checkReferences(v any, path string) error {
check := func(s, path string) error {
segs, err := parseTemplate(s)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
for _, seg := range segs {
if _, ok := m.params[seg.text]; seg.ref && !ok {
return fmt.Errorf("%s: undeclared parameter %q", path, seg.text)
}
}
return nil
}
if s, ok := v.(string); ok {
return check(s, path)
}
if t, ok := v.(map[string]any); ok {
for _, k := range sortedKeys(t) {
if err := check(k, join(path, k)); err != nil {
return err
}
if err := m.checkReferences(t[k], join(path, k)); err != nil {
return err
}
}
}
list, _ := elements(v)
for i, el := range list {
if err := m.checkReferences(el, index(path, i)); err != nil {
return err
}
}
return nil
}
// Expand returns doc with every invocation replaced; doc itself is not modified.
// Arrays come back as []any, whatever form the parser gave them.
func (s *Set) Expand(doc map[string]any) (map[string]any, error) {
if _, ok := doc[keyMacros]; ok {
return nil, errDefinitions
}
e := &expander{set: s}
out, err := e.value(doc, "")
if err != nil {
return nil, err
}
table, ok := out.(map[string]any)
if !ok {
return nil, fmt.Errorf("document expands to a %s, not a table", kindOf(out))
}
return table, nil
}
type expander struct {
set *Set
stack []string // macros being expanded, outermost first
}
// value copies v, replacing invocations. In an array, an expansion that is
// itself an array is spliced in, and so is each result of an each.
func (e *expander) value(v any, path string) (any, error) {
if list, ok := elements(v); ok {
out := make([]any, 0, len(list))
for i, el := range list {
inv, ok := el.(map[string]any)
if _, invokes := inv[keyInvoke]; !ok || !invokes {
x, err := e.value(el, index(path, i))
if err != nil {
return nil, err
}
out = append(out, x)
continue
}
results, err := e.invoke(inv, index(path, i))
if err != nil {
return nil, err
}
for _, r := range results {
if spliced, ok := r.([]any); ok {
out = append(out, spliced...)
} else {
out = append(out, r)
}
}
}
return out, nil
}
t, ok := v.(map[string]any)
if !ok {
return v, nil
}
if _, ok := t[keyInvoke]; ok {
if _, each := t[keyEach]; each {
return nil, fmt.Errorf("%s: each expands only inside an array", at(path))
}
results, err := e.invoke(t, path)
if err != nil {
return nil, err
}
return results[0], nil
}
out := make(map[string]any, len(t))
for _, k := range sortedKeys(t) {
x, err := e.value(t[k], join(path, k))
if err != nil {
return nil, err
}
out[k] = x
}
return out, nil
}
// invoke binds one invocation's arguments and expands its body once, or once
// per element of the arrays its each table names, zipped.
func (e *expander) invoke(inv map[string]any, path string) ([]any, error) {
name, ok := inv[keyInvoke].(string)
if !ok {
return nil, fmt.Errorf("%s: macro name must be a string", at(path))
}
m, ok := e.set.macros[name]
switch {
case !ok:
return nil, fmt.Errorf("%s: unknown macro %q", at(path), name)
case slices.Contains(e.stack, name):
return nil, fmt.Errorf("%s: macro %q invokes itself through %s", at(path), name, strings.Join(e.stack, " → "))
case len(e.stack) >= maxDepth:
return nil, fmt.Errorf("%s: macros nest deeper than %d", at(path), maxDepth)
}
fail := func(err error) ([]any, error) { return nil, fmt.Errorf("%s: macro %q: %w", at(path), name, err) }
// Arguments are expanded where they are written, before binding
args := make(map[string]any, len(m.params))
for _, k := range sortedKeys(inv) {
if k == keyInvoke || k == keyEach {
continue
}
p, ok := m.params[k]
if !ok {
return fail(fmt.Errorf("unknown argument %q", k))
}
v, err := e.value(inv[k], join(path, k))
if err != nil {
return nil, err
}
if !typeHolds(p.typ, v) {
return fail(fmt.Errorf("argument %q: want %s, got %s", k, p.typ, kindOf(v)))
}
args[k] = v
}
each, count, err := e.eachArgs(inv, m, args, join(path, keyEach))
if err != nil {
return fail(err)
}
for _, k := range sortedKeys(m.params) {
_, given := args[k]
_, iterated := each[k]
switch p := m.params[k]; {
case given || iterated:
case p.optional:
args[k] = p.def
default:
return fail(fmt.Errorf("missing argument %q", k))
}
}
e.stack = append(e.stack, name)
defer func() { e.stack = e.stack[:len(e.stack)-1] }()
results := make([]any, 0, count)
for i := range count {
for _, k := range sortedKeys(each) {
args[k] = each[k][i]
}
body, err := e.subst(m.body, args, "body")
if err != nil {
return fail(err)
}
out, err := e.value(body, "body")
if err != nil {
return fail(err)
}
results = append(results, out)
}
return results, nil
}
// eachArgs reads an invocation's each table: parameter = array, all one length.
// Without one, the body expands once.
func (e *expander) eachArgs(inv map[string]any, m *macro, args map[string]any, path string) (map[string][]any, int, error) {
raw, ok := inv[keyEach]
if !ok {
return nil, 1, nil
}
expanded, err := e.value(raw, path)
if err != nil {
return nil, 0, err
}
table, ok := expanded.(map[string]any)
if !ok || len(table) == 0 {
return nil, 0, errors.New("each must be a table of parameter = array")
}
each, count := make(map[string][]any, len(table)), -1
for _, k := range sortedKeys(table) {
p, declared := m.params[k]
_, given := args[k]
list, isArray := table[k].([]any)
switch {
case !declared:
return nil, 0, fmt.Errorf("each: unknown argument %q", k)
case given:
return nil, 0, fmt.Errorf("each: argument %q is also given directly", k)
case !isArray:
return nil, 0, fmt.Errorf("each: argument %q must be an array", k)
case count >= 0 && len(list) != count:
return nil, 0, fmt.Errorf("each: argument %q has %d elements, the others %d", k, len(list), count)
}
for i, v := range list {
if !typeHolds(p.typ, v) {
return nil, 0, fmt.Errorf("each: argument %q[%d]: want %s, got %s", k, i, p.typ, kindOf(v))
}
}
each[k], count = list, len(list)
}
return each, count, nil
}
// subst copies v with ${name} replaced. A string that is one reference becomes
// the argument itself, of any type; elsewhere an argument is spliced into the
// text and must be a string, an integer or a boolean. Nil args copies v verbatim,
// which is how an argument is copied: its text is never a template.
func (e *expander) subst(v any, args map[string]any, path string) (any, error) {
if e.set.nodes++; e.set.nodes > maxNodes {
return nil, fmt.Errorf("expansion exceeds %d values", maxNodes)
}
template := func(s, path string) ([]segment, error) {
if args == nil {
return []segment{{text: s}}, nil
}
segs, err := parseTemplate(s)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return segs, nil
}
if list, ok := elements(v); ok {
out := make([]any, len(list))
for i, el := range list {
x, err := e.subst(el, args, index(path, i))
if err != nil {
return nil, err
}
out[i] = x
}
return out, nil
}
switch v := v.(type) {
case string:
segs, err := template(v, path)
if err != nil {
return nil, err
}
if len(segs) == 1 && segs[0].ref {
// Every use gets its own copy, so a consumer editing one cannot reach another
return e.subst(args[segs[0].text], nil, path)
}
return interpolate(segs, args, path)
case map[string]any:
out := make(map[string]any, len(v))
for _, k := range sortedKeys(v) {
segs, err := template(k, join(path, k))
if err != nil {
return nil, err
}
key, err := interpolate(segs, args, join(path, k))
if err != nil {
return nil, err
}
if _, dup := out[key]; dup {
return nil, fmt.Errorf("%s: key %q appears twice after substitution", join(path, k), key)
}
if out[key], err = e.subst(v[k], args, join(path, k)); err != nil {
return nil, err
}
}
return out, nil
default:
return v, nil
}
}
func interpolate(segs []segment, args map[string]any, path string) (string, error) {
var b strings.Builder
for _, seg := range segs {
if !seg.ref {
b.WriteString(seg.text)
continue
}
switch v := args[seg.text].(type) {
case string:
b.WriteString(v)
case int64:
b.WriteString(strconv.FormatInt(v, 10))
case bool:
b.WriteString(strconv.FormatBool(v))
default:
return "", fmt.Errorf("%s: %s argument %q cannot be part of a string", path, kindOf(v), seg.text)
}
}
return b.String(), nil
}
type segment struct {
text string
ref bool // text names a parameter
}
// parseTemplate splits s into literal text and ${name} references; $$ is a literal $.
func parseTemplate(s string) ([]segment, error) {
if !strings.Contains(s, "$") {
return []segment{{text: s}}, nil
}
var segs []segment
var lit strings.Builder
for i := 0; i < len(s); {
switch {
case s[i] != '$':
lit.WriteByte(s[i])
i++
case strings.HasPrefix(s[i:], "$$"):
lit.WriteByte('$')
i += 2
case strings.HasPrefix(s[i:], "${"):
end := strings.IndexByte(s[i:], '}')
if end < 0 {
return nil, errors.New("unterminated ${")
}
name := s[i+2 : i+end]
if !validName(name) {
return nil, fmt.Errorf("${%s} does not name a parameter", name)
}
if lit.Len() > 0 {
segs = append(segs, segment{text: lit.String()})
lit.Reset()
}
segs = append(segs, segment{text: name, ref: true})
i += end + 1
default:
return nil, errors.New("a literal $ is written $$")
}
}
if lit.Len() > 0 || len(segs) == 0 {
segs = append(segs, segment{text: lit.String()})
}
return segs, nil
}
func validName(s string) bool {
for i, c := range s {
letter := c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
if !letter && (i == 0 || c < '0' || c > '9') {
return false
}
}
return s != ""
}
func typeHolds(typ string, v any) bool {
k := kindOf(v)
return typ == typeAny || typ == k || (typ == typeFloat && k == typeInt)
}
func kindOf(v any) string {
switch v.(type) {
case int64:
return typeInt
case float64:
return typeFloat
case bool:
return typeBool
case string:
return typeString
case map[string]any:
return typeTable
case []any, []map[string]any:
return typeArray
default:
return fmt.Sprintf("%T", v)
}
}
// elements views either array form the parser produces as []any.
func elements(v any) ([]any, bool) {
switch v := v.(type) {
case []any:
return v, true
case []map[string]any:
out := make([]any, len(v))
for i, t := range v {
out[i] = t
}
return out, true
default:
return nil, false
}
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
return keys
}
func join(path, key string) string {
if path == "" {
return key
}
return path + "." + key
}
func index(path string, i int) string { return fmt.Sprintf("%s[%d]", path, i) }
func at(path string) string {
if path == "" {
return "document"
}
return path
}
+268
View File
@@ -0,0 +1,268 @@
package macro
import (
"reflect"
"strconv"
"strings"
"testing"
"github.com/lixenwraith/toml"
)
func parse(t testing.TB, src string) map[string]any {
t.Helper()
doc, err := toml.NewParser([]byte(src)).Parse()
if err != nil {
t.Fatalf("parse: %v", err)
}
return doc
}
func expand(t *testing.T, src string) map[string]any {
t.Helper()
out, err := ExpandDocument(parse(t, src))
if err != nil {
t.Fatalf("expand: %v", err)
}
return out
}
func equal(t *testing.T, got map[string]any, want string) {
t.Helper()
if w := parse(t, want); !reflect.DeepEqual(normalize(got), normalize(w)) {
t.Fatalf("expansion\n got %v\nwant %v", got, w)
}
}
// normalize folds the parser's two array forms into one so trees compare by value.
func normalize(v any) any {
if list, ok := elements(v); ok {
out := make([]any, len(list))
for i, el := range list {
out[i] = normalize(el)
}
return out
}
if t, ok := v.(map[string]any); ok {
out := make(map[string]any, len(t))
for k, x := range t {
out[k] = normalize(x)
}
return out
}
return v
}
// A whole-string reference keeps the argument's type; a reference inside a string
// or a key is spliced as text; an omitted argument takes its default.
func TestInvocationTakesTheBodyWithArgumentsSubstituted(t *testing.T) {
got := expand(t, `
[macros.Spawn]
params = { id = "string", x = "int", hp = "float", at = "table" }
defaults = { hp = 2.5 }
body = { name = "Td${id}", x = "${x}", hp = "${hp}", pos = "${at}", "${id}_anchor" = "$$${x}" }
[states]
A = { macro = "Spawn", id = "Q1", x = 125, at = { y = 62 } }
`)
equal(t, got, `
[states.A]
name = "TdQ1"
x = 125
hp = 2.5
pos = { y = 62 }
Q1_anchor = "$125"
`)
}
// An expansion that is an array splices into the array holding the invocation;
// each expands once per zipped element, beside the arguments shared by all.
func TestArrayInvocationsSplice(t *testing.T) {
got := expand(t, `
[macros.Announce]
params = { message = "string" }
body = [{ strobe = true }, { message = "${message}" }]
[macros.Zero]
params = { name = "string", value = "int" }
defaults = { value = 0 }
body = { set = "${name}", value = "${value}" }
[states.A]
on_enter = [
{ first = true },
{ macro = "Announce", message = "GO" },
{ macro = "Zero", each = { name = ["a", "b"], value = [1, 2] } },
{ macro = "Zero", name = "c" },
]
`)
equal(t, got, `
[states.A]
on_enter = [
{ first = true },
{ strobe = true }, { message = "GO" },
{ set = "a", value = 1 }, { set = "b", value = 2 },
{ set = "c", value = 0 },
]
`)
}
// Bodies invoke other macros, arguments are expanded where they are written, and
// argument text is never read as a template, so a $ an author passes stays one.
func TestNestedInvocationsExpandAndArgumentsStayInert(t *testing.T) {
got := expand(t, `
[macros.Emit]
params = { event = "string", payload = "table" }
defaults = { payload = {} }
body = { action = "EmitEvent", event = "${event}", payload = "${payload}" }
[macros.Say]
params = { text = "string" }
body = { macro = "Emit", event = "Message", payload = { message = "${text}" } }
[macros.Both]
params = { first = "array", text = "string" }
body = { list = "${first}", say = { macro = "Say", text = "${text}" } }
[states]
A = { macro = "Both", first = [{ macro = "Emit", event = "Clear" }], text = "costs ${x} $" }
`)
equal(t, got, `
[states.A]
list = [{ action = "EmitEvent", event = "Clear", payload = {} }]
say = { action = "EmitEvent", event = "Message", payload = { message = "costs ${x} $" } }
`)
}
func TestMalformedMacrosAreRejected(t *testing.T) {
cases := []struct{ name, src, want string }{
{"unknown macro", `a = { macro = "Nope" }`, `unknown macro "Nope"`},
{"unknown argument", `
[macros.M]
body = 1
[x]
a = { macro = "M", y = 1 }`, `unknown argument "y"`},
{"missing argument", `
[macros.M]
params = { y = "int" }
body = "${y}"
[x]
a = { macro = "M" }`, `missing argument "y"`},
{"argument type", `
[macros.M]
params = { y = "int" }
body = "${y}"
[x]
a = { macro = "M", y = "one" }`, `argument "y": want int, got string`},
{"undeclared reference", `
[macros.M]
body = { a = "${y}" }`, `body.a: undeclared parameter "y"`},
{"lone dollar", `
[macros.M]
body = "5 $"`, `a literal $ is written $$`},
{"recursion", `
[macros.A]
body = { macro = "B" }
[macros.B]
body = [{ macro = "A" }]
[x]
a = { macro = "A" }`, `macro "A" invokes itself through A → B`},
{"each outside an array", `
[macros.M]
params = { y = "int" }
body = "${y}"
[x]
a = { macro = "M", each = { y = [1, 2] } }`, `each expands only inside an array`},
{"each lengths", `
[macros.M]
params = { y = "int", z = "int" }
body = "${y}"
[x]
a = [{ macro = "M", each = { y = [1, 2], z = [1] } }]`, `has 1 elements, the others 2`},
{"table spliced into text", `
[macros.M]
params = { y = "table" }
body = "at ${y}"
[x]
a = { macro = "M", y = {} }`, `table argument "y" cannot be part of a string`},
{"keys collide", `
[macros.M]
params = { y = "string" }
body = { "${y}" = 1, b = 2 }
[x]
a = { macro = "M", y = "b" }`, `key "b" appears twice`},
{"default type", `
[macros.M]
params = { y = "int" }
defaults = { y = "one" }
body = 1`, `default for "y": want int, got string`},
{"reserved parameter", `
[macros.M]
params = { each = "int" }
body = 1`, `parameter "each": not a usable name`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ExpandDocument(parse(t, c.src))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("got %v, want an error containing %q", err, c.want)
}
})
}
}
// A document invoking from another document's set may not define its own, and a
// name defined in both is refused rather than shadowed.
func TestDefinitionsBelongToTheSet(t *testing.T) {
s := NewSet()
if err := s.Define(parse(t, "[macros.M]\nbody = 1\n")); err != nil {
t.Fatal(err)
}
if err := s.Define(parse(t, "[macros.M]\nbody = 2\n")); err == nil {
t.Fatal("a second definition of M was accepted")
}
if _, err := s.Expand(parse(t, "[macros.N]\nbody = 1\n")); err == nil {
t.Fatal("a document that only invokes defined a macro")
}
out, err := s.Expand(parse(t, "a = { macro = \"M\" }\n"))
if err != nil || out["a"] != int64(1) {
t.Fatalf("got %v, %v", out, err)
}
}
// Doubling bodies grow exponentially in the invocation depth; the set's budget
// stops them long before memory does, and nesting has its own ceiling.
func TestExpansionIsBounded(t *testing.T) {
var b strings.Builder
b.WriteString("[macros.M0]\nbody = [1, 2, 3, 4, 5, 6, 7, 8]\n")
for i := 1; i <= 24; i++ {
b.WriteString("[macros.M" + strconv.Itoa(i) + "]\nbody = [{ macro = \"M" + strconv.Itoa(i-1) + "\" }, { macro = \"M" + strconv.Itoa(i-1) + "\" }]\n")
}
b.WriteString("[x]\na = { macro = \"M24\" }\n")
if _, err := ExpandDocument(parse(t, b.String())); err == nil || !strings.Contains(err.Error(), "expansion exceeds") {
t.Fatalf("doubling to 2^24 copies: %v", err)
}
b.Reset()
for i := range maxDepth + 1 {
b.WriteString("[macros.N" + strconv.Itoa(i) + "]\nbody = { macro = \"N" + strconv.Itoa(i+1) + "\" }\n")
}
b.WriteString("[macros.N" + strconv.Itoa(maxDepth+1) + "]\nbody = 1\n[x]\na = { macro = \"N0\" }\n")
if _, err := ExpandDocument(parse(t, b.String())); err == nil || !strings.Contains(err.Error(), "nest deeper") {
t.Fatalf("a chain of %d macros: %v", maxDepth+2, err)
}
}
// Scenarios arrive from peers: any parsed document either expands or is refused.
func FuzzExpandDocument(f *testing.F) {
f.Add("[macros.M]\nparams = { y = \"int\" }\nbody = [\"${y}\"]\n[x]\na = [{ macro = \"M\", each = { y = [1, 2] } }]\n")
f.Add("[macros.M]\nbody = { \"${y}\" = 1 }\n")
f.Add("a = { macro = \"M\" }\n")
f.Fuzz(func(t *testing.T, src string) {
doc, err := toml.NewParser([]byte(src)).Parse()
if err != nil {
return
}
_, _ = ExpandDocument(doc)
})
}