2 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
Lixen e5d5567beb v0.4.0 Unmapped reads any parsed value, UnknownKeys refuses what it names, decode errors name the key path
Test / test (push) Canceled after 0s
2026-10-26 03:33:39 -04:00
7 changed files with 995 additions and 19 deletions

No files matched your search

+68 -4
View File
@@ -1,7 +1,7 @@
# toml
A deterministic TOML subset encoder, decoder and formatter using Go standard library.
Requires Go 1.27.1.
A deterministic TOML subset encoder, decoder and formatter using Go standard library,
with a macro expander for parsed documents (`macro`, below). Requires Go 1.27.1.
```go
type Settings struct {
@@ -20,7 +20,10 @@ replace old containers. Existing pointer defaults are retained without writing
through the original pointer. Fixed arrays require an exact input length.
Tags use `toml:"name,omitempty"`; an empty tag name
falls back to the Go field name. Matching is case sensitive. Unknown input keys
and unexported fields are ignored.
and unexported fields are ignored; `Unmapped(data, &target)` names the keys a
parsed value holds that no field takes, and a caller refusing them returns
`UnknownKeys(keys)`, which quotes each. A decode error names where it failed by
the key path the document spells: `rooms[1].width: cannot convert string to int`.
## Values and conversions
@@ -121,7 +124,8 @@ split at the widest gap.
- `Unmapped` names the keys no field of the destination takes, matched as `Decode`
matches them (tags, then field names; unexported and `-` fields take nothing),
each once and sorted. A map or interface destination takes every key under it;
a key under an array of tables is named without its index.
a key under an array of tables is named without its index. The function of the
same name reads any value `Parse` produced, a table inside one included.
- `Key` is a dotted path, one segment per part; `Key.String` spells it as a
document would, quoting segments that need it.
@@ -167,6 +171,66 @@ would not parse to the same values is refused.
`Parse`, `Unmarshal` and `Decode` never format: they skip a comment without
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
- Datetime syntax is unsupported. Use quoted strings and parse them in the
+23
View File
@@ -303,6 +303,29 @@ func TestDecodeErrorPathIsLinear(t *testing.T) {
}
}
// A decode error names where it failed by the key path the document spells
func TestDecodeErrorNamesItsKeyPath(t *testing.T) {
type room struct {
Width int `toml:"width"`
}
var v struct {
Play struct{ Sound bool } `toml:"play"`
Rooms []room `toml:"rooms"`
ByKey map[string]room `toml:"by_key"`
}
for _, tt := range []struct {
doc, want string
}{
{"play.Sound = 1", "play.Sound: cannot convert int64 to bool"},
{"rooms = [{ width = 1 }, { width = \"x\" }]", "rooms[1].width: cannot convert string to int"},
{"by_key.\"a b\".width = true", `by_key."a b".width: cannot convert bool to int`},
} {
if err := Unmarshal([]byte(tt.doc+"\n"), &v); err == nil || err.Error() != tt.want {
t.Errorf("%s: error %v, want %s", tt.doc, err, tt.want)
}
}
}
// Header paths are bounded: deep nesting is written inline past the bound,
// so output stays linear and still round-trips
func TestMarshal_HeaderLengthBounded(t *testing.T) {
+21 -10
View File
@@ -42,19 +42,23 @@ func decodeValue(data any, val reflect.Value) error {
return decodeAt(data, val, 0)
}
// pathError names where decoding failed. Each level adds its segment instead
// of wrapping the message, which would copy the message once per level.
// pathError names where decoding failed as the key path a document spells,
// play.sound or rooms[0].width. Each level adds its segment instead of wrapping
// the message, which would copy the message once per level.
type pathError struct {
path []string // innermost first
path []string // innermost first: a key as keySegment spells it, an index as [n]
err error
}
func (e *pathError) Error() string {
var b strings.Builder
for i := len(e.path) - 1; i >= 0; i-- {
b.WriteString(e.path[i])
b.WriteString(": ")
if i < len(e.path)-1 && e.path[i][0] != '[' {
b.WriteByte('.')
}
b.WriteString(e.path[i])
}
b.WriteString(": ")
b.WriteString(e.err.Error())
return b.String()
}
@@ -69,6 +73,14 @@ func atPath(segment string, err error) error {
return &pathError{path: []string{segment}, err: err}
}
// keySegment spells a key in an error path: bare when it can be, else quoted and cut
func keySegment(k string) string {
if len(k) <= 32 && isBareKey(k) {
return k
}
return brief(k)
}
func decodeAt(data any, val reflect.Value, depth int) error {
if depth > maxValueDepth {
return fmt.Errorf("decode nesting exceeds %d", maxValueDepth)
@@ -120,7 +132,7 @@ func decodeAt(data any, val reflect.Value, depth int) error {
}
for i := 0; i < len(dataSlice); i++ {
if err := decodeAt(dataSlice[i], newSlice.Index(i), depth+1); err != nil {
return atPath(fmt.Sprintf("index %d", i), err)
return atPath(fmt.Sprintf("[%d]", i), err)
}
}
val.Set(newSlice)
@@ -140,7 +152,7 @@ func decodeAt(data any, val reflect.Value, depth int) error {
for k, vData := range dataMap {
elem.SetZero()
if err := decodeAt(vData, elem, depth+1); err != nil {
return atPath("map key "+brief(k), err)
return atPath(keySegment(k), err)
}
key.SetString(k)
newMap.SetMapIndex(key, elem)
@@ -216,7 +228,7 @@ func decodeStruct(data map[string]any, val reflect.Value, depth int) error {
// Look up in data map (case sensitive)
if vData, ok := data[f.key]; ok {
if err := decodeAt(vData, val.Field(f.index), depth+1); err != nil {
return atPath(val.Type().Name()+"."+f.name, err)
return atPath(keySegment(f.key), err)
}
}
}
@@ -227,7 +239,6 @@ func decodeStruct(data map[string]any, val reflect.Value, depth int) error {
type field struct {
index int
key string // the tag's name, else the Go name
name string // the Go name
omitempty bool
}
@@ -246,7 +257,7 @@ func fieldsOf(t reflect.Type) []field {
if !f.IsExported() || name == "-" {
continue
}
x := field{index: i, key: cmp.Or(name, f.Name), name: f.Name}
x := field{index: i, key: cmp.Or(name, f.Name)}
for o := range strings.SplitSeq(opts, ",") {
x.omitempty = x.omitempty || o == "omitempty"
}
+24 -5
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"reflect"
"slices"
"strconv"
"strings"
)
@@ -253,12 +254,30 @@ func (d *Document) Tree() map[string]any { return d.tree }
func (d *Document) Decode(v any) error { return Decode(d.tree, v) }
// Unmapped names the keys of the document that no field of v's type takes, as
// Decode, which ignores them, would match them; each once and sorted. A key under
// a map or interface destination is taken, and one under an array of tables is
// named without its index.
func (d *Document) Unmapped(v any) []Key {
// the function Unmapped does
func (d *Document) Unmapped(v any) []Key { return Unmapped(d.tree, v) }
// UnknownKeys is the error of a caller that refuses the keys Unmapped names
type UnknownKeys []Key
func (u UnknownKeys) Error() string {
q := make([]string, len(u))
for i, k := range u {
q[i] = strconv.Quote(k.String())
}
if len(q) == 1 {
return "unknown key " + q[0]
}
return "unknown keys " + strings.Join(q, ", ")
}
// Unmapped names the keys of data, a value Parse produced, that no field of v's
// type takes, as Decode, which ignores them, would match them; each once and
// sorted. A key under a map or interface destination is taken, and one under an
// array of tables is named without its index.
func Unmapped(data any, v any) []Key {
seen := make(map[string]Key)
unmapped(d.tree, reflect.TypeOf(v), nil, seen, 0)
unmapped(data, reflect.TypeOf(v), nil, seen, 0)
out := make([]Key, 0, len(seen))
for _, k := range seen {
out = append(out, k)
+7
View File
@@ -195,6 +195,7 @@ func TestDocumentRefusesAnEditItCannotPlace(t *testing.T) {
// TestUnmappedNamesWhatDecodeIgnores: keys no field takes, by tag or field name,
// unexported and "-" fields included, are named once each, under arrays of tables
// without their index; a map or interface destination takes every key under it.
// A parsed tree is read as its document is, and UnknownKeys quotes each.
func TestUnmappedNamesWhatDecodeIgnores(t *testing.T) {
doc := `top = 1
skip = 2
@@ -248,6 +249,12 @@ nope = 1
if !slices.Equal(got, want) {
t.Fatalf("unmapped %q, want %q", got, want)
}
if tree := Unmapped(d.Tree(), &v); !slices.EqualFunc(tree, d.Unmapped(&v), slices.Equal) {
t.Fatalf("the tree's unmapped %q differ from the document's", tree)
}
if err := UnknownKeys(d.Unmapped(&v)[:2]).Error(); err != `unknown keys "byname.one.nope", "hidden"` {
t.Fatalf("error %s", err)
}
}
// TestKeyStringQuotesWhatABareKeyCannotHold
+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)
})
}