diff --git a/README.md b/README.md index bbfae4e..910176c 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,9 @@ A `*Fakes` is **not** safe for concurrent use — create one per goroutine. The library ships a ready-to-use set under [`data/`](data): one folder per locale (`en_US`, `sv_SE`) plus a locale-neutral `misc` folder. Point either tool at the -whole tree, a single folder, a copy, or your own directory — anywhere on disk; no -naming rules. +whole tree, a single folder, a copy, or your own directory — anywhere on disk. A +category or folder name must not contain a dot, and dot-prefixed entries are +skipped, so a data directory can also be a checkout. A directory is just a namespace. Each JSON file is a category named after the file; each subdirectory is a dot-path segment — folders nest exactly like JSON @@ -229,6 +230,12 @@ fast instead of silently skewing output. This yields e.g. `bar foo baz`. `repeat` must be a positive integer and `separator` a string, both checked at `New`. +`format`, `weight`, `repeat` and `separator` are the only options; **any other key +is a field**. So write `seperator` and you get a field by that name while the +option stays unset. `New` rejects an option that cannot take effect — a +`separator` without a `repeat` above 1, a `weight` outside a choice — and a +category or folder name containing a dot, which no dot path could reach. + **Functions.** A `{name()}` token calls a built-in function instead of rendering a field. `{luhn()}` appends a Luhn check digit over the digits emitted **so far** in the current format (non-digits skipped but kept); unknown functions or wrong diff --git a/data.go b/data.go index 6d246c5..5415a6c 100644 --- a/data.go +++ b/data.go @@ -53,6 +53,9 @@ func loadDir(dir string) (*group, error) { } g := &group{children: map[string]node{}} for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { // hidden: a checkout or an editor's file, never data + continue + } full := filepath.Join(dir, e.Name()) if e.IsDir() { if isRef(e.Name()) { @@ -62,9 +65,13 @@ func loadDir(dir string) (*group, error) { if err != nil { return nil, err } - if len(child.children) > 0 { - g.children[e.Name()] = child + if len(child.children) == 0 { + continue } + if err := checkName(e.Name()); err != nil { + return nil, fmt.Errorf("%s: folder %w", full, err) + } + g.children[e.Name()] = child continue } if !strings.HasSuffix(e.Name(), ".json") { @@ -74,6 +81,9 @@ func loadDir(dir string) (*group, error) { if isRef(name) { return nil, fmt.Errorf("%s: category %q starts with %q, which is reserved for {..path} bindings", full, name, refPrefix) } + if err := checkName(name); err != nil { + return nil, fmt.Errorf("%s: category %w", full, err) + } b, err := os.ReadFile(full) if err != nil { return nil, err diff --git a/node.go b/node.go index 59d4eec..228225b 100644 --- a/node.go +++ b/node.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "sort" + "strings" ) // node is a compiled template element: literal, choice, or template. Compiling @@ -50,7 +51,20 @@ type template struct { func (*template) isNode() {} // compile converts parsed JSON into a node tree, validating structure up front. +// Only a choice's items carry a weight, so a numeric one here would be inert. func compile(v any) (node, error) { + if m, ok := v.(map[string]any); ok { + if w, weighted := m["weight"]; weighted { + if _, isNumber := w.(float64); isNumber { + return nil, fmt.Errorf("weight only skews a choice's items, so it has no effect here") + } + } + } + return compileItem(v) +} + +// compileItem compiles one node, allowing the weight a choice item may carry. +func compileItem(v any) (node, error) { switch v := v.(type) { case string: return literal(v), nil @@ -81,7 +95,7 @@ func compileChoice(items []any) (node, error) { } total += w cum[i] = total - n, err := compile(raw) + n, err := compileItem(raw) if err != nil { return nil, err } @@ -115,6 +129,9 @@ func compileTemplate(m map[string]any) (node, error) { if sep, ok = sv.(string); !ok { return nil, fmt.Errorf("separator must be a string, got %T", sv) } + if repeat == 1 { + return nil, fmt.Errorf("separator joins repeated renders, so it has no effect without a repeat above 1") + } } t := &template{format: format, fields: make(map[string]node, len(m)), repeat: repeat, separator: sep} keys := make([]string, 0, len(m)) @@ -123,7 +140,7 @@ func compileTemplate(m map[string]any) (node, error) { } sort.Strings(keys) // so which of several bad fields is reported does not vary for _, k := range keys { - if k == "format" || k == "weight" || k == "repeat" || k == "separator" { + if isOption(k) { continue } if isRef(k) { @@ -182,3 +199,23 @@ func weightOf(raw any) (float64, error) { } return w, nil } + +// checkName rejects a category or folder name no dot path can reach. A dot separates +// path segments, so such a name is unaddressable by every route — unlike a field, +// which its parent's format still reaches by token. +func checkName(name string) error { + if strings.Contains(name, ".") { + return fmt.Errorf("%q contains a dot, which a dot path cannot reach", name) + } + return nil +} + +// isOption reports whether a template key configures the node instead of naming a +// field. These four names can never be fields. +func isOption(name string) bool { + switch name { + case "format", "repeat", "separator", "weight": + return true + } + return false +} diff --git a/template.go b/template.go index 14e5ede..bc7b7ce 100644 --- a/template.go +++ b/template.go @@ -143,6 +143,9 @@ func checkTokens(format string, fields map[string]node) error { continue // a root reference; its target is checked at New (see linkRefs) } if _, ok := fields[name]; !ok { + if isOption(name) { + return fmt.Errorf("token {%s}: %q is an option, never a field", t.body, name) + } return fmt.Errorf("token {%s}: no field %q", t.body, name) } }