Reject options that cannot take effect, and skip entries that are not data
This commit is contained in:
@@ -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
|
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
|
(`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
|
whole tree, a single folder, a copy, or your own directory — anywhere on disk. A
|
||||||
naming rules.
|
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
|
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
|
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
|
This yields e.g. `bar foo baz`. `repeat` must be a positive integer and
|
||||||
`separator` a string, both checked at `New`.
|
`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
|
**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**
|
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
|
in the current format (non-digits skipped but kept); unknown functions or wrong
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ func loadDir(dir string) (*group, error) {
|
|||||||
}
|
}
|
||||||
g := &group{children: map[string]node{}}
|
g := &group{children: map[string]node{}}
|
||||||
for _, e := range entries {
|
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())
|
full := filepath.Join(dir, e.Name())
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
if isRef(e.Name()) {
|
if isRef(e.Name()) {
|
||||||
@@ -62,9 +65,13 @@ func loadDir(dir string) (*group, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(child.children) > 0 {
|
if len(child.children) == 0 {
|
||||||
g.children[e.Name()] = child
|
continue
|
||||||
}
|
}
|
||||||
|
if err := checkName(e.Name()); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: folder %w", full, err)
|
||||||
|
}
|
||||||
|
g.children[e.Name()] = child
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(e.Name(), ".json") {
|
if !strings.HasSuffix(e.Name(), ".json") {
|
||||||
@@ -74,6 +81,9 @@ func loadDir(dir string) (*group, error) {
|
|||||||
if isRef(name) {
|
if isRef(name) {
|
||||||
return nil, fmt.Errorf("%s: category %q starts with %q, which is reserved for {..path} bindings", full, name, refPrefix)
|
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)
|
b, err := os.ReadFile(full)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// node is a compiled template element: literal, choice, or template. Compiling
|
// node is a compiled template element: literal, choice, or template. Compiling
|
||||||
@@ -50,7 +51,20 @@ type template struct {
|
|||||||
func (*template) isNode() {}
|
func (*template) isNode() {}
|
||||||
|
|
||||||
// compile converts parsed JSON into a node tree, validating structure up front.
|
// 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) {
|
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) {
|
switch v := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
return literal(v), nil
|
return literal(v), nil
|
||||||
@@ -81,7 +95,7 @@ func compileChoice(items []any) (node, error) {
|
|||||||
}
|
}
|
||||||
total += w
|
total += w
|
||||||
cum[i] = total
|
cum[i] = total
|
||||||
n, err := compile(raw)
|
n, err := compileItem(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -115,6 +129,9 @@ func compileTemplate(m map[string]any) (node, error) {
|
|||||||
if sep, ok = sv.(string); !ok {
|
if sep, ok = sv.(string); !ok {
|
||||||
return nil, fmt.Errorf("separator must be a string, got %T", sv)
|
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}
|
t := &template{format: format, fields: make(map[string]node, len(m)), repeat: repeat, separator: sep}
|
||||||
keys := make([]string, 0, len(m))
|
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
|
sort.Strings(keys) // so which of several bad fields is reported does not vary
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
if k == "format" || k == "weight" || k == "repeat" || k == "separator" {
|
if isOption(k) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if isRef(k) {
|
if isRef(k) {
|
||||||
@@ -182,3 +199,23 @@ func weightOf(raw any) (float64, error) {
|
|||||||
}
|
}
|
||||||
return w, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
continue // a root reference; its target is checked at New (see linkRefs)
|
||||||
}
|
}
|
||||||
if _, ok := fields[name]; !ok {
|
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)
|
return fmt.Errorf("token {%s}: no field %q", t.body, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user