Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f0e9f30a4 |
No files matched your search
@@ -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.<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
|
||||
|
||||
+584
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user