Answer architecture round 2, stability round 1 and product-owner round 1: a format string on stdin keeps every byte while JSON or a lone reference drops one newline, an operand's error names stdin, a file or the pipe, {//x} is refused and "{/x}" read as its record, plain echo and a heredoc in the examples, goal 7 reads one command line, decision titles drop argument, and items 157 to 159 filed

This commit is contained in:
2026-10-07 17:04:00 +02:00
committed by Lillem4n
parent 83b4b0bdf0
commit 58a7b16a7f
7 changed files with 227 additions and 129 deletions
+70 -28
View File
@@ -1,14 +1,15 @@
// Command fejkdata renders the template on its stdin with fake values from the shipped
// data and any directories layered over it.
//
// echo -n '{/sv_SE.person}' | fejkdata # a full person
// echo -n 'name: {/sv_SE.person.last}' | fejkdata # text around a surname
// echo -n '{/sv_SE.person}' | fejkdata --data-path ./mydata # layer custom data; the last dir wins
// echo -n '{/sv_SE.address}' | fejkdata --seed 42
// echo '{/sv_SE.person}' | fejkdata # a full person
// echo 'name: {/sv_SE.person.last}' | fejkdata # text around a surname
// echo '{/sv_SE.person}' | fejkdata --data-path ./mydata # layer custom data; the last dir wins
// echo '{/sv_SE.address}' | fejkdata --seed 42
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
@@ -22,21 +23,29 @@ import (
)
const usage = `Usage: <template> | fejkdata [flags]
fejkdata --list | --help | --version
fejkdata reads one template from stdin and renders it:
echo -n 'name: {/sv_SE.person.last}' | fejkdata
echo -n '{"format":"{x}","x":["bosse","lina"]}' | fejkdata
echo 'name: {/sv_SE.person.last}' | fejkdata
echo '{"format":"{x}","x":["bosse","lina"]}' | fejkdata
fejkdata < template.txt
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.
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}).
One newline ending stdin, \n or \r\n, is dropped, so echo and echo -n render
alike; end stdin with two to print one.
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.
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} —
@@ -46,7 +55,7 @@ and the rows are written as one JSON array, one JSON object per line, one CSV ro
-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 read from the root, then exit
--list list the paths the data offers, each one a reference's path, 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
@@ -266,16 +275,28 @@ func (in invocation) checkFlags() error {
return nil
}
const pipeHint = "echo -n '{/sv_SE.person}' | fejkdata"
const pipeHint = "echo '{/sv_SE.person}' | fejkdata"
// check rejects a flag combination or an argument that cannot run, before any data is
// 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.
func operandError(arg string) error {
if arg == "-" {
return errors.New("fejkdata takes no operand, and reads stdin already; drop the -")
}
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("fejkdata takes no operand; pipe the template to stdin: %s", pipeHint)
}
// check rejects a flag combination or an operand that cannot run, before any data is
// read.
func (in invocation) check() error {
if err := in.checkFlags(); err != nil {
return err
}
if len(in.args) > 0 {
return fmt.Errorf("the template comes from stdin, not from an argument: %s", pipeHint)
return operandError(in.args[0])
}
if in.list && (in.repeatSet || in.separatorSet || in.formatSet || in.tableSet) {
return errors.New("--list takes no --repeat, --separator, --format or --table")
@@ -343,7 +364,7 @@ func (in invocation) recordStream(f *fejkdata.Generator, src input) (func() (*fe
if src.path == "" {
t, err := f.NewRecordTemplate(src.template)
if err != nil {
return nil, "", inputError{err}
return nil, "", inputError{spacedReference(src.template, err)}
}
record = func() (*fejkdata.Record, error) { return t.Fake(), nil }
}
@@ -410,9 +431,9 @@ func defaultTable(path string) string {
return segments[len(segments)-1]
}
// inputError marks a failure that is the input's own fault — no template, or one that
// does not compile. run reports it as misuse (exit 2, with a pointer to --help), unlike
// an unknown path, which is a runtime error (exit 1).
// inputError marks a failure that is the input's own fault — no template, one that does
// not compile, or a stdin that cannot be read. run reports it as misuse (exit 2, with a
// pointer to --help), unlike a lone reference to nothing, which is a runtime error (exit 1).
type inputError struct{ error }
func (e inputError) Unwrap() error { return e.error }
@@ -423,37 +444,58 @@ type input struct {
template string
}
// parseInput reads stdin's text less one newline ending it.
// 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.
func parseInput(raw string) (input, error) {
text, found := strings.CutSuffix(raw, "\n")
if found {
text = strings.TrimSuffix(text, "\r")
}
if text == "" {
if raw == "" {
return input{}, fmt.Errorf("stdin holds no template: %s", pipeHint)
}
if path, lone := loneReference(text); lone {
if text, found := strings.CutSuffix(raw, "\n"); 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: text}, nil
return input{template: raw}, nil
}
// loneReference is the path that text, one reference from the root and nothing else,
// reads: the path IsTemplate advises writing instead.
// reads: the path IsTemplate advises writing instead. A JSON string holding one is one.
func loneReference(text string) (string, bool) {
var unquoted string
if json.Unmarshal([]byte(text), &unquoted) == nil {
text = unquoted
}
if !strings.HasPrefix(text, "{/") || !strings.HasSuffix(text, "}") {
return "", false
}
if inline, err := fejkdata.IsTemplate(text); inline || err == nil {
return "", false
}
path := strings.TrimLeft(text[1:len(text)-1], "/")
path := text[2 : len(text)-1]
if inline, err := fejkdata.IsTemplate(path); inline || err != nil {
return "", false
}
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.
func spacedReference(template string, err error) error {
if trimmed := strings.TrimSpace(template); trimmed != template {
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)
}
}
return err
}
func main() {
var stdin io.Reader = os.Stdin
if fi, err := os.Stdin.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
@@ -512,7 +554,7 @@ func readStdin(stdin io.Reader) (input, error) {
}
raw, err := io.ReadAll(stdin)
if err != nil {
return input{}, err
return input{}, inputError{fmt.Errorf("stdin cannot be read: %w", err)}
}
src, err := parseInput(string(raw))
if err != nil {
+57 -23
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/csv"
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
@@ -224,7 +225,6 @@ func TestRunMisuse(t *testing.T) {
}{
{"", nil},
{"", []string{"--data-path", svSE}},
{"\n", []string{"--data-path", svSE}},
{"{/person}", []string{"-d", svSE, "word"}},
{"", []string{"-d", svSE, "--list", "person"}},
{"{/sv_SE.person}", []string{"--no-shipped-data"}},
@@ -242,17 +242,39 @@ func TestRunMisuse(t *testing.T) {
}
}
// TestRunTakesNoArgument pins the error for a path or a template given as an argument:
// it shows the template piped to stdin.
func TestRunTakesNoArgument(t *testing.T) {
for _, args := range [][]string{{"sv_SE.person"}, {"{/sv_SE.person}"}, {"name:", "{/sv_SE.person.last}"}} {
code, out, errb := runOut("", args...)
if code != 2 || out != "" || !strings.Contains(errb, "stdin") || !strings.Contains(errb, "| fejkdata") {
t.Errorf("run(%v) = %d, %q, %q; want misuse showing a template piped to fejkdata", args, code, out, errb)
func TestRunTakesNoOperand(t *testing.T) {
file := filepath.Join(t.TempDir(), "users.tmpl")
if err := os.WriteFile(file, []byte("{/sv_SE.person}"), 0o644); err != nil {
t.Fatal(err)
}
for _, c := range []struct {
args []string
want string
}{
{[]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{file}, "fejkdata < " + file},
} {
code, out, errb := runOut("", c.args...)
if code != 2 || out != "" || !strings.Contains(errb, "takes no operand") || !strings.Contains(errb, c.want) {
t.Errorf("run(%v) = %d, %q, %q; want misuse naming %q", c.args, code, out, errb, c.want)
}
}
}
type failingReader struct{}
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("bad file descriptor") }
func TestRunUnreadableStdinIsMisuse(t *testing.T) {
var out, errb bytes.Buffer
if code := run(nil, failingReader{}, &out, &errb); code != 2 || !strings.Contains(errb.String(), "cannot be read") {
t.Errorf("run with an unreadable stdin = %d, %q; want misuse naming it", code, errb.String())
}
}
func TestRunTerminalStdinIsMisuse(t *testing.T) {
code, out, errb := runTerminal("--seed", "1")
if code != 2 || out != "" || !strings.Contains(errb, "| fejkdata") {
@@ -265,26 +287,26 @@ func TestRunTerminalStdinIsMisuse(t *testing.T) {
}
}
func TestRunDropsOneTrailingNewline(t *testing.T) {
func TestRunNewlineEndingStdin(t *testing.T) {
for stdin, want := range map[string]string{
"hihi": "hihi\n",
"hihi\n": "hihi\n",
"hihi\r\n": "hihi\n",
"hihi\n\n": "hihi\n\n",
"hihi\r\n\r\n": "hihi\r\n\n",
"a\nb\n": "a\nb\n",
" hihi \n": " hihi \n",
"{{x}}\n": "{x}\n",
"\"a\"\n": "a\n",
"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",
} {
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\n", "--seed", "1")
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("a template ending in a newline = %d, %q, %q; want its own newline, then the line's", code, out, errb)
t.Errorf("echo's format string = %d, %q, %q; want echo's newline, then the line's", code, out, errb)
}
}
@@ -413,10 +435,10 @@ func TestUsageReferencesResolve(t *testing.T) {
}
}
func TestRunArgumentMisuseBeforeLoad(t *testing.T) {
func TestRunOperandMisuseBeforeLoad(t *testing.T) {
code, _, errb := runOut("", "--no-shipped-data", "x")
if code != 2 || !strings.Contains(errb, "stdin") || strings.Contains(errb, "--data-path") {
t.Fatalf("an argument with no data = %d, %q; want the argument refused before any load", code, errb)
if code != 2 || !strings.Contains(errb, "takes no operand") || strings.Contains(errb, "--data-path") {
t.Fatalf("an operand with no data = %d, %q; want the operand refused before any load", code, errb)
}
}
@@ -452,6 +474,7 @@ func TestRunTemplateMisuse(t *testing.T) {
" [\"a\",\"b\"]": "may not be padded",
"[\"a\",\"b\"]\n\n": "may not be padded",
"42": "number",
"{//sv_SE.person}": "write {/sv_SE.person}",
} {
code, out, errb := runOut(stdin, "--seed", "1")
if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") || !strings.Contains(errb, want) {
@@ -605,6 +628,17 @@ func TestRunRecordDefaultTable(t *testing.T) {
}
}
func TestRunRecordOfAQuotedLoneReference(t *testing.T) {
code, out, errb := runOut(`"{/users}"`, "--seed", "1", "--format", "sql", "--data-path", recordDir(t))
if code != 0 || !strings.HasPrefix(out, `INSERT INTO "users" (`) {
t.Fatalf("a JSON string holding {/users} = %d, %q, %q; want the record users", code, out, errb)
}
code, out, errb = runOut("{/users} ", "--format", "csv", "--data-path", recordDir(t))
if code != 2 || out != "" || !strings.Contains(errb, "whitespace around {/users}") {
t.Errorf("{/users} and a space = %d, %q, %q; want misuse naming the whitespace", code, out, errb)
}
}
func TestRunRecordInlineTemplate(t *testing.T) {
code, out, errb := runOut(`{"format":"{x}","x":["a","b"]}`, "--seed", "1", "--format", "json")
if code != 0 {