Add inline templates to the CLI and library #4

Merged
lilleman merged 23 commits from cli-inline-template into main 2026-09-03 21:20:26 +02:00
5 changed files with 166 additions and 73 deletions
Showing only changes of commit 3bc3e35045 - Show all commits
+49 -26
View File
@@ -10,29 +10,36 @@ import (
// visiting keys in sorted order so which of several broken nodes gets reported does // visiting keys in sorted order so which of several broken nodes gets reported does
// not depend on map iteration. // not depend on map iteration.
func walkNodes(root map[string]node, fn func(path string, n node) error) error { func walkNodes(root map[string]node, fn func(path string, n node) error) error {
seen := map[node]bool{} for _, name := range sortedNames(root) {
var visit func(string, node) error if err := eachNode(root[name], name, fn); err != nil {
visit = func(path string, n node) error {
if n == nil || seen[n] {
return nil
}
seen[n] = true
if err := fn(path, n); err != nil {
return err return err
} }
for _, c := range contained(n) { }
return nil
}
// eachNode visits n and every node contained within it once, passing the dot path
// that reaches each. It never crosses a reference edge — a bound {/path} field is
// skipped, as in walkNodes — so a single inline node is walked on its own.
func eachNode(n node, path string, fn func(path string, n node) error) error {
seen := map[node]bool{}
var visit func(string, node) error
visit = func(path string, m node) error {
if m == nil || seen[m] {
return nil
}
seen[m] = true
if err := fn(path, m); err != nil {
return err
}
for _, c := range contained(m) {
if err := visit(join(path, c.name), c.node); err != nil { if err := visit(join(path, c.name), c.node); err != nil {
return err return err
} }
} }
return nil return nil
} }
for _, name := range sortedNames(root) { return visit(path, n)
if err := visit(name, root[name]); err != nil {
return err
}
}
return nil
} }
// namedNode is a contained child and the segment reaching it; a choice's items carry // namedNode is a contained child and the segment reaching it; a choice's items carry
@@ -162,30 +169,46 @@ func pathLeaves(n node, tail []string) []node {
// path, so nested repeats cannot build what one repeat may not. It runs after // path, so nested repeats cannot build what one repeat may not. It runs after
// checkNoCycles, whose guarantee is what lets the walk terminate. // checkNoCycles, whose guarantee is what lets the walk terminate.
func checkRepeatReach(root map[string]node) error { func checkRepeatReach(root map[string]node) error {
reach := map[node]int{} mem := reachMemo{}
var of func(n node) int return walkNodes(root, func(path string, n node) error {
of = func(n node) int { return repeatCheck(path, n, mem)
if r, done := reach[n]; done { })
}
// checkNodeRepeatReach is checkRepeatReach for one inline node: each template in
// it is bounded, following reference edges into the already-validated tree the same
// way a repeat through a reference multiplies along a path.
func checkNodeRepeatReach(n node) error {
mem := reachMemo{}
return eachNode(n, "template", func(path string, m node) error {
return repeatCheck(path, m, mem)
})
}
type reachMemo map[node]int
func (m reachMemo) of(n node) int {
if r, done := m[n]; done {
return r return r
} }
r := 1 r := 1
for _, e := range renderEdges(n) { for _, e := range renderEdges(n) {
if c := of(e.to); c > r { if c := m.of(e.to); c > r {
r = c r = c
} }
} }
if t, ok := n.(*template); ok { if t, ok := n.(*template); ok {
r *= t.repeat r *= t.repeat
} }
reach[n] = r m[n] = r
return r return r
} }
return walkNodes(root, func(path string, n node) error {
if t, ok := n.(*template); ok && t.repeat > 1 && of(n) > MaxRepeat { func repeatCheck(path string, n node, mem reachMemo) error {
return fmt.Errorf("%s: repeat %d multiplies to %d renders along one path, above the maximum %d", path, t.repeat, of(n), MaxRepeat) if t, ok := n.(*template); ok && t.repeat > 1 && mem.of(n) > MaxRepeat {
return fmt.Errorf("%s: repeat %d multiplies to %d renders along one path, above the maximum %d", path, t.repeat, mem.of(n), MaxRepeat)
} }
return nil return nil
})
} }
// checkNoCycles rejects a reference cycle: a node whose rendering can reach itself // checkNoCycles rejects a reference cycle: a node whose rendering can reach itself
+13 -1
View File
@@ -15,6 +15,19 @@ import (
// It runs after checkNoCycles, whose guarantee is what lets the walk terminate. // It runs after checkNoCycles, whose guarantee is what lets the walk terminate.
func checkBoundLevelsHeld(root map[string]node) error { func checkBoundLevelsHeld(root map[string]node) error {
return walkNodes(root, func(path string, n node) error { return walkNodes(root, func(path string, n node) error {
return heldCheck(path, n)
})
}
// checkNodeBoundLevelsHeld is checkBoundLevelsHeld for one inline node: the same
// held fence, over its own templates and the shared tree they reference.
func checkNodeBoundLevelsHeld(n node) error {
return eachNode(n, "template", func(path string, m node) error {
return heldCheck(path, m)
})
}
func heldCheck(path string, n node) error {
t, ok := n.(*template) t, ok := n.(*template)
if !ok || len(t.held) == 0 { if !ok || len(t.held) == 0 {
return nil return nil
@@ -26,7 +39,6 @@ func checkBoundLevelsHeld(root map[string]node) error {
} }
} }
return nil return nil
})
} }
// heldHeads lists a template's held names, operand heads first, then paths, each // heldHeads lists a template's held names, operand heads first, then paths, each
+12
View File
@@ -122,6 +122,18 @@ func (t *template) compileFormat() error {
return checkNoRepeatedRead(t.format, c, t.refs) return checkNoRepeatedRead(t.format, c, t.refs)
} }
// compileInput compiles an inline template: a JSON value, or a bare format string
// when the input is not JSON. A format string is a template with no fields, so a
// string that is itself valid JSON (a JSON string literal) and a bare string
// compile alike.
func compileInput(input string) (node, error) {
var raw any
if err := json.Unmarshal([]byte(input), &raw); err != nil {
return compile(input)
}
return compile(raw)
}
func compileChoice(items []any) (node, error) { func compileChoice(items []any) (node, error) {
if len(items) == 0 { if len(items) == 0 {
return nil, fmt.Errorf("empty choice") return nil, fmt.Errorf("empty choice")
+20
View File
@@ -72,6 +72,14 @@ func refSegments(name string, folder []string) ([]string, error) {
// error, never a random render-time one. // error, never a random render-time one.
func linkRefs(root map[string]node) error { func linkRefs(root map[string]node) error {
return eachTemplate(root, func(folder []string, path string, t *template) error { return eachTemplate(root, func(folder []string, path string, t *template) error {
return linkTemplateRefs(folder, path, t, root)
})
}
// linkTemplateRefs binds one template's references against root. A template with
// none is left untouched, so an inline format that references nothing costs only
// the refTokens scan.
func linkTemplateRefs(folder []string, path string, t *template, root map[string]node) error {
names := refTokens(t.format) names := refTokens(t.format)
if len(names) == 0 { if len(names) == 0 {
return nil return nil
@@ -100,6 +108,18 @@ func linkRefs(root map[string]node) error {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
return nil return nil
}
// linkNodeRefs binds the references in an inline node's templates against the
// loaded tree. The node has no folder of its own, so every sigil is root-relative:
// / and . mean the root, .. has no folder above it.
func linkNodeRefs(n node, root map[string]node) error {
return eachNode(n, "template", func(path string, m node) error {
t, ok := m.(*template)
if !ok {
return nil
}
return linkTemplateRefs(nil, path, t, root)
}) })
} }
+26
View File
@@ -30,6 +30,32 @@ func (f *Generator) Fake(path string) (string, error) {
return render(f.rand, n), nil return render(f.rand, n), nil
} }
// FakeTemplate renders an inline template — a format string or a JSON value — the
// way a category's data is compiled and rendered, references reaching the loaded
// tree with {/path}. It shares [Fake]'s lock: an inline node is compiled and
// referenced per draw, so a seeded sequence is reproducible when drawn from one
// goroutine.
func (f *Generator) FakeTemplate(input string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
n, err := compileInput(input)
if err != nil {
return "", fmt.Errorf("fejkdata: %w", err)
}
if err := linkNodeRefs(n, f.categories); err != nil {
return "", fmt.Errorf("fejkdata: %w", err)
}
// No cycle is possible: a reference binds only into the loaded tree, which has
// no path into this node, so rendering it cannot reach itself.
if err := checkNodeRepeatReach(n); err != nil {
return "", fmt.Errorf("fejkdata: %w", err)
}
if err := checkNodeBoundLevelsHeld(n); err != nil {
return "", fmt.Errorf("fejkdata: %w", err)
}
return render(f.rand, n), nil
}
// descend walks named fields to the node a path names. It is the one render-side // descend walks named fields to the node a path names. It is the one render-side
// step that can fail, because the path comes from the caller and may name a field // step that can fail, because the path comes from the caller and may name a field
// that does not exist. A choice consumes no segment, so the rest of the path must // that does not exist. A choice consumes no segment, so the rest of the path must