Compare commits

..

3 Commits

9 changed files with 65 additions and 36 deletions
+9
View File
@@ -386,6 +386,15 @@ tokens add cost in proportion to the output.
instead of a leading-position special case. The JSON string is what makes the instead of a leading-position special case. The JSON string is what makes the
library's own advice reachable: the error for an object holding only a format library's own advice reachable: the error for an object holding only a format
names `"…"`, and that spelling has to work where it is printed. names `"…"`, and that spelling has to work where it is printed.
- **An inline template skips the cycle fence, and only that one.** `New` proves the
loaded tree acyclic, an inline node is a finite tree of its own, and nothing in
the tree can reference it, so no render of it reaches itself. Every other fence
runs over both, from one `checkScope`.
- **An inline template that does not compile is misuse (exit 2), including a
reference that resolves to nothing** — the whole argument is the spelling under
test, and `NewTemplate` compiles, links and validates as one step. An unknown
*path* stays a runtime error (exit 1): there the argument is well-formed and only
the data is absent.
- **A padded JSON argument is rejected, not trimmed.** Padding is the one place the - **A padded JSON argument is rejected, not trimmed.** Padding is the one place the
two readings disagree — a format string renders it, JSON drops it — so the two readings disagree — a format string renders it, JSON drops it — so the
spelling that renders is named rather than silently chosen. spelling that renders is named rather than silently chosen.
+7 -10
View File
@@ -30,8 +30,8 @@ const usage = `Usage: fejkdata [flags] <path|template>
An argument containing a { token, or a JSON object, array or string, is a An argument containing a { token, or a JSON object, array or string, is a
template; any other argument is a path (a path never contains a brace, a bracket template; any other argument is a path (a path never contains a brace, a bracket
or a quote). Templates reach the data by reference from the root — or a quote). Templates reach the data by reference from the root —
{/sv_SE.person.first} — whether the data is shipped or layered with --data-path. A {/sv_SE.person.last} — whether the data is shipped or layered with --data-path. An
bracket that is not valid JSON names no template and no path. argument carrying one of those characters but no valid JSON names neither.
-d, --data-path D a data directory to layer over the shipped data (repeatable; last wins on a clash) -d, --data-path D a data directory to layer over the shipped data (repeatable; last wins on a clash)
-h, --help print this help, then exit -h, --help print this help, then exit
@@ -208,7 +208,7 @@ func (in invocation) check() (argKind, error) {
return argPath, nil return argPath, nil
} }
if len(in.paths) != 1 { if len(in.paths) != 1 {
return argPath, fmt.Errorf("expected one path, got %d", len(in.paths)) return argPath, fmt.Errorf("expected one path or template, got %d", len(in.paths))
} }
return classify(in.paths[0]) return classify(in.paths[0])
} }
@@ -270,16 +270,13 @@ const (
argTemplate argTemplate
) )
// classify reads what a positional argument names by its shape: a format string // classify reads what a positional argument names by its shape: a { token, or a
// carrying a { token, or a JSON object, array or string, is an inline template; // JSON object, array or string, is an inline template; anything else is a path.
// anything else is a path. A name may hold neither a brace nor a bracket nor a
// quote, so no path collides with any of those spellings, and the JSON gate is
// valid-JSON so a copied bracket names nothing rather than swallowing an argument.
func classify(arg string) (argKind, error) { func classify(arg string) (argKind, error) {
if strings.ContainsRune(arg, '{') || (isJSONStart(arg) && json.Valid([]byte(arg))) { if strings.ContainsRune(arg, '{') || (isJSONStart(strings.TrimSpace(arg)) && json.Valid([]byte(arg))) {
return argTemplate, nil return argTemplate, nil
} }
if i := strings.IndexAny(arg, `[]"`); i >= 0 { if i := strings.IndexAny(arg, `[]}"`); i >= 0 {
return argPath, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1]) return argPath, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1])
} }
return argPath, nil return argPath, nil
+11 -8
View File
@@ -309,6 +309,7 @@ func TestClassify(t *testing.T) {
`{"format":"x"}`: argTemplate, `{"format":"x"}`: argTemplate,
`["a","b"]`: argTemplate, // a JSON array carries no brace `["a","b"]`: argTemplate, // a JSON array carries no brace
`[1, 2]`: argTemplate, `[1, 2]`: argTemplate,
` ["a","b"]`: argTemplate, // padding is the template's own error, not a shape verdict
`"hello"`: argTemplate, // a JSON string, the spelling a format-only object names `"hello"`: argTemplate, // a JSON string, the spelling a format-only object names
} { } {
got, err := classify(arg) got, err := classify(arg)
@@ -321,6 +322,7 @@ func TestClassify(t *testing.T) {
"[abc].field": `holds a "["`, "[abc].field": `holds a "["`,
"x[1]": `holds a "["`, "x[1]": `holds a "["`,
"a]b": `holds a "]"`, "a]b": `holds a "]"`,
"a}b": `holds a "}"`,
`"abc`: `holds a "\""`, `"abc`: `holds a "\""`,
`"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it `"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it
} { } {
@@ -358,16 +360,17 @@ func TestRunInlineTemplate(t *testing.T) {
} }
func TestRunTemplateMisuse(t *testing.T) { func TestRunTemplateMisuse(t *testing.T) {
for _, arg := range []string{ for arg, want := range map[string]string{
"{bad", // unterminated brace "{bad": "unterminated",
"[red,green]", // a near-miss JSON array (unquoted strings) "[red,green]": "names no template either",
`{"format":"x"}`, // an object holding only a format `{"format":"x"}`: "is a string",
"{/no.such.path}", // a reference into nothing "{/no.such.path}": "no entry",
"x[1]", // a bracket no path may hold "x[1]": "names no template either",
` ["a","b"] `: "may not be padded",
} { } {
code, out, errb := runOut("--seed", "1", arg) code, out, errb := runOut("--seed", "1", arg)
if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") { if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") || !strings.Contains(errb, want) {
t.Errorf("run(%q) = %d, %q, %q; want misuse naming --help", arg, code, out, errb) t.Errorf("run(%q) = %d, %q, %q; want misuse naming %q and --help", arg, code, out, errb, want)
} }
if strings.Contains(errb, "fejkdata: fejkdata:") { if strings.Contains(errb, "fejkdata: fejkdata:") {
t.Errorf("run(%q) doubled the program prefix: %q", arg, errb) t.Errorf("run(%q) doubled the program prefix: %q", arg, errb)
+1 -1
View File
@@ -20,7 +20,7 @@ func walkNodes(root map[string]node, fn func(path string, n node) error) error {
// eachNode visits n and every node contained within it once, passing the dot path // 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 // 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. // skipped — so a single inline node is walked on its own.
func eachNode(n node, path string, fn func(path string, n node) error) error { func eachNode(n node, path string, fn func(path string, n node) error) error {
seen := map[node]bool{} seen := map[node]bool{}
var visit func(string, node) error var visit func(string, node) error
+2 -12
View File
@@ -27,10 +27,6 @@ func (t *Template) Fake() string {
// binds its references against the loaded tree, so repeated renders pay the // binds its references against the loaded tree, so repeated renders pay the
// compile and validation once. It shares [New]'s guarantees: a bad template errors // compile and validation once. It shares [New]'s guarantees: a bad template errors
// here, and rendering cannot fail. // here, and rendering cannot fail.
//
// checkNoCycles is the one fence loadData runs that an inline node does not need:
// the loaded tree is proven acyclic at [New], the node is a finite tree, and no
// tree node can reference it, so nothing it renders can reach itself.
func (f *Generator) NewTemplate(input string) (*Template, error) { func (f *Generator) NewTemplate(input string) (*Template, error) {
n, err := compileInput(input) n, err := compileInput(input)
if err != nil { if err != nil {
@@ -58,12 +54,7 @@ func (f *Generator) FakeTemplate(input string) (string, error) {
} }
// compileInput compiles an inline template: a JSON value, or a bare format string // compileInput compiles an inline template: a JSON value, or a bare format string
// when the input is not JSON. A JSON string literal and a bare string compile // when the input is not JSON.
// alike (both are a template with no fields); the other JSON scalars — a number,
// bool or null — are no template, so they are rejected here, as a data file that
// was one would be at load. Padding is where the two readings would disagree — a
// format string renders it, JSON drops it — so a padded JSON value is rejected
// naming the one that renders.
func compileInput(input string) (node, error) { func compileInput(input string) (node, error) {
var raw any var raw any
if err := json.Unmarshal([]byte(input), &raw); err != nil { if err := json.Unmarshal([]byte(input), &raw); err != nil {
@@ -76,8 +67,7 @@ func compileInput(input string) (node, error) {
} }
// linkNodeRefs binds the references in an inline node's templates against the // linkNodeRefs binds the references in an inline node's templates against the
// loaded tree. An inline template sits in no folder, so . and .. name nothing and // loaded tree.
// are rejected for the root spelling they would otherwise silently mean.
func linkNodeRefs(scope nodeScope, root map[string]node) error { func linkNodeRefs(scope nodeScope, root map[string]node) error {
return scope(func(path string, m node) error { return scope(func(path string, m node) error {
t, ok := m.(*template) t, ok := m.(*template)
+12 -3
View File
@@ -76,12 +76,20 @@ func TestFakeTemplateDeterministic(t *testing.T) {
} }
} }
func TestBareStringReferenceHint(t *testing.T) { func TestFieldlessTokenHint(t *testing.T) {
f := shipped(t) f := shipped(t)
_, err := f.FakeTemplate(`{sv_SE.person.last}`) _, err := f.FakeTemplate(`{sv_SE.person.last}`)
if err == nil || !strings.Contains(err.Error(), "write {/sv_SE.person.last}") { if err == nil || !strings.Contains(err.Error(), "write {/sv_SE.person.last}") {
t.Fatalf("FakeTemplate(bare token) = %v, want a hint naming {/sv_SE.person.last}", err) t.Fatalf("FakeTemplate(bare token) = %v, want a hint naming {/sv_SE.person.last}", err)
} }
_, err = f.FakeTemplate(`{"format":"{x}","repeat":2}`)
if err == nil || strings.Contains(err.Error(), "bare string") {
t.Errorf("FakeTemplate(fieldless object) = %v, want an error that does not call it a bare string", err)
}
_, err = f.FakeTemplate(`{ /sv_SE.person.last }`)
if err == nil || strings.Contains(err.Error(), "write {") {
t.Errorf("FakeTemplate(malformed token) = %v, want no hint naming a spelling that fails too", err)
}
} }
func TestFakeTemplateErrors(t *testing.T) { func TestFakeTemplateErrors(t *testing.T) {
@@ -97,6 +105,7 @@ func TestFakeTemplateErrors(t *testing.T) {
{`name: {..nope}`, "write {/nope}"}, {`name: {..nope}`, "write {/nope}"},
{`{"format":"x"}`, "is a string"}, {`{"format":"x"}`, "is a string"},
{`{/misc.country} {/misc.country.alpha2}`, "renders a level"}, {`{/misc.country} {/misc.country.alpha2}`, "renders a level"},
{`{"format":"{/misc.country.alpha2} {x}","x":"{/misc.country}"}`, "renders"},
} { } {
_, err := f.FakeTemplate(c.input) _, err := f.FakeTemplate(c.input)
if err == nil || !strings.Contains(err.Error(), c.want) { if err == nil || !strings.Contains(err.Error(), c.want) {
@@ -124,13 +133,13 @@ func TestPaddedJSONIsRejected(t *testing.T) {
func TestNewTemplateReusable(t *testing.T) { func TestNewTemplateReusable(t *testing.T) {
f := shipped(t) f := shipped(t)
tmpl, err := f.NewTemplate(`{digits(2)}`) reusable, err := f.NewTemplate(`{digits(2)}`)
if err != nil { if err != nil {
t.Fatalf("NewTemplate: %v", err) t.Fatalf("NewTemplate: %v", err)
} }
seen := map[string]bool{} seen := map[string]bool{}
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
seen[tmpl.Fake()] = true seen[reusable.Fake()] = true
} }
if len(seen) < 2 { if len(seen) < 2 {
t.Fatalf("Template.Fake() repeated %v, want varied draws from one compile", seen) t.Fatalf("Template.Fake() repeated %v, want varied draws from one compile", seen)
+1 -1
View File
@@ -302,7 +302,7 @@ func repeatOf(m map[string]any) (int, error) {
if r == 1 { if r == 1 {
return 0, fmt.Errorf("repeat 1 is the default, so it has no effect; drop it") return 0, fmt.Errorf("repeat 1 is the default, so it has no effect; drop it")
} }
if r > MaxRepeat { // caps the renders one repeat asks for; checkRepeatReach bounds what nested ones multiply to if r > MaxRepeat { // caps the renders one repeat asks for; repeatCheck bounds what nested ones multiply to
return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, MaxRepeat) return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, MaxRepeat)
} }
return int(r), nil return int(r), nil
+6
View File
@@ -108,6 +108,12 @@ func TestFakeIsSafeForConcurrentUse(t *testing.T) {
return return
} }
f.List() f.List()
tmpl, err := f.NewTemplate("{/sv_SE.person.last}")
if err != nil {
t.Error(err)
return
}
tmpl.Fake()
} }
}() }()
} }
+16 -1
View File
@@ -165,7 +165,11 @@ func checkArm(name string, fields map[string]node) error {
return fmt.Errorf("%q is an option and can never be a field", a.key) return fmt.Errorf("%q is an option and can never be a field", a.key)
} }
if len(fields) == 0 { if len(fields) == 0 {
return fmt.Errorf("no field %q; a token names a sibling field, and a bare string has none — write {/%s} to reference the data", a.key, name) hint := ""
if hintableRef(name) {
hint = fmt.Sprintf(" — write {/%s} to reference the data", name)
}
return fmt.Errorf("no field %q; a token names a sibling field, and this template has none%s", a.key, hint)
} }
return fmt.Errorf("no field %q", a.key) return fmt.Errorf("no field %q", a.key)
} }
@@ -175,6 +179,17 @@ func checkArm(name string, fields map[string]node) error {
return nil return nil
} }
// hintableRef reports whether {/name} is a reference the grammar accepts, so the
// hint never names a spelling that fails too.
func hintableRef(name string) bool {
for _, seg := range strings.Split(name, ".") {
if checkName(seg) != nil {
return false
}
}
return true
}
// tokenOperands lists the fields one {token} body reads as operands, empty for a // tokenOperands lists the fields one {token} body reads as operands, empty for a
// field token or a builtin that reads none. // field token or a builtin that reads none.
func tokenOperands(body string) []string { func tokenOperands(body string) []string {