Answer architecture round 3, stability round 2, product-owner round 2 and prose round 1: the CLI renders exactly the template on stdin and adds nothing, --separator defaults to empty, whitespace around JSON is dropped, a lone reference under --format allows one newline, a blank stdin is refused, an unexpected argument names the spelling to use, and the prose names one lone reference

This commit is contained in:
2026-10-07 21:39:06 +02:00
committed by Lillem4n
parent 58a7b16a7f
commit e5babf413d
9 changed files with 188 additions and 182 deletions
+71 -76
View File
@@ -28,38 +28,39 @@ const usage = `Usage: <template> | fejkdata [flags]
fejkdata reads one template from stdin and renders it:
echo 'name: {/sv_SE.person.last}' | fejkdata
echo '{"format":"{x}","x":["bosse","lina"]}' | fejkdata
echo '{"format":"{x}\n","x":["bosse","lina"]}' | fejkdata
fejkdata < template.txt
fejkdata <<'EOF'
born {date(1990-01-01,2010-12-31,'2006-01-02')}
EOF
fejkdata <<'EOF'
born {date(1990-01-01,2010-12-31,'2006-01-02')}
EOF
A template is a format string, or a JSON object, array or string. Its {…} tokens
reach the data by reference from the root, {/sv_SE.person.last}, whether the data
is shipped or layered with --data-path; everything outside them prints as written,
and stdin that is JSON is read as JSON, so 42, true or null alone is refused.
A reference names a category or a dotted path into one ({/sv_SE.person.last}), a
table's row by key or name ({/misc.territory[SE]}), and .. steps up from a row to
the row it links to ({/geo.SE.locality..municipality.name}).
is shipped or layered with --data-path. A reference names a category or a dotted
path into one ({/sv_SE.person.last}), a table's row by key or name
({/misc.territory[SE]}), and .. steps up from a row to the row it links to
({/geo.SE.locality..municipality.name}).
A format string keeps every byte of stdin, so echo's newline renders too. JSON and a
lone reference drop one newline ending stdin, \n or \r\n, which neither could print.
A quoted heredoc, <<'EOF', passes a template holding $, a backslash or a quote as
written.
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 its JSON, the
whitespace around it dropped, so 42, true or null 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 name a record — a
template whose fields are its columns, or one reference alone, {/sv_SE.person} —
and the rows are written as one JSON array, one JSON object per line, one CSV row
(after a header), or one INSERT.
With --format json, ndjson, csv or sql the template must be a record — a JSON
template whose fields are its columns, or a lone reference, {/sv_SE.person}, one
newline after it allowed — and the rows are written as one JSON array, one JSON
object per line, one CSV row (after a header), or one INSERT.
-d, --data-path D a data directory to layer over the shipped data (repeatable; last wins on a clash)
--format F output form: text (default), json, ndjson, csv or sql
-h, --help print this help, then exit
--list list the paths the data offers, each one a reference's path, then exit
--list list the paths the data offers, each one a reference from the root, then exit
--no-shipped-data load only the --data-path directories
-n, --repeat N render the template N times, 1..1048576 (default 1)
-s, --seed N same seed, version and data: identical output
--separator S string between repeated values (default newline)
--separator S string between repeated renders (default empty)
--table T the INSERT target for --format sql (default: a lone reference's last segment, else records)
--version print the version, then exit
@@ -184,7 +185,7 @@ func splitFlags(arg string) ([]flagArg, error) {
}
func parseArgs(argv []string) (invocation, error) {
in := invocation{repeat: 1, separator: "\n", format: "text"}
in := invocation{repeat: 1, format: "text"}
for i := 0; i < len(argv); i++ {
arg := argv[i]
if arg == "--" {
@@ -277,16 +278,21 @@ func (in invocation) checkFlags() error {
const pipeHint = "echo '{/sv_SE.person}' | fejkdata"
// operandError refuses an operand, naming the spelling that reads what it most likely
// means: stdin for -, a file for a file's name, else the template piped in.
// operandError refuses an argument that is no flag, naming the spelling that reads what
// it most likely means: stdin for -, a file for a file's name, else a template piped in.
func operandError(arg string) error {
unexpected := "unexpected argument '" + arg + "'"
if arg == "-" {
return errors.New("fejkdata takes no operand, and reads stdin already; drop the -")
return fmt.Errorf("%s; stdin is read already, so drop it", unexpected)
}
if fi, err := os.Stat(arg); err == nil && fi.Mode().IsRegular() {
return fmt.Errorf("fejkdata takes no operand; read the file from stdin: fejkdata < %s", arg)
return fmt.Errorf("%s; read a file from stdin: fejkdata < %s", unexpected, shellQuoted(arg))
}
return fmt.Errorf("fejkdata takes no operand; pipe the template to stdin: %s", pipeHint)
example := pipeHint
if inline, err := fejkdata.IsTemplate(arg); !inline && err == nil {
example = "echo '{/" + arg + "}' | fejkdata"
}
return fmt.Errorf("%s; the template is read from stdin: %s", unexpected, example)
}
// check rejects a flag combination or an operand that cannot run, before any data is
@@ -318,50 +324,32 @@ func (in invocation) options() []fejkdata.Option {
return opts
}
// write streams the input's renders to w, repeat of them joined by the
// separator and ended by a newline. A value that renders once renders every time,
// so a render failure comes before anything is written; a write failure surfaces
// from Flush, bufio keeping the first one.
// write streams the input's renders to w, repeat of them joined by the separator. A
// value that renders once renders every time, so a render failure comes before anything
// is written; a write failure surfaces from Flush, bufio keeping the first one.
func (in invocation) write(f *fejkdata.Generator, src input, w io.Writer) error {
if in.writesRecords() {
return in.writeRecords(f, src, w)
}
draw, err := textDraw(f, src)
t, err := f.NewTemplate(src.template)
if err != nil {
return err
return inputError{err}
}
out := bufio.NewWriter(w)
for i := 0; i < in.repeat; i++ {
v, err := draw()
if err != nil {
return err
}
if i > 0 {
out.WriteString(in.separator)
}
out.WriteString(v)
out.WriteString(t.Fake())
}
out.WriteString("\n")
return out.Flush()
}
// textDraw builds what one text render yields: the value, from a path or a template.
func textDraw(f *fejkdata.Generator, src input) (func() (string, error), error) {
if src.path != "" {
return func() (string, error) { return f.Fake(src.path) }, nil
}
t, err := f.NewTemplate(src.template)
if err != nil {
return nil, inputError{err}
}
return func() (string, error) { return t.Fake(), nil }, nil
}
// recordStream builds the record drawer for the input, plus the INSERT table
// a sql format names.
func (in invocation) recordStream(f *fejkdata.Generator, src input) (func() (*fejkdata.Record, error), string, error) {
record := func() (*fejkdata.Record, error) { return f.FakeRecord(src.path) }
if src.path == "" {
record := func() (*fejkdata.Record, error) { return f.FakeRecord(src.record) }
if src.record == "" {
t, err := f.NewRecordTemplate(src.template)
if err != nil {
return nil, "", inputError{spacedReference(src.template, err)}
@@ -370,7 +358,7 @@ func (in invocation) recordStream(f *fejkdata.Generator, src input) (func() (*fe
}
table := in.table
if table == "" {
table = defaultTable(src.path)
table = defaultTable(src.record)
}
return record, table, nil
}
@@ -411,7 +399,7 @@ func (in invocation) writeRecords(f *fejkdata.Generator, src input, w io.Writer)
}
// defaultTable names the INSERT target when --table is absent: the path's last
// segment, or "records" for a template that reads no path alone.
// segment, or "records" when stdin is not a lone reference.
func defaultTable(path string) string {
if path == "" {
return "records"
@@ -438,38 +426,37 @@ type inputError struct{ error }
func (e inputError) Unwrap() error { return e.error }
// input is what stdin names: the path a lone reference reads, or else the template.
// input is what stdin holds: the template, and the path a lone reference names as a
// record.
type input struct {
path string
template string
record string
}
// parseInput reads stdin's text. A format string keeps every byte; JSON or a lone
// reference sheds one newline ending it, which neither could print.
// parseInput reads stdin. A format string is its every byte; a JSON template sheds the
// whitespace around it; a lone reference names a record less one newline ending it.
func parseInput(raw string) (input, error) {
if raw == "" {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return input{}, fmt.Errorf("stdin holds no template: %s", pipeHint)
}
if text, found := strings.CutSuffix(raw, "\n"); found {
template := raw
if strings.ContainsRune(`{["`, rune(trimmed[0])) && json.Valid([]byte(trimmed)) {
template = trimmed
}
text, found := strings.CutSuffix(template, "\n")
if found {
text = strings.TrimSuffix(text, "\r")
if path, lone := loneReference(text); lone {
return input{path: path}, nil
}
if json.Valid([]byte(text)) {
return input{template: text}, nil
}
}
if path, lone := loneReference(raw); lone {
return input{path: path}, nil
}
return input{template: raw}, nil
record, _ := loneReference(text)
return input{template: template, record: record}, nil
}
// loneReference is the path that text, one reference from the root and nothing else,
// reads: the path IsTemplate advises writing instead. A JSON string holding one is one.
// loneReference returns the path that text reads when it is one reference from the root
// and nothing else, bare or as a JSON string: the path IsTemplate advises writing instead.
func loneReference(text string) (string, bool) {
var unquoted string
if json.Unmarshal([]byte(text), &unquoted) == nil {
if strings.HasPrefix(text, `"`) && strings.HasSuffix(text, `"`) && json.Unmarshal([]byte(text), &unquoted) == nil {
text = unquoted
}
if !strings.HasPrefix(text, "{/") || !strings.HasSuffix(text, "}") {
@@ -485,10 +472,10 @@ func loneReference(text string) (string, bool) {
return path, true
}
// spacedReference adds to err, a refusal of template as a record, that the whitespace
// around a lone reference made it text, where it does.
// spacedReference adds to a record refusal that whitespace around a lone reference made
// the template text.
func spacedReference(template string, err error) error {
if trimmed := strings.TrimSpace(template); trimmed != template {
if trimmed := strings.TrimSpace(template); trimmed != template && errors.Is(err, fejkdata.ErrNoColumns) {
if path, lone := loneReference(trimmed); lone {
return fmt.Errorf("%w; the whitespace around {/%s} makes it text, so remove it to write the record %s", err, path, path)
}
@@ -496,6 +483,14 @@ func spacedReference(template string, err error) error {
return err
}
// shellQuoted is s as a shell reads it back: bare where it holds nothing a shell expands.
func shellQuoted(s string) string {
if s != "" && !strings.ContainsAny(s, " \t'\"$`\\*?[]{}()<>|&;#~!") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func main() {
var stdin io.Reader = os.Stdin
if fi, err := os.Stdin.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
@@ -512,7 +507,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return misuse(stderr, err)
}
if in.help {
fmt.Fprint(stdout, usage)
io.WriteString(stdout, usage)
return 0
}
if in.version {
+47 -41
View File
@@ -36,11 +36,8 @@ func TestRunOutputsValue(t *testing.T) {
if code != 0 {
t.Fatalf("run = %d, stderr=%q", code, errb)
}
if strings.TrimSpace(out) == "" {
t.Fatalf("empty output, stderr=%q", errb)
}
if !strings.HasSuffix(out, "\n") {
t.Errorf("output should end with newline, got %q", out)
if out == "" || strings.Contains(out, "\n") {
t.Fatalf("output = %q, stderr=%q; want one value and nothing added", out, errb)
}
}
@@ -93,7 +90,7 @@ func TestRunShortFlagValues(t *testing.T) {
t.Errorf("run(%v) = %q, want %q", args, got, want)
}
}
_, three, _ := runOut("{/word}", "-s", "1", "-n3", "-d", svSE)
_, three, _ := runOut("{/word}\n", "-s", "1", "-n3", "-d", svSE)
if lines := strings.Split(strings.TrimRight(three, "\n"), "\n"); len(lines) != 3 {
t.Errorf("-n3 gave %d lines: %q", len(lines), three)
}
@@ -177,7 +174,7 @@ func TestRunFlagValues(t *testing.T) {
}
func TestRunRepeat(t *testing.T) {
code, out, errb := runOut("{/word}", "--seed", "1", "--repeat", "3", "--data-path", svSE)
code, out, errb := runOut("{/word}\n", "--seed", "1", "--repeat", "3", "--data-path", svSE)
if code != 0 {
t.Fatalf("run = %d, stderr=%q", code, errb)
}
@@ -185,7 +182,7 @@ func TestRunRepeat(t *testing.T) {
if len(lines) != 3 {
t.Errorf("repeat=3 gave %d lines: %q", len(lines), out)
}
_, short, _ := runOut("{/word}", "--seed", "1", "-n", "3", "-d", svSE)
_, short, _ := runOut("{/word}\n", "--seed", "1", "-n", "3", "-d", svSE)
if short != out {
t.Errorf("-n 3 = %q, want the same as --repeat 3 %q", short, out)
}
@@ -196,8 +193,8 @@ func TestRunSeparator(t *testing.T) {
if code != 0 {
t.Fatalf("run = %d, stderr=%q", code, errb)
}
if n := strings.Count(out, "\n"); n != 1 {
t.Errorf("want one trailing newline, got %d: %q", n, out)
if n := strings.Count(out, "\n"); n != 0 {
t.Errorf("want no newline, got %d: %q", n, out)
}
if !strings.Contains(out, ",") {
t.Errorf("values should be comma-joined: %q", out)
@@ -205,7 +202,7 @@ func TestRunSeparator(t *testing.T) {
}
func TestRunRepeatAdvancesRNG(t *testing.T) {
code, out, errb := runOut("{/person}", "--seed", "1", "--repeat", "5", "--data-path", svSE)
code, out, errb := runOut("{/person}\n", "--seed", "1", "--repeat", "5", "--data-path", svSE)
if code != 0 {
t.Fatalf("run = %d, stderr=%q", code, errb)
}
@@ -253,12 +250,13 @@ func TestRunTakesNoOperand(t *testing.T) {
}{
{[]string{"sv_SE.person"}, "echo '{/sv_SE.person}' | fejkdata"},
{[]string{"{/sv_SE.person}"}, "echo '{/sv_SE.person}' | fejkdata"},
{[]string{"name:", "{/sv_SE.person.last}"}, "echo '{/sv_SE.person}' | fejkdata"},
{[]string{"-"}, "drop the -"},
{[]string{"name: {/sv_SE.person.last}"}, "echo '{/sv_SE.person}' | fejkdata"},
{[]string{"en_US.address"}, "echo '{/en_US.address}' | fejkdata"},
{[]string{"-"}, "drop it"},
{[]string{file}, "fejkdata < " + file},
} {
code, out, errb := runOut("", c.args...)
if code != 2 || out != "" || !strings.Contains(errb, "takes no operand") || !strings.Contains(errb, c.want) {
if code != 2 || out != "" || !strings.Contains(errb, "unexpected argument") || !strings.Contains(errb, c.want) {
t.Errorf("run(%v) = %d, %q, %q; want misuse naming %q", c.args, code, out, errb, c.want)
}
}
@@ -287,33 +285,43 @@ func TestRunTerminalStdinIsMisuse(t *testing.T) {
}
}
func TestRunNewlineEndingStdin(t *testing.T) {
func TestRunRendersExactlyWhatIsSent(t *testing.T) {
for stdin, want := range map[string]string{
"hihi": "hihi\n",
"hihi\n": "hihi\n\n",
"hihi\r\n": "hihi\r\n\n",
"\n": "\n\n",
"{{x}}\n": "{x}\n\n",
"\"a\"\n": "a\n",
"\"a\"\r\n": "a\n",
"{/misc.territory[SE].alpha2}\n": "SE\n",
"{/misc.territory[SE].alpha2}\r\n": "SE\n",
"hihi": "hihi",
"hihi\n": "hihi\n",
"hihi\r\n": "hihi\r\n",
"hihi\n\n": "hihi\n\n",
"{{x}}\n": "{x}\n",
"\"a\"\n": "a",
" \t\"a\"\r\n\n": "a",
"{/misc.territory[SE].alpha2}": "SE",
"{/misc.territory[SE].alpha2}\n": "SE\n",
} {
code, out, errb := runOut(stdin, "--seed", "1")
if code != 0 || out != want {
t.Errorf("run(%q) = %d, %q, %q; want %q", stdin, code, out, errb, want)
}
}
code, out, errb := runOut("x: {/sv_SE.person.last}\n", "--seed", "1")
if code != 0 || !strings.HasPrefix(out, "x: ") || !strings.HasSuffix(out, "\n\n") || strings.Count(out, "\n") != 2 {
t.Errorf("echo's format string = %d, %q, %q; want echo's newline, then the line's", code, out, errb)
if code, out, errb := runOut("{\"format\":\"{x}\\n\",\"x\":[\"a\",\"b\"]}\n"); code != 0 || (out != "a\n" && out != "b\n") {
t.Errorf("a JSON template's format = %d, %q, %q; want its own newline only", code, out, errb)
}
if code, out, errb := runOut("{/sv_SE.word}\n", "--seed", "1", "-n", "3", "--separator", ","); code != 0 || strings.Count(out, "\n,") != 2 || !strings.HasSuffix(out, "\n") {
t.Errorf("echo's newline and a separator = %d, %q, %q; want each render's newline, then the separator", code, out, errb)
}
}
func TestRunRefusesBlankStdin(t *testing.T) {
for _, stdin := range []string{"", "\n", " \r\n\t"} {
if code, out, errb := runOut(stdin); code != 2 || out != "" || !strings.Contains(errb, "holds no template") {
t.Errorf("run(%q) = %d, %q, %q; want misuse naming the empty stdin", stdin, code, out, errb)
}
}
}
func TestRunTextPrintsAsWritten(t *testing.T) {
for _, stdin := range []string{"sv_SE.person", "[Skåne län]", "x[1]y", `say "hi"`} {
code, out, errb := runOut(stdin)
if code != 0 || out != stdin+"\n" {
if code != 0 || out != stdin {
t.Errorf("run(%q) = %d, %q, %q; want the text as written", stdin, code, out, errb)
}
}
@@ -330,7 +338,7 @@ func TestRunMultipleDataPaths(t *testing.T) {
}
func TestRunUnknownCategoryFails(t *testing.T) {
code, _, errb := runOut("{/nope}", "--data-path", svSE)
code, _, errb := runOut("{/nope}", "--data-path", svSE, "--format", "csv")
if code != 1 {
t.Fatalf("run = %d, want 1", code)
}
@@ -394,7 +402,7 @@ func TestRunShippedDataByDefault(t *testing.T) {
t.Errorf("--list without --data-path = %d, %q", code, list)
}
code, _, errb = runOut("{/person}")
if code != 1 || !strings.Contains(errb, "person") {
if code != 2 || !strings.Contains(errb, "person") {
t.Errorf("a category outside the shipped tree: code %d, stderr %q", code, errb)
}
}
@@ -409,17 +417,17 @@ func TestRunSelectsATableRow(t *testing.T) {
t.Fatal(err)
}
}
if code, out, errb := runOut("{/region[12]}", "--no-shipped-data", "-d", dir); code != 0 || out != "Skåne län\n" {
if code, out, errb := runOut("{/region[12]}", "--no-shipped-data", "-d", dir); code != 0 || out != "Skåne län" {
t.Fatalf("{/region[12]} = %d, %q, stderr=%q", code, out, errb)
}
if code, out, _ := runOut("{/region[Skåne län].code}", "--no-shipped-data", "-d", dir); code != 0 || out != "12\n" {
if code, out, _ := runOut("{/region[Skåne län].code}", "--no-shipped-data", "-d", dir); code != 0 || out != "12" {
t.Fatalf("{/region[Skåne län].code} = %d, %q", code, out)
}
if code, out, _ := runOut("{/region[12]}", "--no-shipped-data", "-d", dir, "--format", "sql"); code != 0 || out != `INSERT INTO "region" ("code", "name") VALUES ('12', 'Skåne län');`+"\n" {
t.Fatalf("--format sql {/region[12]} = %d, %q, want the table named without its selector", code, out)
}
if code, _, errb := runOut("{/region[99]}", "--no-shipped-data", "-d", dir); code != 1 || !strings.Contains(errb, `"99"`) {
t.Fatalf("{/region[99]} = %d, stderr=%q, want a runtime error naming the row", code, errb)
if code, _, errb := runOut("{/region[99]}", "--no-shipped-data", "-d", dir); code != 2 || !strings.Contains(errb, `"99"`) {
t.Fatalf("{/region[99]} = %d, stderr=%q, want misuse naming the row", code, errb)
}
if code, out, _ := runOut("{/misc.territory[SE]}", "--seed", "1", "--format", "csv"); code != 0 || !strings.HasPrefix(out, "alpha2,") || !strings.Contains(out, "\nSE,SWE,") {
t.Fatalf("--format csv {/misc.territory[SE]} = %d, %q", code, out)
@@ -437,7 +445,7 @@ func TestUsageReferencesResolve(t *testing.T) {
func TestRunOperandMisuseBeforeLoad(t *testing.T) {
code, _, errb := runOut("", "--no-shipped-data", "x")
if code != 2 || !strings.Contains(errb, "takes no operand") || strings.Contains(errb, "--data-path") {
if code != 2 || !strings.Contains(errb, "unexpected argument") || strings.Contains(errb, "--data-path") {
t.Fatalf("an operand with no data = %d, %q; want the operand refused before any load", code, errb)
}
}
@@ -448,19 +456,19 @@ func TestRunInlineTemplate(t *testing.T) {
t.Fatalf("inline format string = %d, %q, stderr %q", code, out, errb)
}
code, out, errb = runOut(`{"format":"name: {x}","x":["bosse","lina"]}`, "--seed", "1")
if code != 0 || (out != "name: bosse\n" && out != "name: lina\n") {
if code != 0 || (out != "name: bosse" && out != "name: lina") {
t.Fatalf("inline JSON template = %d, %q, want one name, stderr %q", code, out, errb)
}
code, out, errb = runOut(`"name: {/sv_SE.person.last}"`, "--seed", "1")
if code != 0 || !strings.HasPrefix(out, "name: ") || strings.Contains(out, "{") {
t.Fatalf("inline JSON string = %d, %q, stderr %q", code, out, errb)
}
code, out, errb = runOut(`{digits(1)}`, "--seed", "1", "-n", "2")
code, out, errb = runOut("{digits(1)}\n", "--seed", "1", "-n", "2")
if code != 0 || len(strings.Split(strings.TrimRight(out, "\n"), "\n")) != 2 {
t.Fatalf("inline template with --repeat = %d, %q, stderr %q", code, out, errb)
}
code, out, errb = runOut("{/sv_SE.person} hihi", "--seed", "1")
if code != 0 || !strings.HasSuffix(out, " hihi\n") || strings.Contains(out, "{") {
if code != 0 || !strings.HasSuffix(out, " hihi") || strings.Contains(out, "{") {
t.Fatalf("a reference and text = %d, %q, stderr %q", code, out, errb)
}
}
@@ -471,8 +479,6 @@ func TestRunTemplateMisuse(t *testing.T) {
`{"format":"x"}`: "is a string",
"name: {/no.such.path}": "no entry",
"a } b": "}}",
" [\"a\",\"b\"]": "may not be padded",
"[\"a\",\"b\"]\n\n": "may not be padded",
"42": "number",
"{//sv_SE.person}": "write {/sv_SE.person}",
} {
@@ -520,7 +526,7 @@ func TestRunRepeatIsBounded(t *testing.T) {
t.Fatal(err)
}
code, out, errb := runOut("{/x}", "--no-shipped-data", "-d", dir, "--repeat", "1048576", "--separator", "")
if code != 0 || len(out) != 1048576+1 {
if code != 0 || len(out) != 1048576 {
t.Errorf("repeat at the cap = %d, %d bytes, stderr %q; want every render streamed", code, len(out), errb)
}
}