Give every table its path in one pass, name it in every message, read the layout probes through predicates, and leave the shell to the CLI

This commit is contained in:
2026-09-18 20:02:08 +02:00
parent 2d08bf4b32
commit d5d9c2655e
7 changed files with 72 additions and 37 deletions
+24 -13
View File
@@ -472,8 +472,9 @@ func iban(r rng, cc string) string {
const dayLayout = "2006-01-02" const dayLayout = "2006-01-02"
// Alike in no field; layoutDay differs from layoutProbe in its date fields only, // The instants a layout is proved against: layoutProbe2 is alike in no field, while
// layoutClock in its clock fields only. // layoutDay differs from layoutProbe in its date fields alone and layoutClock in its
// clock fields alone, so formatting two of them tells which kind a layout names.
var ( var (
layoutProbe = time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC) layoutProbe = time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC)
layoutProbe2 = time.Date(2010, 11, 12, 13, 14, 15, 0, time.UTC) layoutProbe2 = time.Date(2010, 11, 12, 13, 14, 15, 0, time.UTC)
@@ -481,6 +482,18 @@ var (
layoutClock = time.Date(2001, 2, 3, 13, 14, 15, 0, time.UTC) layoutClock = time.Date(2001, 2, 3, 13, 14, 15, 0, time.UTC)
) )
func namesAField(layout string) bool {
return layoutProbe.Format(layout) != layoutProbe2.Format(layout)
}
func namesADateField(layout string) bool {
return layoutProbe.Format(layout) != layoutDay.Format(layout)
}
func namesAClockField(layout string) bool {
return layoutProbe.Format(layout) != layoutClock.Format(layout)
}
// quotedLayout reports whether an arg carries the single quotes a layout is written in. // quotedLayout reports whether an arg carries the single quotes a layout is written in.
func quotedLayout(a string) bool { func quotedLayout(a string) bool {
return len(a) >= 2 && a[0] == '\'' && a[len(a)-1] == '\'' return len(a) >= 2 && a[0] == '\'' && a[len(a)-1] == '\''
@@ -493,10 +506,10 @@ func layoutArg(a string) (string, error) {
if strings.HasPrefix(a, `"`) || strings.HasSuffix(a, `"`) { if strings.HasPrefix(a, `"`) || strings.HasSuffix(a, `"`) {
return "", fmt.Errorf("layout %s is double-quoted; write '%s'", a, bare) return "", fmt.Errorf("layout %s is double-quoted; write '%s'", a, bare)
} }
return "", fmt.Errorf("layout %s is not quoted; write '%s', which a shell keeps only inside a double-quoted argument", a, bare) return "", fmt.Errorf("layout %s is not quoted; write '%s'", a, bare)
} }
layout := a[1 : len(a)-1] layout := a[1 : len(a)-1]
if layoutProbe.Format(layout) == layoutProbe2.Format(layout) { if !namesAField(layout) {
return "", fmt.Errorf("layout '%s' names no field, so it is the constant %q; write it as text", layout, layout) return "", fmt.Errorf("layout '%s' names no field, so it is the constant %q; write it as text", layout, layout)
} }
return layout, nil return layout, nil
@@ -518,15 +531,15 @@ func layoutArity(name string, n int, a []string) error {
return nil return nil
} }
hint := "" hint := ""
if len(a) > n && !split(a[n-1:]) { if len(a) > n && !holdsQuotedLayout(a[n-1:]) {
hint = fmt.Sprintf("; a layout holding a comma is quoted: '%s'", strings.Join(a[n-1:], ", ")) hint = fmt.Sprintf("; a layout holding a comma is quoted: '%s'", strings.Join(a[n-1:], ", "))
} }
return fmt.Errorf("%s takes %d argument%s, got %d%s", name, n, plural(n), len(a), hint) return fmt.Errorf("%s takes %d argument%s, got %d%s", name, n, plural(n), len(a), hint)
} }
// split reports whether the surplus args already hold a quoted layout, which no // holdsQuotedLayout reports whether the surplus args already carry a quoted layout,
// comma split apart. // which no comma split apart.
func split(a []string) bool { func holdsQuotedLayout(a []string) bool {
for _, arg := range a { for _, arg := range a {
if quotedLayout(arg) { if quotedLayout(arg) {
return true return true
@@ -535,8 +548,6 @@ func split(a []string) bool {
return false return false
} }
func plural(n int) string { return map[bool]string{true: "s"}[n != 1] }
func dateArgs(_ map[string]node, a []string) error { func dateArgs(_ map[string]node, a []string) error {
if err := layoutArity("date", 3, a); err != nil { if err := layoutArity("date", 3, a); err != nil {
return err return err
@@ -556,10 +567,10 @@ func dateArgs(_ map[string]node, a []string) error {
if err != nil { if err != nil {
return err return err
} }
if layoutProbe.Format(layout) == layoutDay.Format(layout) { if !namesADateField(layout) {
return fmt.Errorf("date(from,to,layout): '%s' names no date field; write time('%s')", layout, layout) return fmt.Errorf("date(from,to,layout): '%s' names no date field; write time('%s')", layout, layout)
} }
if from.Equal(to) && layoutProbe.Format(layout) == layoutClock.Format(layout) { if from.Equal(to) && !namesAClockField(layout) {
return fmt.Errorf("date(%s,%s,'%s') is the constant %q; write it as text", a[0], a[1], layout, from.Format(layout)) return fmt.Errorf("date(%s,%s,'%s') is the constant %q; write it as text", a[0], a[1], layout, from.Format(layout))
} }
return nil return nil
@@ -573,7 +584,7 @@ func timeArg(_ map[string]node, a []string) error {
if err != nil { if err != nil {
return err return err
} }
if layoutProbe.Format(layout) != layoutDay.Format(layout) { if namesADateField(layout) {
return fmt.Errorf("time(layout): '%s' names a date field; write date(from,to,layout)", layout) return fmt.Errorf("time(layout): '%s' names a date field; write date(from,to,layout)", layout)
} }
return nil return nil
+3
View File
@@ -28,6 +28,9 @@ const usage = `Usage: fejkdata [flags] <path|template>
<template> a format string or JSON value to render inline, e.g. <template> a format string or JSON value to render inline, e.g.
'name: {/sv_SE.person.last}' or '{"format":"{x}","x":["bosse","lina"]}' 'name: {/sv_SE.person.last}' or '{"format":"{x}","x":["bosse","lina"]}'
A layout inside a template is single-quoted, so quote the whole argument with " to
keep it: "{date(1990-01-01,2010-12-31,'2006-01-02')}".
An argument containing a { token, or a JSON object, array or string, is a An argument containing a { token, or a JSON object, array or string, is a
template; any other argument is a path (a path never contains a brace or a quote, template; any other argument is a path (a path never contains a brace or a quote,
and a bracket only as a [selector] after a table's name). Templates reach the and a bracket only as a [selector] after a table's name). Templates reach the
+1
View File
@@ -62,6 +62,7 @@ func loadData(sources []dataSource) (map[string]node, error) {
if len(root) == 0 { if len(root) == 0 {
return nil, fmt.Errorf("no .json data found") return nil, fmt.Errorf("no .json data found")
} }
setTablePaths(root)
if err := linkTables(root); err != nil { if err := linkTables(root); err != nil {
return nil, err return nil, err
} }
+1 -1
View File
@@ -97,7 +97,7 @@ func (d *draws) rowOf(t *table) int {
// pinRow pins row r of t where it agrees with the rows pinned before it. // pinRow pins row r of t where it agrees with the rows pinned before it.
func (d *draws) pinRow(t *table, r int) error { func (d *draws) pinRow(t *table, r int) error {
if pr, ok := d.pinned(t); ok && pr != r { if pr, ok := d.pinned(t); ok && pr != r {
return fmt.Errorf("%s and %s are two rows of %s", t.selectorSpelling(pr), t.selectorSpelling(r), t.category) return fmt.Errorf("%s and %s are two rows of %s", t.selectorSpelling(pr), t.selectorSpelling(r), t.path)
} }
for a := t.parentT; a != nil; a = a.parentT { for a := t.parentT; a != nil; a = a.parentT {
if pa, ok := d.pinned(a); ok && !t.under(r, a, pa) { if pa, ok := d.pinned(a); ok && !t.under(r, a, pa) {
+2 -2
View File
@@ -141,8 +141,8 @@ func (f *Generator) FakeRecord(path string) (*Record, error) {
} }
shape := f.recordShapeOf(n) shape := f.recordShapeOf(n)
if errors.Is(shape.err, errNoColumns) { if errors.Is(shape.err, errNoColumns) {
column := path[strings.LastIndex(path, ".")+1:] ns := names(segments)
return nil, fmt.Errorf("fejkdata: %s %w; render it as a column of one: '{\"format\":\"\",\"%s\":\"{/%s}\"}'", path, shape.err, column, path) return nil, fmt.Errorf(`fejkdata: %s %w; render it as a column of one: {"format":"","%s":"{/%s}"}`, path, shape.err, ns[len(ns)-1], path)
} }
if shape.err != nil { if shape.err != nil {
return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err) return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err)
+34 -21
View File
@@ -14,7 +14,7 @@ import (
// cell of the row a render pinned; a cell carrying tokens compiles to a string node. // cell of the row a render pinned; a cell carrying tokens compiles to a string node.
type table struct { type table struct {
category string category string
path string // the category's path from the data root, set when the tables are linked path string // the category's path from the data root, which a selector is written at
file string file string
format *template // fields are the column nodes format *template // fields are the column nodes
columns []string columns []string
@@ -340,6 +340,23 @@ func (t *table) compileFormat(format string) error {
// linkTables binds every table's parent to the table beside it, and proves the // linkTables binds every table's parent to the table beside it, and proves the
// links: a parent has a key, every link cell is one, every parent row is linked // links: a parent has a key, every link cell is one, every parent row is linked
// to, no chain of parents closes, and no child is named like a parent's column. // to, no chain of parents closes, and no child is named like a parent's column.
// setTablePaths gives every table the path a selector on it is written at, before a
// link or a draw can name one.
func setTablePaths(root map[string]node) {
var walk func(dir string, children map[string]node)
walk = func(dir string, children map[string]node) {
for name, child := range children {
switch n := child.(type) {
case *folder:
walk(join(dir, name), n.children)
case *table:
n.path = join(dir, name)
}
}
}
walk("", root)
}
func linkTables(root map[string]node) error { func linkTables(root map[string]node) error {
var walk func(dir string, children map[string]node) error var walk func(dir string, children map[string]node) error
walk = func(dir string, children map[string]node) error { walk = func(dir string, children map[string]node) error {
@@ -351,7 +368,6 @@ func linkTables(root map[string]node) error {
return err return err
} }
case *table: case *table:
n.path = path
if n.parent < 0 { if n.parent < 0 {
continue continue
} }
@@ -510,7 +526,7 @@ func (t *table) under(r int, a *table, pr int) bool {
// naming several rows resolves inside the ancestors pinned in d. // naming several rows resolves inside the ancestors pinned in d.
func (t *table) find(sel string, d *draws) (int, error) { func (t *table) find(sel string, d *draws) (int, error) {
if t.key < 0 && t.name < 0 { if t.key < 0 && t.name < 0 {
return 0, fmt.Errorf("%s has no key or name column to select a row by", t.spelledPath()) return 0, fmt.Errorf("%s has no key or name column to select a row by", t.path)
} }
if r, ok := t.byKey[sel]; ok { if r, ok := t.byKey[sel]; ok {
return r, nil return r, nil
@@ -523,34 +539,31 @@ func (t *table) find(sel string, d *draws) (int, error) {
case 1: case 1:
return rows[0], nil return rows[0], nil
case 0: case 0:
return 0, fmt.Errorf("no row of %s has key or name %q", t.spelledPath(), sel) return 0, fmt.Errorf("no row of %s has key or name %q", t.path, sel)
} }
keys := make([]string, len(rows)) return 0, t.ambiguous(sel, rows)
}
// ambiguous names each row a selector could have meant: a keyed table by its keys,
// and one told apart only by its parent by the path that selects it.
func (t *table) ambiguous(sel string, rows []int) error {
spellings := make([]string, len(rows))
for i, r := range rows { for i, r := range rows {
if t.key < 0 { if t.key < 0 {
keys[i] = t.selectorSpelling(r) spellings[i] = t.selectorSpelling(r)
} else { } else {
keys[i] = t.cell(r, t.key) spellings[i] = t.cell(r, t.key)
} }
} }
listed := strings.Join(keys, ", ") listed := strings.Join(spellings, ", ")
if t.key < 0 { if t.key < 0 {
return 0, fmt.Errorf("%q names %d rows of %s; select it inside its %s, one of %s", sel, len(rows), t.spelledPath(), t.parentT.spelledPath(), listed) return fmt.Errorf("%q names %d rows of %s; select it inside its %s, one of %s", sel, len(rows), t.path, t.parentT.path, listed)
} }
inside := "" inside := ""
if t.parentT != nil { if t.parentT != nil {
inside = fmt.Sprintf(", or select it inside its %s", t.parentT.category) inside = fmt.Sprintf(", or select it inside its %s", t.parentT.path)
} }
return 0, fmt.Errorf("%q names %d rows of %s; select one by key, one of %s%s", sel, len(rows), t.spelledPath(), listed, inside) return fmt.Errorf("%q names %d rows of %s; select one by key, one of %s%s", sel, len(rows), t.path, listed, inside)
}
// spelledPath is the path a selector on t is written at, the category's own name
// until the tables are linked.
func (t *table) spelledPath() string {
if t.path == "" {
return t.category
}
return t.path
} }
// selectorSpelling is how a path writes a selected row, for messages: by key, or // selectorSpelling is how a path writes a selected row, for messages: by key, or
@@ -558,7 +571,7 @@ func (t *table) spelledPath() string {
func (t *table) selectorSpelling(r int) string { func (t *table) selectorSpelling(r int) string {
switch { switch {
case t.key >= 0: case t.key >= 0:
return t.spelledPath() + "[" + t.cell(r, t.key) + "]" return t.path + "[" + t.cell(r, t.key) + "]"
case t.name >= 0: case t.name >= 0:
return t.parentT.selectorSpelling(t.parentRow(r)) + "." + t.category + "[" + t.cell(r, t.name) + "]" return t.parentT.selectorSpelling(t.parentRow(r)) + "." + t.category + "[" + t.cell(r, t.name) + "]"
} }
+7
View File
@@ -112,6 +112,13 @@ func splitArgs(s string) []string {
return append(args, strings.TrimSpace(s[start:])) return append(args, strings.TrimSpace(s[start:]))
} }
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// checkFunc validates a function token at compile time: well-formed, naming a // checkFunc validates a function token at compile time: well-formed, naming a
// known builtin, with the arg count that builtin takes and args its check accepts. // known builtin, with the arg count that builtin takes and args its check accepts.
// fields is passed through for the one builtin (calc) that validates against them. // fields is passed through for the one builtin (calc) that validates against them.