One CLI classifier settling the argument shape before load; JSON strings are templates and a quote is reserved in names

This commit is contained in:
2026-09-03 20:34:42 +02:00
parent 80e8d72bb7
commit 6916894ab5
2 changed files with 63 additions and 43 deletions
+51 -36
View File
@@ -27,11 +27,11 @@ const usage = `Usage: fejkdata [flags] <path|template>
<template> a format string or JSON value to render inline, e.g. <template> a format string or JSON value to render inline, e.g.
'name: {/sv_SE.person.last}' or '{"format":"{x}","x":["bosse","lina"]}' 'name: {/sv_SE.person.last}' or '{"format":"{x}","x":["bosse","lina"]}'
An argument containing a { token, or a JSON object or array, is a template; any An argument containing a { token, or a JSON object, array or string, is a
other argument is a path (a path never contains a brace). Templates reach the data template; any other argument is a path (a path never contains a brace, a bracket
with a reference: {/sv_SE.person.first} from the root, whether the data is shipped or a quote). Templates reach the data by reference from the root —
or layered with --data-path. A [ that is not valid JSON names no template and no {/sv_SE.person.first} — whether the data is shipped or layered with --data-path. A
path. bracket that is not valid JSON names no template and no path.
-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
@@ -195,18 +195,22 @@ func parseArgs(argv []string) (invocation, error) {
return in, nil return in, nil
} }
// check rejects a flag combination that cannot run. // check rejects a flag combination or an argument that cannot run, and reports
func (in invocation) check() error { // what the argument names, so its shape is settled before any data is read.
func (in invocation) check() (argKind, error) {
if in.list && len(in.paths) > 0 { if in.list && len(in.paths) > 0 {
return errors.New("--list takes no path") return argPath, errors.New("--list takes no path")
} }
if in.list && (in.repeatSet || in.separatorSet) { if in.list && (in.repeatSet || in.separatorSet) {
return errors.New("--list takes no --repeat or --separator") return argPath, errors.New("--list takes no --repeat or --separator")
} }
if !in.list && len(in.paths) != 1 { if in.list {
return fmt.Errorf("expected one path, got %d", len(in.paths)) return argPath, nil
} }
return nil if len(in.paths) != 1 {
return argPath, fmt.Errorf("expected one path, got %d", len(in.paths))
}
return classify(in.paths[0])
} }
func (in invocation) options() []fejkdata.Option { func (in invocation) options() []fejkdata.Option {
@@ -223,24 +227,19 @@ func (in invocation) options() []fejkdata.Option {
return opts return opts
} }
// write streams the path's renders to w, repeat of them joined by the separator // write streams the argument's renders to w, repeat of them joined by the
// and ended by a newline. A path that renders once renders every time, so a Fake // separator and ended by a newline. A value that renders once renders every time,
// failure comes before anything is written; a write failure surfaces from Flush, // so a render failure comes before anything is written; a write failure surfaces
// bufio keeping the first one. // from Flush, bufio keeping the first one.
func (in invocation) write(f *fejkdata.Generator, w io.Writer) error { func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error {
arg := in.paths[0] arg := in.paths[0]
var draw func() (string, error) draw := func() (string, error) { return f.Fake(arg) }
switch { if kind == argTemplate {
case isTemplate(arg):
t, err := f.NewTemplate(arg) t, err := f.NewTemplate(arg)
if err != nil { if err != nil {
return templateError{err} return templateError{err}
} }
draw = func() (string, error) { return t.Fake(), nil } draw = func() (string, error) { return t.Fake(), nil }
case strings.HasPrefix(arg, "["):
return templateError{fmt.Errorf("%q starts with [ but is not valid JSON, so it names no template and no path", arg)}
default:
draw = func() (string, error) { return f.Fake(arg) }
} }
out := bufio.NewWriter(w) out := bufio.NewWriter(w)
for i := 0; i < in.repeat; i++ { for i := 0; i < in.repeat; i++ {
@@ -264,16 +263,30 @@ type templateError struct{ error }
func (e templateError) Unwrap() error { return e.error } func (e templateError) Unwrap() error { return e.error }
// isTemplate reports whether an argument is an inline template rather than a type argKind int
// path: a format string carrying a { token, or a JSON object or array. A name may
// not contain a brace or bracket, so both spellings collide with no path; the [ const (
// gate is valid-JSON so a [ alone never swallows an argument that merely began argPath argKind = iota
// with a copied bracket. argTemplate
func isTemplate(arg string) bool { )
if strings.ContainsRune(arg, '{') {
return true // classify reads what a positional argument names by its shape: a format string
// carrying a { token, or a JSON object, array or string, is an inline template;
// 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) {
if strings.ContainsRune(arg, '{') || (isJSONStart(arg) && json.Valid([]byte(arg))) {
return argTemplate, nil
} }
return strings.HasPrefix(arg, "[") && json.Valid([]byte(arg)) 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, nil
}
func isJSONStart(arg string) bool {
return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`)
} }
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
@@ -292,7 +305,8 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stdout, "fejkdata "+buildVersion()) fmt.Fprintln(stdout, "fejkdata "+buildVersion())
return 0 return 0
} }
if err := in.check(); err != nil { kind, err := in.check()
if err != nil {
return misuse(stderr, err) return misuse(stderr, err)
} }
f, err := fejkdata.New(in.options()...) f, err := fejkdata.New(in.options()...)
@@ -309,7 +323,7 @@ func run(args []string, stdout, stderr io.Writer) int {
} }
return 0 return 0
} }
if err := in.write(f, stdout); err != nil { if err := in.write(f, kind, stdout); err != nil {
var te templateError var te templateError
if errors.As(err, &te) { if errors.As(err, &te) {
return misuse(stderr, te.error) return misuse(stderr, te.error)
@@ -321,7 +335,8 @@ func run(args []string, stdout, stderr io.Writer) int {
} }
func misuse(stderr io.Writer, err error) int { func misuse(stderr io.Writer, err error) int {
fmt.Fprintf(stderr, "fejkdata: %v\ntry 'fejkdata --help'\n", err) // A library error already names the program, so the prefix is not doubled.
fmt.Fprintf(stderr, "fejkdata: %s\ntry 'fejkdata --help'\n", strings.TrimPrefix(err.Error(), "fejkdata: "))
return 2 return 2
} }
+12 -7
View File
@@ -140,12 +140,17 @@ func (t *template) compileFormat() error {
// when the input is not JSON. A JSON string literal and a bare string compile // when the input is not JSON. A JSON string literal and a bare string compile
// alike (both are a template with no fields); the other JSON scalars — a number, // 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 // bool or null — are no template, so they are rejected here, as a data file that
// was one would be at load. // 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 {
return compile(input) return compile(input)
} }
if trimmed := strings.TrimSpace(input); trimmed != input {
return nil, fmt.Errorf("a JSON template may not be padded with spaces, which a format string would render; write %s", trimmed)
}
return compile(raw) return compile(raw)
} }
@@ -350,15 +355,15 @@ func weightOf(raw any) (float64, error) {
// reservedInName is what a category, folder or field name may not contain: a dot // reservedInName is what a category, folder or field name may not contain: a dot
// separates the segments of a path, '|' the arms of a token, '(' opens a function // separates the segments of a path, '|' the arms of a token, '(' opens a function
// call, braces delimit the token, '/' starts a reference, and a bracket would be // call, braces delimit the token, '/' starts a reference, and brackets and a quote
// misread by the command line as a JSON array. A name carrying one is rejected // open a JSON value. A name carrying one is rejected where it is authored rather
// where it is authored rather than where it would be unreachable. // than where it would be unreachable.
const reservedInName = ".|({}/[]" const reservedInName = ".|({}/[]\""
// reservedList spells reservedInName for an error message, so the two cannot drift. // reservedList spells reservedInName for an error message, so the two cannot drift.
var reservedList = strings.Join(strings.Split(reservedInName, ""), " ") var reservedList = strings.Join(strings.Split(reservedInName, ""), " ")
// checkName rejects a name the dot path, {token} and CLI grammars cannot spell. // checkName rejects a name the dot path, {token} and JSON grammars cannot spell.
// Both a category or folder and a field go through it, so there is one answer to // Both a category or folder and a field go through it, so there is one answer to
// what a name may contain. // what a name may contain.
func checkName(name string) error { func checkName(name string) error {
@@ -366,7 +371,7 @@ func checkName(name string) error {
return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name) return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name)
} }
if i := strings.IndexAny(name, reservedInName); i >= 0 { if i := strings.IndexAny(name, reservedInName); i >= 0 {
return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path, {token} and CLI grammars reserve", return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path, {token} and JSON grammars reserve",
name, name[i:i+1], reservedList) name, name[i:i+1], reservedList)
} }
return nil return nil