diff --git a/README.md b/README.md index 7ba87b7..e3e03ec 100644 --- a/README.md +++ b/README.md @@ -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 { @@ -171,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.]` 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 diff --git a/macro/macro.go b/macro/macro.go new file mode 100644 index 0000000..9fc22b0 --- /dev/null +++ b/macro/macro.go @@ -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 := ¯o{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 +} diff --git a/macro/macro_test.go b/macro/macro_test.go new file mode 100644 index 0000000..5fb0e6c --- /dev/null +++ b/macro/macro_test.go @@ -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) + }) +}