Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f0e9f30a4 | ||
|
|
e5d5567beb
|
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
# toml
|
# toml
|
||||||
|
|
||||||
A deterministic TOML subset encoder, decoder and formatter using Go standard library.
|
A deterministic TOML subset encoder, decoder and formatter using Go standard library,
|
||||||
Requires Go 1.27.1.
|
with a macro expander for parsed documents (`macro`, below). Requires Go 1.27.1.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
@@ -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.
|
through the original pointer. Fixed arrays require an exact input length.
|
||||||
Tags use `toml:"name,omitempty"`; an empty tag name
|
Tags use `toml:"name,omitempty"`; an empty tag name
|
||||||
falls back to the Go field name. Matching is case sensitive. Unknown input keys
|
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
|
## 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`
|
- `Unmapped` names the keys no field of the destination takes, matched as `Decode`
|
||||||
matches them (tags, then field names; unexported and `-` fields take nothing),
|
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;
|
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
|
- `Key` is a dotted path, one segment per part; `Key.String` spells it as a
|
||||||
document would, quoting segments that need it.
|
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
|
`Parse`, `Unmarshal` and `Decode` never format: they skip a comment without
|
||||||
reading its text.
|
reading its text.
|
||||||
|
|
||||||
|
## Macros
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/lixenwraith/toml/macro"
|
||||||
|
|
||||||
|
doc, err := toml.NewParser(data).Parse()
|
||||||
|
doc, err = macro.ExpandDocument(doc) // a document that defines its own macros
|
||||||
|
err = toml.Decode(doc, &target)
|
||||||
|
|
||||||
|
set := macro.NewSet() // one set shared by several documents
|
||||||
|
err = set.Define(library) // takes library's [macros]; a name defined twice is an error
|
||||||
|
doc, err = set.Expand(doc) // doc defines none; its invocations use the set
|
||||||
|
```
|
||||||
|
|
||||||
|
A macro is data in the document, expanded on the parsed tree between `Parse`
|
||||||
|
and `Decode`, so nothing downstream of decoding sees anything but plain tables.
|
||||||
|
A macro is a `[macros.<Name>]` table:
|
||||||
|
|
||||||
|
- `params`: parameter name to type: `int`, `float` (an integer is accepted),
|
||||||
|
`bool`, `string`, `table`, `array` or `any`; `macro` and `each` are reserved.
|
||||||
|
- `defaults`: values for optional parameters; every other parameter is required.
|
||||||
|
- `doc`: one line for authors.
|
||||||
|
- `body`: any value: a table, an array, a string or a number.
|
||||||
|
|
||||||
|
Inside a body, `${name}` names a parameter, in a string or in a key. A string
|
||||||
|
that is exactly one reference becomes the argument itself, of any type;
|
||||||
|
elsewhere the argument is spliced into the text and must be a string, an integer
|
||||||
|
or a boolean. `$$` is a literal `$`; any other `$` in a body is an error. Only
|
||||||
|
bodies are templates: an argument's own text is never scanned.
|
||||||
|
|
||||||
|
Any table with a `macro` key is an invocation; its other keys are arguments,
|
||||||
|
checked against the parameters' types. As a value it is replaced by the
|
||||||
|
expansion. As an array element, an array expansion is spliced into the array and
|
||||||
|
any other is one element; with `each`, a table of parameter = array, all of one
|
||||||
|
length, it expands once per zipped element. Bodies may invoke other macros and
|
||||||
|
arguments may be invocations: arguments are expanded where they are written,
|
||||||
|
then bound, then the body is substituted and expanded in turn.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[macros.Note]
|
||||||
|
doc = "One note: pos, deg, dur in steps, vel"
|
||||||
|
params = { pos = "int", deg = "int", dur = "int", vel = "float" }
|
||||||
|
defaults = { dur = 1, vel = 0.5 }
|
||||||
|
body = { pos = "${pos}", deg = "${deg}", dur = "${dur}", vel = "${vel}" }
|
||||||
|
|
||||||
|
[[track]]
|
||||||
|
instr = "lead"
|
||||||
|
event = [{ macro = "Note", each = { pos = [0, 4, 8], deg = [0, 2, 4] } }]
|
||||||
|
```
|
||||||
|
|
||||||
|
Definitions are checked when read: an unknown key, an unknown type, a default of
|
||||||
|
the wrong type and a `${name}` naming no parameter are errors before any use.
|
||||||
|
Expansion is a pure function of the documents given, so equal bytes expand to
|
||||||
|
equal trees. A macro reached again through its own expansion, nesting deeper
|
||||||
|
than 32, and more than 2^18 values produced through one set are errors, and an
|
||||||
|
error names the invocation path through every macro involved. `Expand` refuses
|
||||||
|
a document holding `[macros]` that `Define` has not taken, so a file meant only
|
||||||
|
to invoke cannot define. Arrays come back as `[]any`, whatever form the parser
|
||||||
|
gave them.
|
||||||
|
|
||||||
## Deliberate subset and deviations
|
## Deliberate subset and deviations
|
||||||
|
|
||||||
- Datetime syntax is unsupported. Use quoted strings and parse them in the
|
- Datetime syntax is unsupported. Use quoted strings and parse them in the
|
||||||
|
|||||||
@@ -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,
|
// Header paths are bounded: deep nesting is written inline past the bound,
|
||||||
// so output stays linear and still round-trips
|
// so output stays linear and still round-trips
|
||||||
func TestMarshal_HeaderLengthBounded(t *testing.T) {
|
func TestMarshal_HeaderLengthBounded(t *testing.T) {
|
||||||
|
|||||||
@@ -42,19 +42,23 @@ func decodeValue(data any, val reflect.Value) error {
|
|||||||
return decodeAt(data, val, 0)
|
return decodeAt(data, val, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathError names where decoding failed. Each level adds its segment instead
|
// pathError names where decoding failed as the key path a document spells,
|
||||||
// of wrapping the message, which would copy the message once per level.
|
// 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 {
|
type pathError struct {
|
||||||
path []string // innermost first
|
path []string // innermost first: a key as keySegment spells it, an index as [n]
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *pathError) Error() string {
|
func (e *pathError) Error() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for i := len(e.path) - 1; i >= 0; i-- {
|
for i := len(e.path) - 1; i >= 0; i-- {
|
||||||
b.WriteString(e.path[i])
|
if i < len(e.path)-1 && e.path[i][0] != '[' {
|
||||||
b.WriteString(": ")
|
b.WriteByte('.')
|
||||||
}
|
}
|
||||||
|
b.WriteString(e.path[i])
|
||||||
|
}
|
||||||
|
b.WriteString(": ")
|
||||||
b.WriteString(e.err.Error())
|
b.WriteString(e.err.Error())
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,14 @@ func atPath(segment string, err error) error {
|
|||||||
return &pathError{path: []string{segment}, err: err}
|
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 {
|
func decodeAt(data any, val reflect.Value, depth int) error {
|
||||||
if depth > maxValueDepth {
|
if depth > maxValueDepth {
|
||||||
return fmt.Errorf("decode nesting exceeds %d", 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++ {
|
for i := 0; i < len(dataSlice); i++ {
|
||||||
if err := decodeAt(dataSlice[i], newSlice.Index(i), depth+1); err != nil {
|
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)
|
val.Set(newSlice)
|
||||||
@@ -140,7 +152,7 @@ func decodeAt(data any, val reflect.Value, depth int) error {
|
|||||||
for k, vData := range dataMap {
|
for k, vData := range dataMap {
|
||||||
elem.SetZero()
|
elem.SetZero()
|
||||||
if err := decodeAt(vData, elem, depth+1); err != nil {
|
if err := decodeAt(vData, elem, depth+1); err != nil {
|
||||||
return atPath("map key "+brief(k), err)
|
return atPath(keySegment(k), err)
|
||||||
}
|
}
|
||||||
key.SetString(k)
|
key.SetString(k)
|
||||||
newMap.SetMapIndex(key, elem)
|
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)
|
// Look up in data map (case sensitive)
|
||||||
if vData, ok := data[f.key]; ok {
|
if vData, ok := data[f.key]; ok {
|
||||||
if err := decodeAt(vData, val.Field(f.index), depth+1); err != nil {
|
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 {
|
type field struct {
|
||||||
index int
|
index int
|
||||||
key string // the tag's name, else the Go name
|
key string // the tag's name, else the Go name
|
||||||
name string // the Go name
|
|
||||||
omitempty bool
|
omitempty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +257,7 @@ func fieldsOf(t reflect.Type) []field {
|
|||||||
if !f.IsExported() || name == "-" {
|
if !f.IsExported() || name == "-" {
|
||||||
continue
|
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, ",") {
|
for o := range strings.SplitSeq(opts, ",") {
|
||||||
x.omitempty = x.omitempty || o == "omitempty"
|
x.omitempty = x.omitempty || o == "omitempty"
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-5
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"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) }
|
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
|
// 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
|
// the function Unmapped does
|
||||||
// a map or interface destination is taken, and one under an array of tables is
|
func (d *Document) Unmapped(v any) []Key { return Unmapped(d.tree, v) }
|
||||||
// named without its index.
|
|
||||||
func (d *Document) Unmapped(v any) []Key {
|
// 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)
|
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))
|
out := make([]Key, 0, len(seen))
|
||||||
for _, k := range seen {
|
for _, k := range seen {
|
||||||
out = append(out, k)
|
out = append(out, k)
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ func TestDocumentRefusesAnEditItCannotPlace(t *testing.T) {
|
|||||||
// TestUnmappedNamesWhatDecodeIgnores: keys no field takes, by tag or field name,
|
// TestUnmappedNamesWhatDecodeIgnores: keys no field takes, by tag or field name,
|
||||||
// unexported and "-" fields included, are named once each, under arrays of tables
|
// 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.
|
// 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) {
|
func TestUnmappedNamesWhatDecodeIgnores(t *testing.T) {
|
||||||
doc := `top = 1
|
doc := `top = 1
|
||||||
skip = 2
|
skip = 2
|
||||||
@@ -248,6 +249,12 @@ nope = 1
|
|||||||
if !slices.Equal(got, want) {
|
if !slices.Equal(got, want) {
|
||||||
t.Fatalf("unmapped %q, want %q", 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
|
// TestKeyStringQuotesWhatABareKeyCannotHold
|
||||||
|
|||||||
+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