GNU-style CLI flags, embedded data, and a literal format grammar #3

Merged
lilleman merged 38 commits from cli-and-syntax into main 2026-09-03 14:21:08 +02:00
6 changed files with 129 additions and 23 deletions
Showing only changes of commit 4f9285ab5c - Show all commits
+11 -8
View File
@@ -180,9 +180,9 @@ many times — each an independent draw — joined by `separator` (default `""`)
{ "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] } { "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] }
``` ```
Renders e.g. `bar foo baz`. A `separator` without a `repeat` is rejected, and so Renders e.g. `bar foo baz`. Rejected at load: a `separator` without a `repeat`,
is a `repeat` that multiplies to more than 1 048 576 renders along any path of a `separator` of `""` (the default), and a `repeat` that multiplies to more than
nested repeats. 1 048 576 renders along any path of nested repeats.
### Options and fields ### Options and fields
@@ -194,9 +194,11 @@ choice naming its item.
### Functions ### Functions
A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad
count, range, country or expression fails fast, and a length, count or decimal count, range, country or expression fails fast; an integer is written plain
place beyond a sane maximum is rejected, so a fat-fingered `hex(2000000000)` (`5`, not `+5` or `05`); bounds are finite; a sample that could only ever emit one
never tries to allocate gigabytes. Every builtin draws only from the seed — a value (`int(5,5)`, `float(1,1,2)`) is rejected naming the text to write instead;
and a length, count or decimal place beyond a sane maximum is rejected, so a
fat-fingered `hex(2000000000)` never tries to allocate gigabytes. Every builtin draws only from the seed — a
time-based id takes its timestamp from the rng, not the clock — so seeded output time-based id takes its timestamp from the rng, not the clock — so seeded output
stays reproducible. stays reproducible.
@@ -243,8 +245,9 @@ hyphenated field can't be an operand.
``` ```
Renders e.g. `19.99 x 3 = 59.97`. An operand that can never be a number (`"abc"`, Renders e.g. `19.99 x 3 = 59.97`. An operand that can never be a number (`"abc"`,
or a choice of such) is rejected at load; one that sometimes is not yields `NaN`, or a choice of such) is rejected at load, as is a division by a constant zero
and a division by zero `Inf` — both print rather than fail. (`1/0`, or a fixed `"0"` field); an operand that sometimes is not a number yields
`NaN`, and a division by a sometimes-zero one `Inf` — both print rather than fail.
### Transforms ### Transforms
+36 -12
View File
@@ -176,7 +176,7 @@ var asciiFolds = map[rune]string{
func asciiFold(s string) string { func asciiFold(s string) string {
var b strings.Builder var b strings.Builder
for _, r := range s { for _, r := range s {
if r < unicode.MaxASCII { if r <= unicode.MaxASCII {
b.WriteRune(r) b.WriteRune(r)
} else { } else {
b.WriteString(asciiFolds[r]) b.WriteString(asciiFolds[r])
@@ -221,10 +221,25 @@ func randChars(r rng, n int, alphabet string) string {
return string(b) return string(b)
} }
// plainInt parses an integer arg written the one way: no sign, no leading zero.
func plainInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("%q is not an integer", s)
}
if strconv.Itoa(n) != s {
return 0, fmt.Errorf("%q is not a plain integer; write %d", s, n)
}
return n, nil
}
func posIntArg(_ map[string]node, a []string) error { func posIntArg(_ map[string]node, a []string) error {
n, err := strconv.Atoi(a[0]) n, err := plainInt(a[0])
if err != nil || n < 1 { if err != nil {
return fmt.Errorf("count %q must be a positive integer", a[0]) return fmt.Errorf("count %w", err)
}
if n < 1 {
return fmt.Errorf("count %q must be positive", a[0])
} }
if n > maxLen { if n > maxLen {
return fmt.Errorf("count %d exceeds the maximum %d", n, maxLen) return fmt.Errorf("count %d exceeds the maximum %d", n, maxLen)
@@ -233,14 +248,17 @@ func posIntArg(_ map[string]node, a []string) error {
} }
func intRangeArgs(_ map[string]node, a []string) error { func intRangeArgs(_ map[string]node, a []string) error {
lo, e1 := strconv.Atoi(a[0]) lo, e1 := plainInt(a[0])
hi, e2 := strconv.Atoi(a[1]) hi, e2 := plainInt(a[1])
if e1 != nil || e2 != nil { if e1 != nil || e2 != nil {
return fmt.Errorf("int(min,max) needs integer args, got %q,%q", a[0], a[1]) return fmt.Errorf("int(min,max) needs plain integers, got %q,%q", a[0], a[1])
} }
if lo > hi { if lo > hi {
return fmt.Errorf("int(min,max): min %d > max %d", lo, hi) return fmt.Errorf("int(min,max): min %d > max %d", lo, hi)
} }
if lo == hi {
return fmt.Errorf("int(%d,%d) is the constant %d; write it as text", lo, hi, lo)
}
if uint64(hi)-uint64(lo) >= uint64(math.MaxInt64) { // span hi-lo+1 would overflow int -> IntN panic if uint64(hi)-uint64(lo) >= uint64(math.MaxInt64) { // span hi-lo+1 would overflow int -> IntN panic
return fmt.Errorf("int(min,max): range %d..%d is too wide", lo, hi) return fmt.Errorf("int(min,max): range %d..%d is too wide", lo, hi)
} }
@@ -250,19 +268,25 @@ func intRangeArgs(_ map[string]node, a []string) error {
func floatArgs(_ map[string]node, a []string) error { func floatArgs(_ map[string]node, a []string) error {
lo, e1 := strconv.ParseFloat(a[0], 64) lo, e1 := strconv.ParseFloat(a[0], 64)
hi, e2 := strconv.ParseFloat(a[1], 64) hi, e2 := strconv.ParseFloat(a[1], 64)
dp, e3 := strconv.Atoi(a[2]) dp, e3 := plainInt(a[2])
if e1 != nil || e2 != nil || e3 != nil { if e1 != nil || e2 != nil || e3 != nil {
return fmt.Errorf("float(min,max,dp) needs numeric args, got %q,%q,%q", a[0], a[1], a[2]) return fmt.Errorf("float(min,max,dp) needs numeric bounds and a plain decimals count, got %q,%q,%q", a[0], a[1], a[2])
}
if math.IsNaN(lo) || math.IsNaN(hi) || math.IsInf(lo, 0) || math.IsInf(hi, 0) {
return fmt.Errorf("float(min,max,dp) needs finite bounds, got %q,%q", a[0], a[1])
} }
if lo > hi { if lo > hi {
return fmt.Errorf("float(min,max,dp): min %v > max %v", lo, hi) return fmt.Errorf("float(min,max,dp): min %v > max %v", lo, hi)
} }
if math.IsInf(hi-lo, 0) { // an overflowing span would render as "+Inf"
return fmt.Errorf("float(min,max,dp): range %v..%v is too wide", lo, hi)
}
if dp < 0 || dp > maxDecimals { if dp < 0 || dp > maxDecimals {
return fmt.Errorf("float(min,max,dp): decimals %d out of range 0..%d", dp, maxDecimals) return fmt.Errorf("float(min,max,dp): decimals %d out of range 0..%d", dp, maxDecimals)
} }
if lo == hi {
return fmt.Errorf("float(%s,%s,%d) is the constant %q; write it as text", a[0], a[1], dp, strconv.FormatFloat(lo, 'f', dp, 64))
}
if math.IsInf(hi-lo, 0) { // an overflowing span would render as "+Inf"
return fmt.Errorf("float(min,max,dp): range %v..%v is too wide", lo, hi)
}
return nil return nil
} }
+66 -2
View File
@@ -78,14 +78,78 @@ func checkCalc(fields map[string]node, args []string) error {
return fmt.Errorf("calc(%q): operand %q is never a number: it renders %q", args[0], name, text) return fmt.Errorf("calc(%q): operand %q is never a number: it renders %q", args[0], name, text)
} }
} }
if divisor, zero := constantZeroDivisor(expr, fields); zero {
return fmt.Errorf("calc(%q) divides by %s, which is always zero", args[0], divisor)
}
if len(args) == 2 { if len(args) == 2 {
if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 || dp > maxDecimals { if dp, err := plainInt(args[1]); err != nil || dp < 0 || dp > maxDecimals {
return fmt.Errorf("calc decimals %q must be an integer in 0..%d", args[1], maxDecimals) return fmt.Errorf("calc decimals %q must be a plain integer in 0..%d", args[1], maxDecimals)
} }
} }
return nil return nil
} }
// constantZeroDivisor finds a division whose right side is a constant zero: number
// literals and fixed operands folded, anything that varies left unknown.
func constantZeroDivisor(n calcNode, fields map[string]node) (string, bool) {
switch n := n.(type) {
case calcNeg:
return constantZeroDivisor(n.x, fields)
case calcBin:
if n.op == '/' {
if v, known := constantValue(n.r, fields); known && v == 0 {
return calcText(n.r), true
}
}
if d, zero := constantZeroDivisor(n.l, fields); zero {
return d, true
}
return constantZeroDivisor(n.r, fields)
}
return "", false
}
// constantValue evaluates an expression whose every operand is fixed.
func constantValue(n calcNode, fields map[string]node) (float64, bool) {
switch n := n.(type) {
case calcNum:
return float64(n), true
case calcVar:
t, ok := fields[string(n)].(*template)
if !ok || !t.fixed || t.repeat > 1 {
return 0, false
}
v, err := strconv.ParseFloat(strings.TrimSpace(t.lit), 64)
return v, err == nil
case calcNeg:
v, ok := constantValue(n.x, fields)
return -v, ok
case calcBin:
l, lok := constantValue(n.l, fields)
r, rok := constantValue(n.r, fields)
if !lok || !rok {
return 0, false
}
return calcBin{n.op, calcNum(l), calcNum(r)}.eval(nil), true
}
return 0, false
}
// calcText spells an expression node the way an author would read it.
func calcText(n calcNode) string {
switch n := n.(type) {
case calcNum:
return strconv.FormatFloat(float64(n), 'f', -1, 64)
case calcVar:
return string(n)
case calcNeg:
return "-" + calcText(n.x)
case calcBin:
return "(" + calcText(n.l) + " " + string(n.op) + " " + calcText(n.r) + ")"
}
return "?"
}
// neverNumeric reports a node no render of which is a number: fixed text that does // neverNumeric reports a node no render of which is a number: fixed text that does
// not parse, or a choice of only such items. text is one such render. // not parse, or a choice of only such items. text is one such render.
func neverNumeric(n node) (text string, never bool) { func neverNumeric(n node) (text string, never bool) {
+3
View File
@@ -273,6 +273,9 @@ type draws struct {
// linkRefs prove every step, so the walk cannot fail. // linkRefs prove every step, so the walk cannot fail.
func readField(s *session, t *template, held *draws, a arm) string { func readField(s *session, t *template, held *draws, a arm) string {
if !t.held[a.key] { if !t.held[a.key] {
if len(a.tail) > 0 {
panic(fmt.Sprintf("fejkdata: %q reads a path into %q, which the expansion does not hold", a.name, a.key))
}
return render(s, t.fields[a.key]) return render(s, t.fields[a.key])
} }
if v, read := held.value[a.path]; read { if v, read := held.value[a.path]; read {
+12
View File
@@ -57,6 +57,15 @@ type template struct {
func (*template) isNode() {} func (*template) isNode() {}
// field is the node a path segment names; a binding is a render edge, not a field.
func (t *template) field(seg string) (node, bool) {
if isRef(seg) {
return nil, false
}
n, ok := t.fields[seg]
return n, ok
}
// compile converts parsed JSON into a node tree, validating structure up front. // compile converts parsed JSON into a node tree, validating structure up front.
// Only a choice's items carry a weight, so one here would be inert whatever its type. // Only a choice's items carry a weight, so one here would be inert whatever its type.
func compile(v any) (node, error) { func compile(v any) (node, error) {
@@ -229,6 +238,9 @@ func readOptions(m map[string]any) (templateOptions, error) {
if repeat == 1 { if repeat == 1 {
return o, fmt.Errorf("separator joins repeated renders, so it has no effect without a repeat above 1") return o, fmt.Errorf("separator joins repeated renders, so it has no effect without a repeat above 1")
} }
if o.separator == "" {
return o, fmt.Errorf("separator \"\" is the default, so it has no effect; drop it")
}
} }
_, o.weighted = m["weight"] _, o.weighted = m["weight"]
return o, nil return o, nil
+1 -1
View File
@@ -40,7 +40,7 @@ func walkPath(n node, tail []string, w pathWalk) error {
return err return err
} }
} }
child, ok := n.fields[tail[0]] child, ok := n.field(tail[0])
if !ok { if !ok {
return fmt.Errorf("no field %q", tail[0]) return fmt.Errorf("no field %q", tail[0])
} }