diff --git a/CHANGELOG.md b/CHANGELOG.md index 973a7e5..6630905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ replacement, and each removed path, column or flag. - Names: `{/misc.territory as t}` binds one row of `misc.territory` to `t` and prints nothing; `{t}`, `{t.capital}` and `{lowercase(t.name)}` read that one row. A name lives in the category binding it, and a `repeat` binding it picks again on every iteration. - What `New` refuses is listed under [Names](README.md#names). + A name is a `{calc()}` operand: `{/n as k}{k} x 2 = {calc(k * 2)}`. What `New` refuses is listed under [Names](README.md#names). - First release: the CLI, the library and the shipped data set. - Table categories: a category JSON naming a `rows` TSV beside it, with the options `key`, `name`, `weight` and `parent`; a path selects a row by key or name, diff --git a/README.md b/README.md index 63cdb77..fd304b7 100644 --- a/README.md +++ b/README.md @@ -676,9 +676,10 @@ the SQL example above. ### Computation `{calc(expr)}` evaluates `+ - * /`, parentheses and unary minus over number -literals and sibling field names, each rendered then read as a number; a second -argument rounds to that many decimals. A hyphen is always subtraction, so a -hyphenated field can't be an operand. +literals, sibling field names and [names](#names), each rendered then read as a number; +a second argument rounds to that many decimals. A name is the operand `{k}` prints: +`{/n as k}{k} x 2 = {calc(k * 2)}` doubles the pick it shows. A hyphen is always +subtraction, so a hyphenated field or name can't be an operand. ```json { "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] } @@ -770,7 +771,8 @@ Refused at `New`, each error naming what to write instead: - a binding of anything but a reference; - a binding in a choice's item, which every other item would leave unbound; - a binding nothing reads, or a binding of a whole category read once whole where it is - bound, which the bare reference spells: `{/word as w}{w}` is `{/word}`; + bound, which the bare reference spells: `{/word as w}{w}` is `{/word}`, while + `{/n as k}{calc(k * 2)}` stands, since a calc operand is no reference; - a path through a name that selects a row; - `{n}` beside `{n.w}` where `n`'s category renders `w` twice, as in `{w}-{w}`; - reads of a name in two draw groups, or inside a `repeat` nested where the name is bound, diff --git a/calc.go b/calc.go index 081fd52..b6e7fd7 100644 --- a/calc.go +++ b/calc.go @@ -3,6 +3,7 @@ package fejkdata import ( "fmt" "math" + "slices" "strconv" "strings" "unicode" @@ -15,7 +16,7 @@ type calcNode interface { } type calcNum float64 // a number literal -type calcVar string // a sibling-field name, before indexVars places it +type calcVar string // an operand, a sibling field or a name, before indexVars places it type calcIdx int // an operand, by its position in the values expand read type calcNeg struct{ x calcNode } type calcBin struct { // a + - * / b @@ -53,8 +54,9 @@ func (n calcBin) eval(operands []string) float64 { } } -// checkCalc validates a calc token at compile time: a parseable expression whose -// operands all name existing fields, and an optional non-negative integer dp. +// checkCalc validates a calc token at compile time: a parseable expression, operands +// that can be numbers, and an optional non-negative integer dp. An operand no field +// holds is left for a name to answer, and checkCalcNames checks it once names link. func checkCalc(fields map[string]node, args []string) error { if len(args) < 1 || len(args) > 2 { return fmt.Errorf("calc takes an expression and an optional decimals count, got %d args", len(args)) @@ -63,17 +65,13 @@ func checkCalc(fields map[string]node, args []string) error { if err != nil { return fmt.Errorf("calc(%q): %w", args[0], err) } - for _, name := range calcVars(expr) { - operand, ok := fields[name] - if !ok { - return fmt.Errorf("calc(%q): no field %q", args[0], name) + if err := checkOperands(args[0], expr, func(name string) []node { + if n, ok := fields[name]; ok { + return []node{n} } - if text, never := neverNumeric(operand); never { - 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) + return nil + }); err != nil { + return err } if len(args) == 2 { dp, err := plainInt(args[1]) @@ -87,33 +85,76 @@ func checkCalc(fields map[string]node, args []string) error { return nil } +// checkCalcNames holds each calc of t reading a name to the checks checkCalc makes of a field. +func checkCalcNames(path string, t *template) error { + for _, o := range t.compiled.ops { + if o.fn != "calc" || !slices.ContainsFunc(o.operands, func(a arm) bool { return a.kind == namedRead }) { + continue + } + if err := checkOperands(o.args[0], parsedCalc(o.args[0]), operandNodes(t, o)); err != nil { + return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), o.body, err) + } + } + return nil +} + +// operandNodes lists every node each operand of o may render: its field, or each node its name's +// path lands on. +func operandNodes(t *template, o op) func(name string) []node { + return func(name string) []node { + i := slices.IndexFunc(o.operands, func(a arm) bool { return a.head == name }) + if a := o.operands[i]; a.kind == namedRead { + return a.leaves + } + return []node{t.fields[name]} + } +} + +// checkOperands refuses an operand that is never a number and a division by a constant zero. +// operand lists every node an operand may render, nil while that is unknown. +func checkOperands(text string, expr calcNode, operand func(name string) []node) error { + for _, name := range calcVars(expr) { + if rendered, never := neverNumber(operand(name)); never { + return fmt.Errorf("calc(%q): operand %q is never a number: it renders %q", text, name, rendered) + } + } + if divisor, zero := constantZeroDivisor(expr, operand); zero { + return fmt.Errorf("calc(%q) divides by %s, which is always zero", text, divisor) + } + 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) { +func constantZeroDivisor(n calcNode, operand func(string) []node) (string, bool) { switch n := n.(type) { case calcNeg: - return constantZeroDivisor(n.x, fields) + return constantZeroDivisor(n.x, operand) case calcBin: if n.operator == '/' { - if v, known := constantValue(n.r, fields); known && v == 0 { + if v, known := constantValue(n.r, operand); known && v == 0 { return calcText(n.r), true } } - if d, zero := constantZeroDivisor(n.l, fields); zero { + if d, zero := constantZeroDivisor(n.l, operand); zero { return d, true } - return constantZeroDivisor(n.r, fields) + return constantZeroDivisor(n.r, operand) } return "", false } // constantValue evaluates an expression whose every operand is fixed. -func constantValue(n calcNode, fields map[string]node) (float64, bool) { +func constantValue(n calcNode, operand func(string) []node) (float64, bool) { switch n := n.(type) { case calcNum: return float64(n), true case calcVar: - t, ok := fields[string(n)].(*template) + nodes := operand(string(n)) + if len(nodes) != 1 { + return 0, false + } + t, ok := nodes[0].(*template) if !ok || t.repeat > 1 { return 0, false } @@ -124,11 +165,11 @@ func constantValue(n calcNode, fields map[string]node) (float64, bool) { v, err := strconv.ParseFloat(strings.TrimSpace(lit), 64) return v, err == nil case calcNeg: - v, ok := constantValue(n.x, fields) + v, ok := constantValue(n.x, operand) return -v, ok case calcBin: - l, lok := constantValue(n.l, fields) - r, rok := constantValue(n.r, fields) + l, lok := constantValue(n.l, operand) + r, rok := constantValue(n.r, operand) if !lok || !rok { return 0, false } @@ -167,26 +208,28 @@ func neverNumeric(n node) (text string, never bool) { return lit, true } case *choice: - for _, it := range n.items { - t, itemNever := neverNumeric(it) - if !itemNever { - return "", false - } - text = t - } - return text, true + return neverNumber(n.items) } return "", false } +// neverNumber reports nodes no render of which is a number, and none where there are none. +func neverNumber(nodes []node) (text string, never bool) { + for _, n := range nodes { + t, nodeNever := neverNumeric(n) + if !nodeNever { + return "", false + } + text = t + } + return text, len(nodes) > 0 +} + // calcPrep parses the expression and decimals once, at compile time, and places each // operand name at the position expand will read it into. checkCalc proved both args // valid, so no step here can fail. func calcPrep(args []string) callFn { - expr, err := parseCalc(args[0]) - if err != nil { // a nil AST would be a nil dereference per render, with no message - panic(internalError("calc(%q) reached prep unparsed: %v", args[0], err)) - } + expr := parsedCalc(args[0]) at := make(map[string]int) for i, name := range calcVars(expr) { at[name] = i @@ -198,6 +241,16 @@ func calcPrep(args []string) callFn { } } +// parsedCalc parses an expression checkCalc accepted. A nil AST would be a nil dereference +// per render, with no message. +func parsedCalc(expr string) calcNode { + n, err := parseCalc(expr) + if err != nil { + panic(internalError("calc(%q) passed its check unparsed: %v", expr, err)) + } + return n +} + // calcDecimals is a calc's decimals count, or shortestDecimals where it names none. func calcDecimals(args []string) int { if len(args) == 2 { @@ -224,7 +277,7 @@ func indexVars(n calcNode, at map[string]int) calcNode { return n } -// calcOperands lists the sibling-field names a calc's args read. checkCalc reports +// calcOperands lists the operands a calc's args read, fields or names. checkCalc reports // an expression that does not parse, so one that does not simply names nothing. func calcOperands(args []string) []string { if len(args) == 0 { @@ -237,7 +290,7 @@ func calcOperands(args []string) []string { return calcVars(expr) } -// calcVars lists the distinct field names an expression reads, in the order it first +// calcVars lists the distinct operands an expression reads, in the order it first // names each. That order is the contract between expand, which reads the operands // into a slice, and indexVars, which places each name at its position in it. func calcVars(n calcNode) []string { diff --git a/inline.go b/inline.go index df9b4d5..e4bfde6 100644 --- a/inline.go +++ b/inline.go @@ -200,7 +200,7 @@ func linkNodeRefs(scope nodeScope, root map[string]node) error { }); err != nil { return err } - for _, pass := range []func(string, *template) error{linkNames, checkNameReads} { + for _, pass := range []func(string, *template) error{linkNames, checkNameReads, checkCalcNames} { if err := scope(func(path string, m node) error { if t, ok := m.(*template); ok { return pass(path, t) diff --git a/namefence.go b/namefence.go index f99765d..5e677d4 100644 --- a/namefence.go +++ b/namefence.go @@ -26,15 +26,11 @@ func checkNameReads(path string, t *template) error { }) } -// checkUses refuses a binding of a category read once whole, and reads of b in two draw groups, +// checkUses refuses a binding of a category read once whole, outside a calc, which takes no reference, and reads of b in two draw groups, // or inside a repeat, where what b names reads a reference path. func (b *nameBinding) checkUses() error { - if r := b.uses[0]; len(b.uses) == 1 && r.tail == "" && !r.nested && len(b.tail) == 0 { - spelling := b.ref - if !r.operand { - spelling = "{" + spelling + "}" - } - return fmt.Errorf("name %q is read once, whole, which the bare reference draws the same way; write %s where it is read, and drop the token", b.name, spelling) + if r := b.uses[0]; len(b.uses) == 1 && r.tail == "" && !r.nested && !r.operand && len(b.tail) == 0 { + return fmt.Errorf("name %q is read once, whole, which the bare reference draws the same way; write {%s} where it is read, and drop the token", b.name, b.ref) } if !readsHeld(b.head, map[node]bool{}) { return nil diff --git a/reference.go b/reference.go index fc717c6..6b81f82 100644 --- a/reference.go +++ b/reference.go @@ -77,10 +77,12 @@ func linkRefs(sites []categorySite, root map[string]node) error { }); err != nil { return err } - if err := eachTemplate(sites, func(_ categorySite, path string, t *template) error { return linkNames(path, t) }); err != nil { - return err + for _, pass := range []func(string, *template) error{linkNames, checkNameReads, checkCalcNames} { + if err := eachTemplate(sites, func(_ categorySite, path string, t *template) error { return pass(path, t) }); err != nil { + return err + } } - return eachTemplate(sites, func(_ categorySite, path string, t *template) error { return checkNameReads(path, t) }) + return nil } // linkTemplate resolves t's references in category, "" for an inline template, and diff --git a/valueproof.go b/valueproof.go index 6a8bf3e..b77501a 100644 --- a/valueproof.go +++ b/valueproof.go @@ -210,11 +210,9 @@ func (p *valueProof) proveTemplate(t *template) proven { } func (p *valueProof) proveCalc(t *template, body string, args []string) proven { - expr, err := parseCalc(args[0]) - if err != nil { - panic(internalError("calc(%q) reached a proof unparsed: %v", args[0], err)) - } - v, doubt := p.proveExpr(expr, t.fields) + expr := parsedCalc(args[0]) + nodes := operandNodes(t, t.compiled.ops[0]) + v, doubt := p.proveExpr(expr, func(name string) proven { return p.proveUnion(nodes(name)) }) if doubt == "" && !(magnitude(v) <= calcLimit) { doubt = calcText(expr) + " is not proven within 1e300" } @@ -229,27 +227,27 @@ func (p *valueProof) proveCalc(t *template, body string, args []string) proven { const calcLimit = 1e300 // proveExpr bounds a calc expression from its operands, or says why it cannot. -func (p *valueProof) proveExpr(n calcNode, fields map[string]node) (proven, string) { +func (p *valueProof) proveExpr(n calcNode, operand func(name string) proven) (proven, string) { switch n := n.(type) { case calcNum: v := float64(n) return bounded(v, v, v == math.Trunc(v)), "" case calcVar: - v := p.prove(fields[string(n)]) + v := operand(string(n)) if v.notOperand != "" { return proven{}, fmt.Sprintf("operand %q: %s", string(n), v.notOperand) } return proven{lo: v.lo, hi: v.hi, nonZero: v.nonZero, integral: v.integral}, "" case calcNeg: - v, doubt := p.proveExpr(n.x, fields) + v, doubt := p.proveExpr(n.x, operand) v.lo, v.hi = -v.hi, -v.lo return v, doubt case calcBin: - l, doubt := p.proveExpr(n.l, fields) + l, doubt := p.proveExpr(n.l, operand) if doubt != "" { return l, doubt } - r, doubt := p.proveExpr(n.r, fields) + r, doubt := p.proveExpr(n.r, operand) if doubt != "" { return r, doubt }