diff --git a/builtins.go b/builtins.go index 6075668..5a71929 100644 --- a/builtins.go +++ b/builtins.go @@ -7,6 +7,7 @@ import ( "math" "strconv" "strings" + "time" "unicode" ) @@ -60,6 +61,8 @@ var builtins = map[string]builtin{ cc := a[0] return func(s *session, _ string, _ []string) string { return iban(s, cc) } }}, + "date": {arity: 3, check: dateArgs, prep: datePrep}, + "time": {arity: 1, check: timeArg, prep: timePrep}, "calc": {arity: -1, check: checkCalc, prep: calcPrep, operands: calcOperands}, "lowercase": {arity: 1, check: transformArg, prep: transformPrep(strings.ToLower), operands: transformOperand}, "uppercase": {arity: 1, check: transformArg, prep: transformPrep(strings.ToUpper), operands: transformOperand}, @@ -466,3 +469,87 @@ func iban(r rng, cc string) string { feed(0) return fmt.Sprintf("%s%02d%s", cc, 98-rem, bban) } + +const dayLayout = "2006-01-02" + +// The two instants a layout is proved against: alike in nothing, so a layout that +// formats them alike names no field, and one that tells their days apart at the same +// clock names a date field. +var ( + layoutProbe = time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC) + layoutProbe2 = time.Date(2010, 11, 12, 13, 14, 15, 0, time.UTC) + layoutDay = time.Date(2010, 11, 12, 4, 5, 6, 0, time.UTC) +) + +// layoutArg is the Go layout a quoted arg holds; unquoted, it is refused naming the +// quoted spelling, since a layout may carry the comma that splits args. +func layoutArg(a string) (string, error) { + if len(a) < 2 || a[0] != '\'' || a[len(a)-1] != '\'' { + return "", fmt.Errorf("layout %s is not quoted; write '%s'", a, strings.Trim(a, "'")) + } + layout := a[1 : len(a)-1] + if layoutProbe.Format(layout) == layoutProbe2.Format(layout) { + return "", fmt.Errorf("layout '%s' names no field, so it is the constant %q; write it as text", layout, layout) + } + return layout, nil +} + +// layoutOf is layoutArg for an arg a check already validated. +func layoutOf(a string) string { + layout, err := layoutArg(a) + if err != nil { + panic(fmt.Sprintf("fejkdata: builtin arg %q reached prep unvalidated: %v", a, err)) + } + return layout +} + +func dateArgs(_ map[string]node, a []string) error { + from, err := time.Parse(dayLayout, a[0]) + if err != nil { + return fmt.Errorf("date(from,to,layout): from %q is not a YYYY-MM-DD date", a[0]) + } + to, err := time.Parse(dayLayout, a[1]) + if err != nil { + return fmt.Errorf("date(from,to,layout): to %q is not a YYYY-MM-DD date", a[1]) + } + if !from.Before(to) { + return fmt.Errorf("date(from,to,layout): from %s is not before to %s", a[0], a[1]) + } + _, err = layoutArg(a[2]) + return err +} + +func timeArg(_ map[string]node, a []string) error { + layout, err := layoutArg(a[0]) + if err != nil { + return err + } + if layoutProbe.Format(layout) != layoutDay.Format(layout) { + return fmt.Errorf("time(layout): '%s' names a date field; write date(from,to,layout)", layout) + } + return nil +} + +// datePrep draws a second in [from 00:00:00, to 23:59:59] UTC; the span is counted +// in seconds, since a Duration overflows past 292 years. +func datePrep(a []string) callFn { + from, err := time.Parse(dayLayout, a[0]) + if err != nil { + panic(fmt.Sprintf("fejkdata: builtin arg %q reached prep unvalidated: %v", a[0], err)) + } + to, err := time.Parse(dayLayout, a[1]) + if err != nil { + panic(fmt.Sprintf("fejkdata: builtin arg %q reached prep unvalidated: %v", a[1], err)) + } + layout, start, span := layoutOf(a[2]), from.Unix(), int(to.Unix()-from.Unix())+86400 + return func(s *session, _ string, _ []string) string { + return time.Unix(start+int64(s.IntN(span)), 0).UTC().Format(layout) + } +} + +func timePrep(a []string) callFn { + layout := layoutOf(a[0]) + return func(s *session, _ string, _ []string) string { + return time.Unix(int64(s.IntN(86400)), 0).UTC().Format(layout) + } +} diff --git a/table.go b/table.go index dd8c811..1800d84 100644 --- a/table.go +++ b/table.go @@ -222,8 +222,8 @@ func (t *table) bindOptions(o tableOptionValues) error { } *opt.into = i } - if t.name >= 0 && t.key < 0 { - return fmt.Errorf("name selects a row as a key does, and lists the rows it matches by their keys, so it needs a key column; add key") + if t.name >= 0 && t.key < 0 && t.parent < 0 { + return fmt.Errorf("name selects a row as a key does, and lists the rows it matches by their keys, so it needs a key column, or a parent inside which each name is one row; add key") } if err := t.indexKeys(); err != nil { return err @@ -231,9 +231,18 @@ func (t *table) bindOptions(o tableOptionValues) error { return t.sumWeights() } -// indexKeys proves every key names one row, and keeps the index a link is proved by. +// indexKeys proves every key names one row, and keeps the index a link is proved by; +// without a key, a name names one row inside its parent. func (t *table) indexKeys() error { if t.key < 0 { + inside := make(map[string]int, t.rows()) + for r := 0; r < t.rows() && t.name >= 0; r++ { + k := t.cell(r, t.parent) + "\t" + t.cell(r, t.name) + if first, dup := inside[k]; dup { + return fmt.Errorf("%s line %d: name %q repeats line %d inside %s %q; a name selects one row inside its parent", t.file, r+2, t.cell(r, t.name), first+2, t.columns[t.parent], t.cell(r, t.parent)) + } + inside[k] = r + } return nil } t.byKey = make(map[string]int, t.rows()) @@ -488,8 +497,8 @@ func (t *table) under(r int, a *table, pr int) bool { // find is the row a selector names: by key first, then by name, where a name // naming several rows resolves inside the ancestors pinned in d. func (t *table) find(sel string, d *draws) (int, error) { - if t.key < 0 { - return 0, fmt.Errorf("%s has no key column to select a row by", t.category) + if t.key < 0 && t.name < 0 { + return 0, fmt.Errorf("%s has no key or name column to select a row by", t.category) } if r, ok := t.byKey[sel]; ok { return r, nil @@ -506,7 +515,14 @@ func (t *table) find(sel string, d *draws) (int, error) { } keys := make([]string, len(rows)) for i, r := range rows { - keys[i] = t.cell(r, t.key) + if t.key < 0 { + keys[i] = t.selectorSpelling(r) + } else { + keys[i] = t.cell(r, t.key) + } + } + if t.key < 0 { + return 0, fmt.Errorf("%q names %d rows of %s; select it inside its %s, one of %v", sel, len(rows), t.category, t.parentT.category, keys) } inside := "" if t.parentT != nil { @@ -515,7 +531,14 @@ func (t *table) find(sel string, d *draws) (int, error) { return 0, fmt.Errorf("%q names %d rows of %s; select one by key, one of %v%s", sel, len(rows), t.category, keys, inside) } -// selectorSpelling is how a path writes a selected row, for messages. +// selectorSpelling is how a path writes a selected row, for messages: by key, or +// by name inside its parent's row. func (t *table) selectorSpelling(r int) string { - return t.category + "[" + t.cell(r, t.key) + "]" + switch { + case t.key >= 0: + return t.category + "[" + t.cell(r, t.key) + "]" + case t.name >= 0: + return t.parentT.selectorSpelling(t.parentRow(r)) + "." + t.category + "[" + t.cell(r, t.name) + "]" + } + return fmt.Sprintf("%s line %d", t.file, r+2) } diff --git a/template.go b/template.go index 31c5526..321db26 100644 --- a/template.go +++ b/template.go @@ -88,17 +88,28 @@ func funcCall(body string) (name string, args []string, ok bool) { return body[:lp], splitArgs(body[lp+1 : len(body)-1]), true } -// splitArgs parses a function arg list: comma-separated outside a selector, -// trimmed; empty -> none. +// splitArgs parses a function arg list: comma-separated outside a selector or a +// quoted layout, trimmed; empty -> none. func splitArgs(s string) []string { if strings.TrimSpace(s) == "" { return nil } - args := splitOutside(s, ',') - for i := range args { - args[i] = strings.TrimSpace(args[i]) + var args []string + depth, quoted, start := 0, false, 0 + for i := 0; i < len(s); i++ { + switch c := s[i]; { + case c == '\'': + quoted = !quoted + case quoted: + case c == '[': + depth++ + case c == ']' && depth > 0: + depth-- + case c == ',' && depth == 0: + args, start = append(args, strings.TrimSpace(s[start:i])), i+1 + } } - return args + return append(args, strings.TrimSpace(s[start:])) } // checkFunc validates a function token at compile time: well-formed, naming a