From 6b26b18104c99bce9c32495a69692de9f57c41f9 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 7 Oct 2026 22:55:09 +0200 Subject: [PATCH] Let a declared datatype win over a column read, count a repeat as written, refuse null alone on stdin and a second leading slash, and hand NewTemplate stdin as sent --- CHANGELOG.md | 2 +- README.md | 26 +++++++++---------- cmd/fejkdata/main.go | 28 ++++++++++++--------- datatype.go | 17 +++---------- format.go | 25 +------------------ internal/grammar/template.go | 17 ++++++++++--- node.go | 48 ++++++------------------------------ record.go | 5 +++- render.go | 5 +++- resolve.go | 4 +-- struct.go | 5 +++- valueproof.go | 9 ++----- 12 files changed, 73 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c3a37..8bffbe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ replacement, and each removed path, column or flag. fejkdata`, and adds nothing under `--format text`: `echo`'s newline ends the render, and `--separator` defaults to empty. Whitespace around a JSON template is dropped. A lone reference, `{/users}`, is the record `users` under `--format`. An argument other than a flag, an - empty or blank stdin, or nothing piped in, exits 2. + empty or blank stdin, one holding only `null`, or nothing piped in, exits 2. - Table categories: a category JSON naming a `rows` TSV beside it, with the options `key`, `name`, `weight` and `parent`; a path selects a row by key or name, `misc.territory[SE]`, and descends to a linked table by name, one path drawing each diff --git a/README.md b/README.md index ad41b7a..48bd5fd 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ these paths; a template reads one as `{/path}`. Under `--format text`, the default, what you send is what renders, and fejkdata adds nothing. A format string keeps every byte, so `echo`, a file or a heredoc ends each render with its newline, and `printf '%s'` sends none; `-n` joins renders with `--separator`, empty by default. A JSON template is -an object, array or string, and the whitespace around it is dropped; `null` alone -renders nothing, and `42` or `true` alone is refused. A JSON template that should end +an object, array or string, and the whitespace around it is dropped; `null`, `42` or +`true` alone is refused. A JSON template that should end each render in a newline writes `\n` in its format, from a quoted heredoc or a file. A lone reference, one `{/…}` and nothing else, is the record it names under `--format`, one newline after it allowed. Some shells' `echo` reads a backslash as an escape, so pipe a @@ -65,7 +65,7 @@ template holding one from a quoted heredoc or a file. The rest of stdin's rules follows (`-n3`, `-n 3`) and short flags bundle (`-hn 3`) — see [Decisions](docs/decisions.md#flags-follow-getopt_long); flags go anywhere, `--` ends them. Exit codes: `0` success, `1` runtime error (missing dir, a lone reference to nothing under `--format`), `2` misuse — a bad flag, an argument -other than a flag, an empty, blank or unreadable stdin, nothing piped in, or a +other than a flag, an empty, blank, `null` or unreadable stdin, nothing piped in, or a template that does not compile. From a checkout: `go run ./cmd/fejkdata …`. @@ -336,7 +336,7 @@ generator: with the same error every call. - `Template.Fake` cannot fail. -A path, wherever a call takes one, may start with `/`, as a reference does. +A path, wherever a call takes one, may start with one `/`, as a reference does. A `*Record` carries its columns via `Columns()` — each a `Column` of `Name`, `DataType`, rendered `Value` and `Null` — and serializes them with `JSON()` @@ -409,8 +409,8 @@ are two draws each. Only a [name](#names) keeps one pick for every read of it. Renders e.g. `100 Main St, Apt 4B — tel 555-0199`. Random characters come from the sample functions, so a phone pattern is `070-{digits(3)} {digits(2)} {digits(2)}`. -A lone `}` is a load error naming `}}`; the arms of `{a|b}` must differ, a -repeated arm being a second spelling of [weight](#weight). +A lone `}` is a load error naming `}}`. A repeated arm counts as written, so +`{a|a|b}` draws `a` two times in three. ### Weight @@ -425,10 +425,10 @@ An item of a choice may carry a `weight` (default `1`) to skew its odds: ``` Renders `070-412 38 91` ten times as often as `08-…`. A string item is weighted by -writing it as `{ "format": "AB", "weight": 3 }`. Rejected at load: a weight that -is negative, non-numeric, `0` (never drawn — remove the item) or outside a choice, -and a repeated item — `["a", "a", "b"]` is `[{ "format": "a", "weight": 2 }, "b"]`, -and the error says so. +writing it as `{ "format": "AB", "weight": 3 }`. A repeated item counts as written, +so `["a", "a", "b"]` draws `a` two times in three. Rejected at load: a weight that is +negative, non-numeric or `0` (never drawn — remove the item), and one where it does +nothing: outside a choice, or on the item of a one-item choice. ### Repeat @@ -472,9 +472,9 @@ order.id: datatype integer: "1{digits(2)}" is not one value; write one literal o A column whose format is one read of a record's column and nothing else — `"score": "{/src.score}"`, `"score": "{s.score}"` with `s` bound to `/src`, or `"a": "{p}"` beside `{place as p}` in its own record — is that column: it takes the column's datatype and -is null where the column is, and a struct field tagged `src.score` is nil there. A `datatype` of its own types the -column's values where they prove it, and one restating the datatype it takes is refused. -Any other read renders the column's text, a null as `""`. +is null where the column is, and a struct field tagged `src.score` is nil there. A +`datatype` of its own types the column's values where they prove it, and `"string"` +reads them as text. Any other read renders the column's text, a null as `""`. A typed column's `{calc()}` must be proven to print a number: each operand a number literal, an `{int()}`, `{float()}`, `{seq()}` or `{digits()}` call, a calc, or a read of diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index 5574195..8819f1d 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -45,8 +45,8 @@ path into one ({/sv_SE.person.last}), a table's row by key or name Under --format text, the default, what you send is what renders, and fejkdata adds nothing: a format string keeps every byte, so echo's newline ends each render, and printf '%s' sends none. -n joins renders with --separator, empty by default. A JSON -template is an object, array or string, and the whitespace around it is dropped; null -alone renders nothing, and 42 or true alone is refused. A quoted heredoc, <<'EOF', +template is an object, array or string, and the whitespace around it is dropped; null, +42 or true alone is refused. A quoted heredoc, <<'EOF', passes $, a backslash or a quote as written. With --format json, ndjson, csv or sql the template must be a record — a JSON @@ -291,13 +291,13 @@ func operandError(arg string) error { if fi, err := os.Stat(arg); err == nil && fi.Mode().IsRegular() { return fmt.Errorf("%s; read a file from stdin: fejkdata < %s", unexpected, shellQuoted(arg)) } - example := pipeHint + example, ref := pipeHint, "{/"+strings.TrimPrefix(arg, "/")+"}" if _, lone := loneReference(arg); lone { example = piped(arg) } else if inline, err := fejkdata.IsTemplate(arg); inline { example = piped(arg) - } else if _, lone := loneReference("{/" + arg + "}"); lone && err == nil { - example = piped("{/" + arg + "}") + } else if _, lone := loneReference(ref); lone && err == nil { + example = piped(ref) } return fmt.Errorf("%s; the template is read from stdin: %s", unexpected, example) } @@ -448,24 +448,27 @@ type input struct { record string } -// parseInput reads stdin. A format string is every byte of stdin; a JSON template drops -// the whitespace around it; a lone reference names a record, with one newline ending it +// parseInput reads stdin as the template, and as a record where it is a lone reference: JSON +// with the whitespace around it dropped, else a format string with one newline ending it // allowed. func parseInput(raw string) (input, error) { trimmed := strings.Trim(raw, jsonSpace) - if trimmed == "" { + switch trimmed { + case "": return input{}, fmt.Errorf("stdin holds no template: %s", pipeHint) + case "null": + return input{}, fmt.Errorf("stdin holds only null, as jq prints for a missing key: %s", pipeHint) } - template := raw + text := raw if json.Valid([]byte(trimmed)) { - template = trimmed + text = trimmed } - text, found := strings.CutSuffix(template, "\n") + text, found := strings.CutSuffix(text, "\n") if found { text = strings.TrimSuffix(text, "\r") } record, _ := loneReference(text) - return input{template: template, record: record}, nil + return input{template: raw, record: record}, nil } // loneReference returns the path that text reads when it is one reference from the root @@ -485,6 +488,7 @@ func loneReference(text string) (string, bool) { // isPath reports whether a reference's body after its / is a path alone: no second /, no // folder sigil, and none of the |, ( or " as " a token reads outside a selector. +// It copies the grammar, since the CLI reads only the library's public API. func isPath(body string) bool { if body == "" || strings.ContainsAny(body[:1], "/.") { return false diff --git a/datatype.go b/datatype.go index 9775b18..03eb54f 100644 --- a/datatype.go +++ b/datatype.go @@ -100,7 +100,7 @@ func columnDatatype(n node) DataType { // itemDatatype is the datatype a column item declares, else that of the column it reads. func itemDatatype(t *template) DataType { - if t.datatype == DataTypeString && t.readsColumn != nil { + if !t.declared && t.readsColumn != nil { return columnDatatype(t.readsColumn.column) } return t.datatype @@ -139,7 +139,7 @@ const ( func kindOf(t *template) itemKind { switch { - case t.datatype != DataTypeString: + case t.declared: return kindDeclares case itemDatatype(t) != DataTypeString: return kindReads @@ -187,12 +187,12 @@ func (c clash) retypeRead() error { if c.fits() { return fmt.Errorf("%s, so %s", c.held, typedAs(c.fix, c.want)) } - return fmt.Errorf("%s, so to read %q as text, %s", c.held, c.fix.format, asText(c.fix)) + return fmt.Errorf("%s, so to read %q as text, %s", c.held, c.fix.format, typedAs(c.fix, DataTypeString)) } func (c clash) textBesideRead() error { if !c.fits() { - return fmt.Errorf(`item %q is not %s, the datatype item %q takes from the column it reads; to read that column as text, %s`, c.fix.format, datatype.Noun(c.want), c.other.format, asText(c.other)) + return fmt.Errorf(`item %q is not %s, the datatype item %q takes from the column it reads; to read that column as text, %s`, c.fix.format, datatype.Noun(c.want), c.other.format, typedAs(c.other, DataTypeString)) } return c.declareText() } @@ -212,12 +212,3 @@ func typedAs(t *template, d DataType) string { } return fmt.Sprintf(`give %q "datatype": %q`, t.format, d) } - -// asText names the spelling that reads the column a column-read item reads as text, keeping the -// other keys an object item carries. -func asText(t *template) string { - if t.fromString { - return fmt.Sprintf(`write {"format":"{text}","text":%q}`, t.format) - } - return fmt.Sprintf(`set its "format" to "{text}" and add "text": %q`, t.format) -} diff --git a/format.go b/format.go index 07e4511..db7015a 100644 --- a/format.go +++ b/format.go @@ -61,13 +61,7 @@ func checkToken(t grammar.Token, fields map[string]node) ([]unboundRead, error) case grammar.NameBind: return nil, checkBind(t, fields) case grammar.PathRead: - unbound, err := checkReads(t, fields, false) - if err != nil { - return nil, err - } - // Last, so an arm broken on its own terms is reported as that: a repeat is - // the consequence of such a mistake, not the mistake itself. - return unbound, checkNoRepeatedArm(t.Body, t.Arms) + return checkReads(t, fields, false) } return nil, nil } @@ -178,23 +172,6 @@ func tokenReads(t grammar.Token) []string { return t.Arms } -// checkNoRepeatedArm rejects {a|a|b}: an alternation draws its arms evenly, so a -// repeated one is a second spelling of weight. The error names the spelling that -// does skew a draw. -func checkNoRepeatedArm(body string, names []string) error { - if len(names) < 2 { - return nil - } - seen := make(map[string]bool, len(names)) - for _, name := range names { - if seen[name] { - return fmt.Errorf("token {%s}: arm %q is repeated; an alternation draws its arms evenly, so skew the odds with a choice's weights instead", body, name) - } - seen[name] = true - } - return nil -} - // formatOps is a compiled format: its ops, and the size of its literal text. type formatOps struct { ops []op diff --git a/internal/grammar/template.go b/internal/grammar/template.go index 5d1e7b7..8db90dd 100644 --- a/internal/grammar/template.go +++ b/internal/grammar/template.go @@ -16,12 +16,23 @@ func IsTemplate(arg string) (bool, error) { if strings.HasPrefix(strings.TrimSpace(arg), "[") { return false, fmt.Errorf(`%q starts with "[" and is not valid JSON, so it names no template; a path starts with a name`, arg) } - _, err := SplitPath(CallerPath(arg)) + path, err := CallerPath(arg) + if err != nil { + return false, err + } + _, err = SplitPath(path) return false, err } -// CallerPath is a path as a caller passed it, without the slashes it may start with. -func CallerPath(path string) string { return strings.TrimLeft(path, "/") } +// CallerPath is a path as a caller passed it, without the one / it may start with, as a +// reference does. +func CallerPath(path string) (string, error) { + rest := strings.TrimPrefix(path, "/") + if strings.HasPrefix(rest, "/") { + return "", fmt.Errorf("path %s starts with more than one /; write /%s", path, strings.TrimLeft(rest, "/")) + } + return rest, nil +} func isJSONStart(arg string) bool { return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`) diff --git a/node.go b/node.go index ae255c6..7404199 100644 --- a/node.go +++ b/node.go @@ -1,7 +1,6 @@ package fejkdata import ( - "encoding/json" "fmt" "math" @@ -53,6 +52,7 @@ type template struct { repeat int separator string datatype DataType + declared bool // carries a "datatype", which wins over the datatype of a column it reads fromString bool // written as a JSON string rather than an object isRecord bool // compiled at the top without a repeat, so its fields are record columns unbound []unboundRead // the heads its tokens read that no field holds @@ -186,17 +186,11 @@ func compileChoice(items []any, pos position) (node, error) { if pos == inColumn { itemPos = inColumn } - if len(items) == 0 { + switch len(items) { + case 0: return nil, fmt.Errorf("empty choice") - } - if len(items) == 1 { - if _, err := weightOf(items[0]); err != nil { - return nil, err - } - return compileItem(items[0], pos) - } - if err := checkNoRepeatedItem(items); err != nil { - return nil, err + case 1: + return compileAt(items[0], pos) } c := &choice{items: make([]node, len(items))} cum := make([]float64, len(items)) @@ -228,34 +222,6 @@ func compileChoice(items []any, pos position) (node, error) { return c, nil } -// checkNoRepeatedItem rejects a choice that lists one item twice: a draw is even -// over the items, so a repeat is a second spelling of weight. The error names the -// spelling that does skew a draw. -func checkNoRepeatedItem(items []any) error { - seen := make(map[string]int, len(items)) - for i, raw := range items { - key, isString := raw.(string) - if !isString { - b, err := json.Marshal(raw) - if err != nil { - return err - } - key = "\x00" + string(b) - } - if j, dup := seen[key]; dup { - if s, isString := raw.(string); isString { - return fmt.Errorf("choice item %q is repeated; skew the odds with a weight instead: { \"format\": %q, \"weight\": 2 }", s, s) - } - if raw == nil { - return fmt.Errorf("choice item %d repeats null; a null takes no weight, so weight the other items instead", i) - } - return fmt.Errorf("choice item %d repeats item %d; skew the odds with a weight on one of them instead", i, j) - } - seen[key] = i - } - return nil -} - func compileTemplate(m map[string]any, pos position) (node, error) { if _, isTable := m["rows"]; isTable { return nil, fmt.Errorf("rows names a TSV beside a category's file, so only a category is a table; an inline template has no file beside it") @@ -276,12 +242,13 @@ func compileTemplate(m map[string]any, pos position) (node, error) { if err != nil { return nil, err } - return &template{format: o.format, tokens: toks, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype, isRecord: fieldPos == inColumn, unbound: unbound}, nil + return &template{format: o.format, tokens: toks, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype, declared: o.declared, isRecord: fieldPos == inColumn, unbound: unbound}, nil } // templateOptions is what a template object's option keys say. type templateOptions struct { datatype DataType + declared bool format string repeat int separator string @@ -299,6 +266,7 @@ func readOptions(m map[string]any, pos position) (templateOptions, error) { return o, err } o.repeat = repeat + _, o.declared = m["datatype"] if o.datatype, err = datatypeOf(m, pos); err != nil { return o, err } diff --git a/record.go b/record.go index 3b321ae..5f720ba 100644 --- a/record.go +++ b/record.go @@ -125,7 +125,10 @@ func literal(c Column, quote func(string) string, nullText string) string { func (f *Generator) FakeRecord(path string) (*Record, error) { f.mu.Lock() defer f.mu.Unlock() - path = grammar.CallerPath(path) + path, err := grammar.CallerPath(path) + if err != nil { + return nil, fmt.Errorf("fejkdata: %w", err) + } segments, err := grammar.SplitPath(path) if err != nil { return nil, fmt.Errorf("fejkdata: %w", err) diff --git a/render.go b/render.go index b203c67..28d28fa 100644 --- a/render.go +++ b/render.go @@ -16,7 +16,10 @@ import ( func (f *Generator) Fake(path string) (string, error) { f.mu.Lock() defer f.mu.Unlock() - path = grammar.CallerPath(path) + path, err := grammar.CallerPath(path) + if err != nil { + return "", fmt.Errorf("fejkdata: %w", err) + } segments, err := grammar.SplitPath(path) if err != nil { return "", fmt.Errorf("fejkdata: %w", err) diff --git a/resolve.go b/resolve.go index 7787f3b..99c47a2 100644 --- a/resolve.go +++ b/resolve.go @@ -101,10 +101,10 @@ type columnRead struct { } // columnReadOf is the record's column t's format reads, where the format only reads one reference -// or name, and nil where it does not. +// or name, and nil where it does not or t declares a string, which reads the column as text. func columnReadOf(t *template, targets map[*nameBinding]nameTarget) *columnRead { ops := t.compiled.ops - if t.repeat != 1 || len(ops) != 1 || ops[0].Kind != grammar.PathRead || len(ops[0].arms) != 1 { + if t.declared && t.datatype == DataTypeString || t.repeat != 1 || len(ops) != 1 || ops[0].Kind != grammar.PathRead || len(ops[0].arms) != 1 { return nil } a := ops[0].arms[0] diff --git a/struct.go b/struct.go index a323ad2..1990f53 100644 --- a/struct.go +++ b/struct.go @@ -228,7 +228,10 @@ func tagValue(sf reflect.StructField, tag string) (any, error) { case inline: return inputValue(tag), nil } - path := grammar.CallerPath(tag) + path, err := grammar.CallerPath(tag) + if err != nil { + return nil, err + } if err := grammar.CheckPathIdentifiers(path); err != nil { return nil, err } diff --git a/valueproof.go b/valueproof.go index 2621741..ae2e179 100644 --- a/valueproof.go +++ b/valueproof.go @@ -16,17 +16,12 @@ type valueProof struct { columnMemo map[node]proven.Facts } -// checkDatatype rejects a typed column item some render of which is not text of its datatype, -// and one restating the datatype of the column it is. -// docs/decisions.md#a-column-that-only-reads-one-reference-or-name-is-the-column-it-reads +// checkDatatype rejects a typed column item some render of which is not text of its datatype. func (p *valueProof) checkDatatype(label string, n node) error { t, ok := n.(*template) if !ok || t.datatype == DataTypeString { return nil } - if r := t.readsColumn; r != nil && columnDatatype(r.column) == t.datatype { - return fmt.Errorf(`%s: %s takes datatype %s from the column it reads; drop "datatype"`, label, t.format, t.datatype) - } if reason := p.proveColumnItem(t).Not[t.datatype]; reason != "" { return fmt.Errorf("%s: datatype %s: %s", label, t.datatype, reason) } @@ -38,7 +33,7 @@ func (p *valueProof) checkDatatype(label string, n node) error { func (p *valueProof) checkField(label string, ft reflect.Type, column node) error { items, _ := columnItems(column) for _, it := range items { - if it.datatype != DataTypeString { + if it.declared { return fmt.Errorf("%s: its Go type %s sets the datatype; drop \"datatype\"", label, ft) } }