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:
+70
-28
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user