diff --git a/README.md b/README.md index aed8bf4..ecb68b2 100644 --- a/README.md +++ b/README.md @@ -240,16 +240,16 @@ hyphenated field can't be an operand. { "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] } ``` -Renders e.g. `19.99 x 3 = 59.97`: an operand is drawn once per expansion, so the -operand shown is the operand computed. A non-numeric operand yields `NaN` and a +Renders e.g. `19.99 x 3 = 59.97`. A non-numeric operand yields `NaN` and a division by zero `Inf`; both print rather than fail. ### Transforms `{lowercase(x)}`, `{uppercase(x)}` and `{ascii(x)}` rewrite the value of `x` — a field, a path or a `..path` — and nest. `ascii` folds Latin letters (`Åsa Öberg` -→ `Asa Oberg`) and drops any other non-ASCII rune. Like a calc operand, `x` is -drawn once per expansion, so an email built from a name matches the name beside it: +→ `Asa Oberg`) and drops any other non-ASCII rune. `x` is held +([One draw, one spelling](#one-draw-one-spelling)), so an email built from a name +matches the name beside it: ```json { "format": "{p.first} {p.last} <{lowercase(ascii(p.first))}.{lowercase(ascii(p.last))}@example.com>", @@ -282,9 +282,9 @@ through a chain. ### Correlated fields -`{name.field}` reads a path into a sibling, and a sibling read that way is drawn -**once per expansion**, so several tokens read one row — a locality and the -postal code that really covers it: +`{name.field}` reads a path into a sibling, which holds the sibling to one draw +([One draw, one spelling](#one-draw-one-spelling)), so several tokens read one +row — a locality and the postal code that really covers it: ```json { "format": "{street} {int(1,99)}\n{place.postal-code} {place.locality}", @@ -297,11 +297,9 @@ postal code that really covers it: Renders e.g. `Kungsgatan 35` / `176 99 Stockholm`, never a Stockholm code beside Tranås; each row's `weight` says how often it appears. A path is held at every -level it passes through (`{p.geo.town.name} {p.geo.town.zip}` share the town), -one path read twice reads one value, and a field no path addresses is drawn each -time (`{word} {word}` differs). The hold lasts one expansion: each `repeat` -iteration and each nested template draws again. Every variant of a choice on the -path must carry the rest of it, so a row missing a field is named at load: +level it passes through: `{p.geo.town.name} {p.geo.town.zip}` share the town. +Every variant of a choice on the path must carry the rest of it, so a row missing +a field is named at load: ```text token {place.postal-code}: field "place": not every variant of this 2-way choice carries "postal-code"; all carry [locality] @@ -312,14 +310,17 @@ The sub-fields stay addressable — `Fake("address.place.locality")` renders, an ### One draw, one spelling -A format may not both **render** a level and **read a path into** it — `{p}` -beside `{p.first}`, `{..cat.net}` beside `{calc(net * 2)}`, or `{q}` beside -`{p.first}` where `q` renders `{..cat.p.last}` — because the render draws afresh -while the path reads the held draw, and the two would disagree. Wherever the -second route sits, it is a load error naming the spelling to use: +A name any token reads as a path (`{p.first}`) or as an operand (`{calc(net * 2)}`, +`{uppercase(w)}`) is drawn **once per expansion**, and every other route to it — +a bare `{p}`, a second bare `{w}`, `{..cat.net}`, a nested template rendering +`{..cat.p.last}`, at any depth — is a load error naming the spelling to use. A +name nothing reads that way is drawn each time: `{word} {word}` differs. An +expansion is one render of one format, so each `repeat` iteration and each nested +template draws again. ```text token {p} renders a level that {p.first} reads a path into; name the fields you want instead +token {w} is repeated, and uppercase operand "w" holds "w" to one draw per expansion; write {w} once ``` ### Performance @@ -347,6 +348,11 @@ tokens add cost in proportion to the output. - **The shipped data is embedded, not discovered.** A directory a machine happens to have would make `--seed 42` machine-dependent. Data still lives in `data/` as JSON; `--data-path` layers over it. +- **A bare reference draws each time; a reference path is held.** `{..p} {..p}` + is two draws, as `{word} {word}` is, while `{..p.first}` beside a nested template + rendering `{..p.first}` is a load error: a bare token is by contract an + independent draw, a path pins its level, and any route into a pinned level from + another expansion could show another row. - **Samples say what they emit, transforms what they do.** `{upper(2)}` is two letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on whether the argument looks like a number. diff --git a/node.go b/node.go index 793c4f7..61566c1 100644 --- a/node.go +++ b/node.go @@ -87,13 +87,17 @@ func compileString(s string) (node, error) { return nil, err } t := &template{format: s, repeat: 1} - t.compileFormat() + if err := t.compileFormat(); err != nil { + return nil, err + } return t, nil } -// compileFormat compiles the format into ops once every field is in place. -func (t *template) compileFormat() { - t.ops, t.grow, t.bound, t.held = compileOps(t.format, t.refs) +// compileFormat compiles the format into ops once every field is in place, and +// applies the fences that need the compiled reads. +func (t *template) compileFormat() error { + c := compileOps(t.format, t.refs) + t.ops, t.grow, t.bound, t.held = c.ops, c.grow, c.bound, c.held t.fixed = true for _, o := range t.ops { if o.kind != 'l' { @@ -103,6 +107,10 @@ func (t *template) compileFormat() { if t.fixed && len(t.ops) == 1 { t.lit = t.ops[0].lit } + if err := checkNoOverlap(t.format, t.bound, t.refs); err != nil { + return err + } + return checkNoRepeatedRead(t.format, c, t.refs) } func compileChoice(items []any) (node, error) { @@ -218,8 +226,7 @@ func compileTemplate(m map[string]any) (node, error) { if err := checkTokens(format, t.fields); err != nil { return nil, err } - t.compileFormat() - if err := checkNoOverlap(format, t.bound, nil); err != nil { + if err := t.compileFormat(); err != nil { return nil, err } return t, nil diff --git a/reference.go b/reference.go index aac8a1d..0089f23 100644 --- a/reference.go +++ b/reference.go @@ -46,8 +46,7 @@ func linkRefs(root map[string]node) error { t.fields[key] = target t.refs[name] = key } - t.compileFormat() - if err := checkNoOverlap(t.format, t.bound, t.refs); err != nil { + if err := t.compileFormat(); err != nil { return fmt.Errorf("%s: %w", path, err) } return nil diff --git a/template.go b/template.go index fd6bb61..9c2e3a9 100644 --- a/template.go +++ b/template.go @@ -363,38 +363,69 @@ type op struct { operands []arm } -// compileOps turns a format string into ops, and returns the size of its literal -// text to size the render buffer, plus two sets. bound is the levels the format -// addresses by dotted path, each mapped to the first path reading it, which is what -// the overlap fences name. held is every name drawn once per expansion — those -// levels, plus the fields an operand reads, so an operand shown is the operand -// computed. Both are nil when the format needs neither, so data that uses neither -// carries no render-time cost. Call checkTokens first: it is what proves the scan -// and every token are valid. -func compileOps(format string, refs map[string]string) ([]op, int, map[string]string, map[string]bool) { - var ops []op - var lit strings.Builder - var bound map[string]string - var held map[string]bool - grow := 0 - hold := func(a arm) { - if held == nil { - held = map[string]bool{} +// formatOps is a compiled format: its ops, the size of its literal text (to size +// the render buffer), and the names drawn once per expansion. bound maps each level +// a path reads into to the first such path; held is every such level plus the +// fields an operand reads; holder maps each held name to the first reader holding +// it, for error messages. The maps are nil when the format holds nothing, so data +// that holds nothing carries no render-time cost. +type formatOps struct { + ops []op + grow int + bound map[string]string + held map[string]bool + holder map[string]string +} + +func (c *formatOps) hold(a arm, label string) { + if c.held == nil { + c.held = map[string]bool{} + c.holder = map[string]string{} + } + c.held[a.key] = true + if _, named := c.holder[a.key]; !named { + c.holder[a.key] = label + } + if len(a.tail) > 0 { + if c.bound == nil { + c.bound = map[string]string{} } - held[a.key] = true - if len(a.tail) > 0 { - if bound == nil { - bound = map[string]string{} - } - if _, named := bound[a.key]; !named { - bound[a.key] = a.name // the first path reading it, for error messages - } + if _, named := c.bound[a.key]; !named { + c.bound[a.key] = a.name } } +} + +func (c *formatOps) function(body string, refs map[string]string) { + name, args, _ := funcCall(body) + var operands []arm + for _, operand := range tokenOperands(body) { + a := splitArm(operand, refs) + c.hold(a, fmt.Sprintf("%s operand %q", name, operand)) + operands = append(operands, a) + } + c.ops = append(c.ops, op{kind: 'b', call: builtins[name].prep(args), operands: operands}) +} + +func (c *formatOps) field(body string, refs map[string]string) { + arms := splitArms(body, refs) + for _, a := range arms { + if len(a.tail) > 0 { + c.hold(a, "token {"+a.name+"}") + } + } + c.ops = append(c.ops, op{kind: 'f', arms: arms}) +} + +// compileOps compiles a format string. Call checkTokens first: it is what proves +// the scan and every token are valid. +func compileOps(format string, refs map[string]string) formatOps { + var c formatOps + var lit strings.Builder flush := func() { if lit.Len() > 0 { - grow += lit.Len() - ops = append(ops, op{kind: 'l', lit: lit.String()}) + c.grow += lit.Len() + c.ops = append(c.ops, op{kind: 'l', lit: lit.String()}) lit.Reset() } } @@ -404,26 +435,38 @@ func compileOps(format string, refs map[string]string) ([]op, int, map[string]st lit.WriteRune(t.r) case 'b': flush() - if name, args, ok := funcCall(t.body); ok { - var operands []arm - for _, operand := range tokenOperands(t.body) { - a := splitArm(operand, refs) - hold(a) // the builtin renders its operand, so the expansion holds that draw - operands = append(operands, a) - } - ops = append(ops, op{kind: 'b', call: builtins[name].prep(args), operands: operands}) + if _, _, isFunc := funcCall(t.body); isFunc { + c.function(t.body, refs) } else { - arms := splitArms(t.body, refs) - for _, a := range arms { - if len(a.tail) > 0 { - hold(a) - } - } - ops = append(ops, op{kind: 'f', arms: arms}) + c.field(t.body, refs) } } return nil }) flush() - return ops, grow, bound, held + return c +} + +// checkNoRepeatedRead rejects a bare token repeated on a held name: {w} {w} beside +// {uppercase(w)} would read one draw twice, where {w} {w} alone draws twice. The +// error names the single-token spelling. +func checkNoRepeatedRead(format string, c formatOps, refs map[string]string) error { + count := map[string]int{} + return eachToken(format, func(t ftoken) error { + if t.kind != 'b' { + return nil + } + if _, _, isFunc := funcCall(t.body); isFunc { + return nil + } + for _, a := range splitArms(t.body, refs) { + if len(a.tail) > 0 || !c.held[a.key] { + continue + } + if count[a.key]++; count[a.key] > 1 { + return fmt.Errorf("token {%s} is repeated, and %s holds %q to one draw per expansion; write {%s} once", a.name, c.holder[a.key], a.key, a.name) + } + } + return nil + }) }