Reject options that cannot take effect, and skip entries that are not data

This commit is contained in:
M
2026-08-28 00:17:39 +02:00
committed by lilleman-tw
parent 8d0393713c
commit b14caae3a8
4 changed files with 63 additions and 6 deletions
+9 -2
View File
@@ -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
+12 -2
View File
@@ -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
+39 -2
View File
@@ -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
}
+3
View File
@@ -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)
}
}