705 lines
23 KiB
Go
705 lines
23 KiB
Go
// Package cli is the fejkdata command: it renders the template on its stdin with fake
|
|
// values from the modules it is given and any directories layered over them. A program
|
|
// passing its own modules to Main is a fejkdata CLI carrying them, for example
|
|
// os.Exit(cli.Main(append(data.Modules(), mydata)...)).
|
|
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime/debug"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/larvit/fejkdata"
|
|
)
|
|
|
|
const usage = `Usage: <template> | COMMAND [flags]
|
|
COMMAND --list | --help | --version
|
|
|
|
COMMAND reads one template from stdin and renders it:
|
|
|
|
echo 'id: {int(1,999)}' | COMMAND
|
|
echo '{"format":"{x}","x":["bosse","lina"]}' | COMMAND
|
|
COMMAND < template.txt
|
|
|
|
COMMAND <<'EOF'
|
|
born {date(1990-01-01,2010-12-31,'2006-01-02')}
|
|
EOF
|
|
|
|
A template is a format string, or a JSON object, array, string or null. Its {…} tokens
|
|
reach the data by reference from the root: {/path}, for any path --list prints, shipped
|
|
or layered with --data-path. A reference names a category or a dotted path into one, a
|
|
table's row by key or name ({/table[KEY]}), and .. steps up from a row to the row it
|
|
links to.
|
|
|
|
Under --format text, the default, what you send is what renders, and COMMAND 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
|
|
object, array or string drops the whitespace around it, and null alone renders nothing;
|
|
42 or true alone is a format string, printed as written. An empty or blank stdin is a
|
|
format string like any other, and renders as sent. 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
|
|
template whose fields are its columns, or a lone reference such as {/path}:
|
|
one reference and nothing else, with 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 written after {/ in a template, then exit;
|
|
--repeat, --separator, --format and --table do nothing here
|
|
--no-shipped-data load only the --data-path directories
|
|
-n, --repeat N render the template N times, 0..1048576 (default 1); 0 prints nothing,
|
|
[] under --format json, or the header under --format csv
|
|
-s, --seed N same seed, version and data: identical output
|
|
--separator S string between repeated renders (default empty); refused with a record
|
|
--format, which has its own separators
|
|
--table T the INSERT target for --format sql, ignored under any other
|
|
(default: a lone reference's last segment, else records)
|
|
--version print the version, then exit
|
|
|
|
A short flag's value attaches or follows (-n3, -n 3); short flags bundle (-hn 3);
|
|
-- ends the flags.
|
|
|
|
Exit codes: 0 success, 1 runtime error, 2 misuse.
|
|
`
|
|
|
|
type invocation struct {
|
|
args []string
|
|
dirs []string
|
|
format string
|
|
help bool
|
|
list bool
|
|
noShipped bool
|
|
repeat int
|
|
seed uint64
|
|
seeded bool
|
|
separator string
|
|
separatorSet bool
|
|
table string
|
|
tableSet bool
|
|
version bool
|
|
}
|
|
|
|
type flagDef struct {
|
|
long string
|
|
short string
|
|
value bool
|
|
set func(*invocation, string) error
|
|
}
|
|
|
|
var flagDefs = []flagDef{
|
|
{"data-path", "d", true, func(in *invocation, v string) error { in.dirs = append(in.dirs, v); return nil }},
|
|
{"format", "", true, func(in *invocation, v string) error { in.format = v; return nil }},
|
|
{"help", "h", false, func(in *invocation, _ string) error { in.help = true; return nil }},
|
|
{"list", "", false, func(in *invocation, _ string) error { in.list = true; return nil }},
|
|
{"no-shipped-data", "", false, func(in *invocation, _ string) error { in.noShipped = true; return nil }},
|
|
{"repeat", "n", true, func(in *invocation, v string) error {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 || n > fejkdata.MaxRepeat {
|
|
return fmt.Errorf("--repeat needs an integer in 0..%d, got %q", fejkdata.MaxRepeat, v)
|
|
}
|
|
in.repeat = n
|
|
return nil
|
|
}},
|
|
{"seed", "s", true, func(in *invocation, v string) error {
|
|
n, err := strconv.ParseUint(v, 10, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("--seed needs an unsigned integer, got %q", v)
|
|
}
|
|
in.seed, in.seeded = n, true
|
|
return nil
|
|
}},
|
|
{"separator", "", true, func(in *invocation, v string) error { in.separator, in.separatorSet = v, true; return nil }},
|
|
{"table", "", true, func(in *invocation, v string) error { in.table, in.tableSet = v, true; return nil }},
|
|
{"version", "", false, func(in *invocation, _ string) error { in.version = true; return nil }},
|
|
}
|
|
|
|
func flagByLong(name string) *flagDef {
|
|
for i := range flagDefs {
|
|
if flagDefs[i].long == name {
|
|
return &flagDefs[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func flagByShort(name string) *flagDef {
|
|
for i := range flagDefs {
|
|
if flagDefs[i].short == name {
|
|
return &flagDefs[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// flagArg is one flag as written: its definition and the value attached to it,
|
|
// if any.
|
|
type flagArg struct {
|
|
def *flagDef
|
|
value string
|
|
inline bool
|
|
}
|
|
|
|
// splitFlags reads one argv element as flags: --name or --name=value, or -abc
|
|
// where each letter is a short flag and the first that takes a value takes the
|
|
// rest of the element.
|
|
func splitFlags(arg string) ([]flagArg, error) {
|
|
if strings.HasPrefix(arg, "--") {
|
|
name, value, inline := strings.Cut(arg[2:], "=")
|
|
def := flagByLong(name)
|
|
if def == nil {
|
|
return nil, fmt.Errorf("unknown flag --%s", name)
|
|
}
|
|
return []flagArg{{def, value, inline}}, nil
|
|
}
|
|
if name, _, _ := strings.Cut(arg[1:], "="); len(name) > 1 && flagByLong(name) != nil {
|
|
return nil, fmt.Errorf("unknown flag %s; use --%s", arg, name)
|
|
}
|
|
var flags []flagArg
|
|
letters := arg[1:]
|
|
for i, r := range letters {
|
|
letter := string(r)
|
|
def := flagByShort(letter)
|
|
if def == nil {
|
|
return nil, fmt.Errorf("unknown flag -%s", letter)
|
|
}
|
|
if !def.value {
|
|
flags = append(flags, flagArg{def: def})
|
|
continue
|
|
}
|
|
rest := letters[i+len(letter):]
|
|
if strings.HasPrefix(rest, "=") {
|
|
return nil, fmt.Errorf("-%s takes its value attached (-%s%s) or next (-%s %s); = belongs to --%s=%s",
|
|
letter, letter, rest[1:], letter, rest[1:], def.long, rest[1:])
|
|
}
|
|
return append(flags, flagArg{def, rest, rest != ""}), nil
|
|
}
|
|
return flags, nil
|
|
}
|
|
|
|
func parseArgs(argv []string) (invocation, error) {
|
|
in := invocation{repeat: 1, format: "text"}
|
|
for i := 0; i < len(argv); i++ {
|
|
arg := argv[i]
|
|
if arg == "--" {
|
|
in.args = append(in.args, argv[i+1:]...)
|
|
return in, nil
|
|
}
|
|
if len(arg) < 2 || arg[0] != '-' {
|
|
in.args = append(in.args, arg)
|
|
continue
|
|
}
|
|
flags, err := splitFlags(arg)
|
|
if err != nil {
|
|
return in, err
|
|
}
|
|
for _, fl := range flags {
|
|
if !fl.def.value {
|
|
if fl.inline {
|
|
return in, fmt.Errorf("--%s takes no value", fl.def.long)
|
|
}
|
|
_ = fl.def.set(&in, "")
|
|
continue
|
|
}
|
|
value := fl.value
|
|
if !fl.inline {
|
|
if i++; i >= len(argv) {
|
|
return in, fmt.Errorf("--%s needs a value", fl.def.long)
|
|
}
|
|
value = argv[i]
|
|
}
|
|
if err := fl.def.set(&in, value); err != nil {
|
|
return in, err
|
|
}
|
|
}
|
|
}
|
|
return in, nil
|
|
}
|
|
|
|
// recordFormat is one way to write a record out: the line each record renders,
|
|
// the header that precedes the first one, and the open/close frame plus the
|
|
// between-record separator a document form needs.
|
|
type recordFormat struct {
|
|
header func(*fejkdata.Record) string
|
|
line func(r *fejkdata.Record, table string) string
|
|
open string
|
|
close string
|
|
sep string
|
|
writes string // what it writes between values, which --separator would seem to set
|
|
}
|
|
|
|
// recordFormats is every --format that writes records. json frames the records
|
|
// as one array document; ndjson writes one object per line.
|
|
var recordFormats = map[string]recordFormat{
|
|
"csv": {header: (*fejkdata.Record).CSVHeader, line: func(r *fejkdata.Record, _ string) string { return r.CSVLine() }, sep: "\n", writes: `always separates fields with ","`},
|
|
"json": {line: jsonLine, open: "[", close: "]", sep: ",\n", writes: "writes the records as one JSON array"},
|
|
"ndjson": {line: jsonLine, sep: "\n", writes: "writes one record per line"},
|
|
"sql": {line: func(r *fejkdata.Record, table string) string { return r.SQLInsert(table) }, sep: "\n", writes: "writes one INSERT per line"},
|
|
}
|
|
|
|
func jsonLine(r *fejkdata.Record, _ string) string { return r.JSON() }
|
|
|
|
func (in invocation) writesRecords() bool {
|
|
return in.format != "text"
|
|
}
|
|
|
|
// formatNames lists the --format values, text included, for the misuse error.
|
|
func formatNames() string {
|
|
names := []string{"text"}
|
|
for name := range recordFormats {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return strings.Join(names[:len(names)-1], ", ") + " or " + names[len(names)-1]
|
|
}
|
|
|
|
// checkFlags rejects a flag value or combination that cannot run.
|
|
func (in invocation) checkFlags() error {
|
|
if _, ok := recordFormats[in.format]; !ok && in.format != "text" {
|
|
return fmt.Errorf("--format takes %s, got %q", formatNames(), in.format)
|
|
}
|
|
if in.tableSet && in.table == "" && in.format == "sql" {
|
|
return errors.New("--table names the INSERT target, so it cannot be empty")
|
|
}
|
|
if f, records := recordFormats[in.format]; records && in.separatorSet && !in.list {
|
|
return fmt.Errorf("--separator joins text renders; --format %s %s", in.format, f.writes)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pipeHint(name string) string { return "echo 'id: {int(1,999)}' | " + name }
|
|
|
|
// operandError refuses an argument that is no flag, naming the spelling that reads what
|
|
// it most likely means: dropping it for -, a file for a file's name, else a template
|
|
// piped in.
|
|
func operandError(arg, name string) error {
|
|
unexpected := "unexpected argument '" + arg + "'"
|
|
if arg == "-" {
|
|
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("%s; read a file from stdin: %s < %s", unexpected, name, shellQuoted(arg))
|
|
}
|
|
ref := "{/" + strings.TrimPrefix(arg, "/") + "}"
|
|
_, argLone := loneReference(arg)
|
|
inline, err := fejkdata.IsTemplate(arg)
|
|
_, refLone := loneReference(ref)
|
|
example := pipeHint(name)
|
|
switch {
|
|
case argLone || inline:
|
|
example = piped(arg, name)
|
|
case err == nil && refLone:
|
|
example = piped(ref, name)
|
|
}
|
|
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
|
|
// read.
|
|
func (in invocation) check(name string) error {
|
|
if err := in.checkFlags(); err != nil {
|
|
return err
|
|
}
|
|
if len(in.args) > 0 {
|
|
return operandError(in.args[0], name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (in invocation) options(modules []fs.FS) []fejkdata.Option {
|
|
if in.noShipped {
|
|
modules = nil
|
|
}
|
|
opts := []fejkdata.Option{fejkdata.WithDataFS(modules...)}
|
|
for _, dir := range in.dirs {
|
|
opts = append(opts, fejkdata.WithDataPath(dir))
|
|
}
|
|
if in.seeded {
|
|
opts = append(opts, fejkdata.WithSeed(in.seed))
|
|
}
|
|
return opts
|
|
}
|
|
|
|
// write streams the input's renders to w, repeat of them joined by the separator; 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)
|
|
}
|
|
t, err := f.NewTemplate(src.template)
|
|
if err != nil {
|
|
return templateError(err)
|
|
}
|
|
out := bufio.NewWriter(w)
|
|
for i := 0; i < in.repeat; i++ {
|
|
if i > 0 {
|
|
out.WriteString(in.separator)
|
|
}
|
|
out.WriteString(t.Fake())
|
|
}
|
|
return out.Flush()
|
|
}
|
|
|
|
// 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.record) }
|
|
if src.record == "" {
|
|
t, err := f.NewRecordTemplate(src.template)
|
|
if err != nil {
|
|
return nil, "", templateError(spacedReference(src.template, err))
|
|
}
|
|
record = func() (*fejkdata.Record, error) { return t.Fake(), nil }
|
|
}
|
|
table := in.table
|
|
if table == "" {
|
|
table = defaultTable(src.record)
|
|
}
|
|
return record, table, nil
|
|
}
|
|
|
|
// writeRecords streams a record per line in the chosen format, framing a document
|
|
// form with its open/close brackets and a header preceding the first record.
|
|
func (in invocation) writeRecords(f *fejkdata.Generator, src input, w io.Writer) error {
|
|
record, table, err := in.recordStream(f, src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
format := recordFormats[in.format]
|
|
if in.repeat == 0 {
|
|
return writeNoRecord(record, format, w)
|
|
}
|
|
out := bufio.NewWriter(w)
|
|
if format.open != "" {
|
|
out.WriteString(format.open)
|
|
out.WriteByte('\n')
|
|
}
|
|
for i := 0; i < in.repeat; i++ {
|
|
r, err := record()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if i == 0 && format.header != nil {
|
|
out.WriteString(format.header(r))
|
|
out.WriteByte('\n')
|
|
}
|
|
if i > 0 {
|
|
out.WriteString(format.sep)
|
|
}
|
|
out.WriteString(format.line(r, table))
|
|
}
|
|
if format.close != "" {
|
|
out.WriteByte('\n')
|
|
out.WriteString(format.close)
|
|
}
|
|
out.WriteByte('\n')
|
|
return out.Flush()
|
|
}
|
|
|
|
// writeNoRecord writes zero records: the header where the format writes one, an empty document
|
|
// where it frames one, else nothing. It draws one record, which names the header's columns, so a
|
|
// lone reference to nothing fails as it would with records.
|
|
func writeNoRecord(record func() (*fejkdata.Record, error), format recordFormat, w io.Writer) error {
|
|
r, err := record()
|
|
switch {
|
|
case err != nil:
|
|
return err
|
|
case format.header != nil:
|
|
_, err = io.WriteString(w, format.header(r)+"\n")
|
|
case format.open != "":
|
|
_, err = io.WriteString(w, format.open+format.close+"\n")
|
|
}
|
|
return err
|
|
}
|
|
|
|
// defaultTable names the INSERT target when --table is absent: the path's last
|
|
// segment, or "records" when stdin is not a lone reference.
|
|
func defaultTable(path string) string {
|
|
if path == "" {
|
|
return "records"
|
|
}
|
|
segments := strings.Split(withoutSelectors(path), ".")
|
|
return segments[len(segments)-1]
|
|
}
|
|
|
|
// withoutSelectors is path with its [selectors] cut out.
|
|
func withoutSelectors(path string) string {
|
|
var names strings.Builder
|
|
for depth, i := 0, 0; i < len(path); i++ {
|
|
switch {
|
|
case path[i] == '[':
|
|
depth++
|
|
case path[i] == ']':
|
|
depth--
|
|
case depth == 0:
|
|
names.WriteByte(path[i])
|
|
}
|
|
}
|
|
return names.String()
|
|
}
|
|
|
|
// inputError marks a failure that is the input's own fault — nothing piped in, a template
|
|
// 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 under --format, which is a runtime
|
|
// error (exit 1).
|
|
type inputError struct{ error }
|
|
|
|
func (e inputError) Unwrap() error { return e.error }
|
|
|
|
// jsonSpace is the whitespace JSON allows around a value.
|
|
const jsonSpace = " \t\r\n"
|
|
|
|
// input is what stdin holds: the template, and the path a lone reference names as a
|
|
// record.
|
|
type input struct {
|
|
template string
|
|
record string
|
|
}
|
|
|
|
// parseInput keeps stdin whole as the template, and names a record where stdin is a lone
|
|
// reference once JSON's surrounding whitespace, or one final newline, is cut.
|
|
func parseInput(raw string) input {
|
|
trimmed := strings.Trim(raw, jsonSpace)
|
|
text := raw
|
|
if json.Valid([]byte(trimmed)) {
|
|
text = trimmed
|
|
}
|
|
text, found := strings.CutSuffix(text, "\n")
|
|
if found {
|
|
text = strings.TrimSuffix(text, "\r")
|
|
}
|
|
record, _ := loneReference(text)
|
|
return input{template: raw, record: record}
|
|
}
|
|
|
|
// loneReference returns the path that text reads when it is one reference from the root
|
|
// and nothing else, bare or as a JSON string.
|
|
func loneReference(text string) (string, bool) {
|
|
var unquoted string
|
|
if strings.HasPrefix(text, `"`) && strings.HasSuffix(text, `"`) && json.Unmarshal([]byte(text), &unquoted) == nil {
|
|
text = unquoted
|
|
}
|
|
path, opens := strings.CutPrefix(text, "{/")
|
|
path, closes := strings.CutSuffix(path, "}")
|
|
if !opens || !closes || !isPath(path) {
|
|
return "", false
|
|
}
|
|
return path, true
|
|
}
|
|
|
|
// isPath reports whether a reference's body after its / is a path alone: no second /, no
|
|
// folder sigil, no empty segment, 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
|
|
}
|
|
names := withoutSelectors(body)
|
|
if strings.ContainsAny(names, "|(") || strings.Contains(names, " as ") || hasEmptySegment(names) {
|
|
return false
|
|
}
|
|
inline, err := fejkdata.IsTemplate(body)
|
|
return !inline && err == nil
|
|
}
|
|
|
|
// hasEmptySegment reports whether a dotted path holds a segment with no name: two dots are a
|
|
// step up, and only a step up may end the path.
|
|
func hasEmptySegment(path string) bool {
|
|
segments := strings.Split(path, ".")
|
|
stepUp := false
|
|
for i, seg := range segments[1:] {
|
|
last := i == len(segments)-2
|
|
switch {
|
|
case seg != "":
|
|
stepUp = false
|
|
case stepUp != last:
|
|
return true
|
|
default:
|
|
stepUp = !stepUp
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 && 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)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// piped is the command piping template into the command called name; some shells' echo
|
|
// reads a backslash as an escape, so a template holding one goes through printf.
|
|
func piped(template, name string) string {
|
|
if strings.ContainsRune(template, '\\') {
|
|
return "printf '%s' " + shellQuoted(template) + " | " + name
|
|
}
|
|
return "echo " + shellQuoted(template) + " | " + name
|
|
}
|
|
|
|
// 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\r\n'\"$`\\*?[]{}()<>|&;#~!") {
|
|
return s
|
|
}
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// Main runs the command on the process's arguments and standard streams over modules, each
|
|
// passed to [fejkdata.WithDataFS], and returns the exit code: 0 ok, 1 runtime error,
|
|
// 2 misuse. --no-shipped-data leaves modules out.
|
|
func Main(modules ...fs.FS) int {
|
|
var stdin io.Reader = os.Stdin
|
|
if fi, err := os.Stdin.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
|
|
stdin = nil
|
|
}
|
|
return run(commandName(os.Args[0]), modules, os.Args[1:], stdin, os.Stdout, os.Stderr)
|
|
}
|
|
|
|
// commandName is the name the command runs under: the file arg0 names, less a Windows
|
|
// .exe, or fejkdata where arg0 names none.
|
|
func commandName(arg0 string) string {
|
|
name := filepath.Base(arg0)
|
|
if ext := filepath.Ext(name); strings.EqualFold(ext, ".exe") {
|
|
name = strings.TrimSuffix(name, ext)
|
|
}
|
|
if name == "." || name == "" {
|
|
return "fejkdata"
|
|
}
|
|
return name
|
|
}
|
|
|
|
// run is Main over the given streams, run as the command name. A nil stdin is a terminal,
|
|
// which holds no template.
|
|
func run(name string, modules []fs.FS, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
in, err := parseArgs(args)
|
|
if err != nil {
|
|
return misuse(stderr, name, err)
|
|
}
|
|
if in.help {
|
|
io.WriteString(stdout, strings.ReplaceAll(usage, "COMMAND", name))
|
|
return 0
|
|
}
|
|
if in.version {
|
|
info, _ := debug.ReadBuildInfo()
|
|
fmt.Fprintln(stdout, versionLine(name, info))
|
|
return 0
|
|
}
|
|
if err := in.check(name); err != nil {
|
|
return misuse(stderr, name, err)
|
|
}
|
|
var src input
|
|
if !in.list {
|
|
if src, err = readStdin(stdin, name); err != nil {
|
|
return fail(stderr, name, err)
|
|
}
|
|
}
|
|
f, err := fejkdata.New(in.options(modules)...)
|
|
if err != nil {
|
|
return fail(stderr, name, err)
|
|
}
|
|
if in.list {
|
|
for _, p := range f.List() {
|
|
fmt.Fprintln(stdout, p)
|
|
}
|
|
return 0
|
|
}
|
|
if err := in.write(f, src, stdout); err != nil {
|
|
return fail(stderr, name, err)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func readStdin(stdin io.Reader, name string) (input, error) {
|
|
if stdin == nil {
|
|
return input{}, inputError{fmt.Errorf("the template comes from stdin, and nothing is piped in: %s; %s --list prints the paths a {/path} reference reads", pipeHint(name), name)}
|
|
}
|
|
raw, err := io.ReadAll(stdin)
|
|
if err != nil {
|
|
return input{}, inputError{fmt.Errorf("stdin cannot be read: %w", err)}
|
|
}
|
|
return parseInput(string(raw)), nil
|
|
}
|
|
|
|
// templateError is misuse unless the data the template reads failed to load.
|
|
func templateError(err error) error {
|
|
if errors.Is(err, fejkdata.ErrLoad) {
|
|
return err
|
|
}
|
|
return inputError{err}
|
|
}
|
|
|
|
func fail(stderr io.Writer, name string, err error) int {
|
|
var te inputError
|
|
if errors.As(err, &te) {
|
|
return misuse(stderr, name, te.error)
|
|
}
|
|
report(stderr, name, err)
|
|
if errors.Is(err, fejkdata.ErrNoColumns) {
|
|
fmt.Fprintln(stderr, "wrap that JSON in single quotes, which keep a shell from expanding its braces")
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func misuse(stderr io.Writer, name string, err error) int {
|
|
report(stderr, name, err)
|
|
fmt.Fprintf(stderr, "try '%s --help'\n", name)
|
|
return 2
|
|
}
|
|
|
|
// report writes err under the command name, in place of the library's own on each line.
|
|
func report(stderr io.Writer, name string, err error) {
|
|
msg := strings.ReplaceAll(strings.TrimPrefix(err.Error(), "fejkdata: "), "\nfejkdata: ", "\n"+name+": ")
|
|
fmt.Fprintf(stderr, "%s: %s\n", name, msg)
|
|
}
|
|
|
|
// fejkdataModule is the path of the module holding the library; the root package's path is
|
|
// the module's.
|
|
var fejkdataModule = reflect.TypeOf((*fejkdata.Generator)(nil)).Elem().PkgPath()
|
|
|
|
// versionLine names the fejkdata version the binary is built with. Where another module's
|
|
// main package carries the CLI, the main module's version comes first, under the command name.
|
|
func versionLine(name string, info *debug.BuildInfo) string {
|
|
if info == nil {
|
|
return "fejkdata devel"
|
|
}
|
|
if info.Main.Path == fejkdataModule {
|
|
return "fejkdata " + moduleVersion(info.Main)
|
|
}
|
|
for _, m := range info.Deps {
|
|
if m.Path == fejkdataModule {
|
|
return name + " " + moduleVersion(info.Main) + ", fejkdata " + moduleVersion(*m)
|
|
}
|
|
}
|
|
return name + " " + moduleVersion(info.Main) + ", fejkdata devel"
|
|
}
|
|
|
|
// moduleVersion is the version m is built at: the replacement's where a replace directive
|
|
// names one, and "devel" where it carries none, as a local directory does.
|
|
func moduleVersion(m debug.Module) string {
|
|
if m.Replace != nil {
|
|
m = *m.Replace
|
|
}
|
|
if m.Version == "" || m.Version == "(devel)" {
|
|
return "devel"
|
|
}
|
|
return m.Version
|
|
}
|