From 3e7127da71f167d86389fb8dacf831398ebfe403 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 15:08:26 +0200 Subject: [PATCH] Parse a divisor's rounding half exactly, print zero unsigned, name int64 refusals, and quote CSV fields without a writer --- README.md | 9 +++---- builtins.go | 27 ++++++++++++++------- calc.go | 2 +- datatype.go | 2 +- node.go | 6 +++-- record.go | 20 ++++++++-------- template.go | 6 ++--- value.go | 69 +++++++++++++++++++++++++++++++---------------------- 8 files changed, 83 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index ae37af6..2cda366 100644 --- a/README.md +++ b/README.md @@ -273,8 +273,8 @@ Writes e.g. `{"id":1,"paid":true,"total":59.97}`. A column is a field of the top template, or an item of a choice standing in for one; `datatype` anywhere else is a load error. A typed column holds one value, alone in its format: a literal, one `{int()}`, `{float()}`, `{seq()}` or `{calc()}` call, or a read that lands only on such -values. `integer` is an int64 written `-?(0|[1-9][0-9]*)` — `{float()}` prints one at -`0` decimals — `number` a JSON number, `boolean` `true` or `false`. A value its +values. `integer` is an int64 written `0|-?[1-9][0-9]*` — `{float()}` prints one at +`0` decimals within int64 — `number` a JSON number, `boolean` `true` or `false`. A value its datatype cannot hold is a load error naming it: ```text @@ -287,7 +287,7 @@ literal, an `{int()}`, `{float()}`, `{seq()}` or `{digits()}` call, a calc, or a such values, whose bounds keep every divisor from zero and the result within `1e300`. What the bounds cannot show is refused — `{calc(a / b)}: divides by b, which is not proven nonzero`. The calc fills an `integer` column at `0` decimals, or over whole -operands with no `/`. +operands with no `/`, while its bounds stay within int64. ### Null @@ -365,7 +365,8 @@ hyphenated field can't be an operand. { "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] } ``` -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`. A result that rounds to zero prints unsigned — `0`, +`0.00` — as `{float()}`'s does. An operand that can never be a number (`"abc"`, or a choice of such) is rejected at load, as is a division by a constant zero (`1/0`, or a fixed `"0"` field); an operand that sometimes is not a number yields `NaN`, and a division by one that is not constant `Inf` — both print rather than diff --git a/builtins.go b/builtins.go index 833c2e2..6075668 100644 --- a/builtins.go +++ b/builtins.go @@ -31,8 +31,8 @@ var builtins = map[string]builtin{ "ulid": {arity: 0, prep: sample(ulid)}, "nanoid": {arity: 1, check: posIntArg, prep: chars(nanoidAlphabet)}, "hex": {arity: 1, check: posIntArg, prep: chars(hexDigits)}, - "digits": {arity: 1, check: posIntArg, prep: chars("0123456789"), number: func(a []string) (proven, DataType) { - return bounded(0, math.Pow(10, float64(atoi(a[0])))-1, true), DataTypeString + "digits": {arity: 1, check: posIntArg, prep: chars("0123456789"), number: func(token string, a []string) proven { + return printing(token, DataTypeString, bounded(0, math.Pow(10, float64(atoi(a[0])))-1, true)) }}, "upper": {arity: 1, check: posIntArg, prep: chars("ABCDEFGHIJKLMNOPQRSTUVWXYZ")}, "lower": {arity: 1, check: posIntArg, prep: chars("abcdefghijklmnopqrstuvwxyz")}, @@ -45,16 +45,16 @@ var builtins = map[string]builtin{ "int": {arity: 2, check: intRangeArgs, prep: func(a []string) callFn { lo, span := atoi(a[0]), atoi(a[1])-atoi(a[0])+1 return func(s *session, _ string, _ []string) string { return strconv.Itoa(lo + s.IntN(span)) } - }, number: func(a []string) (proven, DataType) { - return bounded(float64(atoi(a[0])), float64(atoi(a[1])), true), DataTypeInteger + }, number: func(token string, a []string) proven { + return printing(token, DataTypeInteger, bounded(float64(atoi(a[0])), float64(atoi(a[1])), true)) }}, "float": {arity: 3, check: floatArgs, prep: func(a []string) callFn { lo, hi, dp := atof(a[0]), atof(a[1]), atoi(a[2]) return func(s *session, _ string, _ []string) string { - return strconv.FormatFloat(lo+s.Float64()*(hi-lo), 'f', dp, 64) + return formatFloat(lo+s.Float64()*(hi-lo), dp) } - }, number: func(a []string) (proven, DataType) { - return printedNumber(bounded(atof(a[0]), atof(a[1]), false), atoi(a[2])) + }, number: func(token string, a []string) proven { + return printedNumber(token, bounded(atof(a[0]), atof(a[1]), false), atoi(a[2])) }}, "iban": {arity: 1, check: ibanArg, prep: func(a []string) callFn { cc := a[0] @@ -75,8 +75,8 @@ var builtins = map[string]builtin{ return func(s *session, _ string, _ []string) string { return strconv.FormatUint(s.next(key), 10) } - }, number: func([]string) (proven, DataType) { - return bounded(1, math.MaxInt64, true), DataTypeInteger + }, number: func(token string, _ []string) proven { + return printing(token, DataTypeInteger, bounded(1, math.MaxInt64, true)) }}, } @@ -104,6 +104,15 @@ func chars(alphabet string) func([]string) callFn { const hexDigits = "0123456789abcdef" +// formatFloat prints v to dp decimals, -1 for the shortest form, and a zero unsigned. +func formatFloat(v float64, dp int) string { + s := strconv.FormatFloat(v, 'f', dp, 64) + if strings.HasPrefix(s, "-") && strings.Trim(s, "-0.") == "" { + return s[1:] + } + return s +} + // transforms are the builtins that rewrite one operand's value; they nest, so // {lowercase(ascii(x))} folds then lowers. var transforms = map[string]func(string) string{ diff --git a/calc.go b/calc.go index 901e8c9..eb0c2c4 100644 --- a/calc.go +++ b/calc.go @@ -195,7 +195,7 @@ func calcPrep(args []string) callFn { placed := indexVars(expr, at) dp := calcDecimals(args) return func(_ *session, _ string, operands []string) string { - return strconv.FormatFloat(placed.eval(operands), 'f', dp, 64) + return formatFloat(placed.eval(operands), dp) } } diff --git a/datatype.go b/datatype.go index 94873de..03bdb7f 100644 --- a/datatype.go +++ b/datatype.go @@ -100,7 +100,7 @@ func disagreement(a, b *template) error { switch { case bare.datatype != DataTypeString: return fmt.Errorf("its items declare %s and %s; a column holds one datatype", a.datatype, b.datatype) - case len(bare.fields) == 0 && bare.repeat == 1: + case bare.fields == nil: // a JSON string; an object, which may carry a weight, has a fields map return fmt.Errorf(`item %q declares no datatype, and a column holds one; write it as {"format":%q,"datatype":%q}`, bare.format, bare.format, typed.datatype) } return fmt.Errorf(`an item declares no datatype beside one declaring %s; a column holds one, so give it "datatype": %q`, typed.datatype, typed.datatype) diff --git a/node.go b/node.go index e73de11..cef1bc7 100644 --- a/node.go +++ b/node.go @@ -32,8 +32,7 @@ type choice struct { func (*choice) isNode() {} -// null is a record column's missing value, rendered as "". It is not zero-sized, so two -// nulls are two map keys. +// null is a column's missing value, rendered ""; sized so two nulls are two map keys. type null struct{ _ byte } func (*null) isNode() {} @@ -216,6 +215,9 @@ func checkNoRepeatedItem(items []any) error { if s, isString := raw.(string); isString { return fmt.Errorf("choice item %q is repeated; skew the odds with a weight instead: { \"format\": %q, \"weight\": 2 }", s, s) } + if raw == nil { + return fmt.Errorf("choice item %d repeats null; a null takes no weight, so weight the other items instead", i) + } return fmt.Errorf("choice item %d repeats item %d; skew the odds with a weight on one of them instead", i, j) } seen[string(key)] = i diff --git a/record.go b/record.go index 2658343..84bf8dd 100644 --- a/record.go +++ b/record.go @@ -1,12 +1,13 @@ package fejkdata import ( - "encoding/csv" "encoding/json" "errors" "fmt" "sort" "strings" + "unicode" + "unicode/utf8" ) // Column is one rendered column of a record. Value is the rendered text, which a @@ -73,15 +74,16 @@ func (r *Record) CSVLine() string { return strings.Join(fields, ",") } +// csvField quotes a field where encoding/csv would, and an empty one too. func csvField(s string) string { - if s == "" { + first, _ := utf8.DecodeRuneInString(s) + switch { + case s == "": return `""` + case s == `\.` || strings.ContainsAny(s, "\",\r\n") || unicode.IsSpace(first): + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } - var b strings.Builder - w := csv.NewWriter(&b) - _ = w.Write([]string{s}) - w.Flush() - return strings.TrimSuffix(b.String(), "\n") + return s } // SQLInsert renders the record as one INSERT statement into table, identifiers in ANSI @@ -104,9 +106,7 @@ func quoteIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } -// literal spells a column the way a serializer writes it: quoted for a string, bare for -// any other datatype, whose every render the load check proved a literal, and nullText -// for a null. +// literal writes a string column quoted, a proven typed one bare, and a null as nullText. func literal(c Column, quote func(string) string, nullText string) string { switch { case c.Null: diff --git a/template.go b/template.go index 1e5d3e6..45bfcda 100644 --- a/template.go +++ b/template.go @@ -72,9 +72,9 @@ type builtin struct { // operands names the fields the call reads, which expand renders for it; nil // for a builtin that reads none. operands func(args []string) []string - // number bounds the number a call prints and names the datatype its text is; nil - // for a builtin whose text is no number. - number func(args []string) (proven, DataType) + // number proves what a call prints, token its body: the bounds of its number and the + // datatype of its text; nil for a builtin whose text is no number. + number func(token string, args []string) proven } // funcCall splits a "{token}" body shaped name(args) into its parts; ok is false diff --git a/value.go b/value.go index 5d580b5..6d19dc7 100644 --- a/value.go +++ b/value.go @@ -11,16 +11,14 @@ import ( // proven is what a proof knows of every render of a node: bounds on the number each // reads as, and per datatype why some render's text is not one ("" when none). type proven struct { - lo, hi float64 - nonZero float64 // every value is at least this far from zero; 0 when one can be zero - integral bool - notNumber string // why some render reads as no finite number, the way calc reads it - not [len(dataTypeNames)]string + lo, hi float64 + nonZero float64 // every value is at least this far from zero; 0 when one can be zero + integral bool + notOperand string // why some render reads as no finite number, the way calc reads it + not [len(dataTypeNames)]string } -// valueProof proves what typed columns and their calc operands hold, each node once per -// scope. A typed column holds one value: a literal, one value builtin, one calc, or a -// read of such values. +// valueProof proves what typed columns and their calc operands hold, each node once per scope. type valueProof struct { memo map[node]proven } @@ -71,8 +69,8 @@ func (p *valueProof) unite(nodes []node) proven { w := p.of(n) v.lo, v.hi, v.nonZero = min(v.lo, w.lo), max(v.hi, w.hi), min(v.nonZero, w.nonZero) v.integral = v.integral && w.integral - if v.notNumber == "" { - v.notNumber = w.notNumber + if v.notOperand == "" { + v.notOperand = w.notOperand } for d := range v.not { if v.not[d] == "" { @@ -93,7 +91,7 @@ func (p *valueProof) template(t *template) proven { return literalValue(t.lit) case len(t.ops) != 1: v := unproven(notOneValue(t.format, "{int()}, {float()}, {seq()} or {calc()}")) - v.notNumber = notOneValue(t.format, "{int()}, {float()}, {seq()}, {digits()} or {calc()}") + v.notOperand = notOneValue(t.format, "{int()}, {float()}, {seq()}, {digits()} or {calc()}") return v } body := t.format[1 : len(t.format)-1] @@ -108,12 +106,11 @@ func (p *valueProof) template(t *template) proven { case name == "calc": return p.calc(t, body, args) case builtins[name].number != nil: - v, prints := builtins[name].number(args) - return printing(body, prints, v) + return builtins[name].number(body, args) case isTransform: return unproven(fmt.Sprintf("{%s} rewrites text rather than printing a value; write the values it would print", body)) } - return printing(body, DataTypeString, proven{notNumber: fmt.Sprintf("{%s} prints text, not a number", body)}) + return printing(body, DataTypeString, proven{notOperand: fmt.Sprintf("{%s} prints text, not a number", body)}) } func (p *valueProof) calc(t *template, body string, args []string) proven { @@ -128,8 +125,7 @@ func (p *valueProof) calc(t *template, body string, args []string) proven { if doubt != "" { return unproven(fmt.Sprintf("{%s}: %s", body, doubt)) } - v, prints := printedNumber(v, calcDecimals(args)) - return printing(body, prints, v) + return printedNumber(body, v, calcDecimals(args)) } // calcLimit is the largest magnitude a proof accepts as finite, far enough below @@ -144,8 +140,8 @@ func (p *valueProof) expr(n calcNode, fields map[string]node) (proven, string) { return bounded(v, v, v == math.Trunc(v)), "" case calcVar: v := p.of(fields[string(n)]) - if v.notNumber != "" { - return proven{}, fmt.Sprintf("operand %q: %s", string(n), v.notNumber) + 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: @@ -205,17 +201,21 @@ func bounded(lo, hi float64, integral bool) proven { func magnitude(v proven) float64 { return math.Max(math.Abs(v.lo), math.Abs(v.hi)) } -// printedNumber is v once strconv.FormatFloat prints it to dp decimals, and the datatype -// that text is: an integer when whole and within int64, else a number. -func printedNumber(v proven, dp int) (proven, DataType) { +// printedNumber is what a token printing v to dp decimals holds: an integer when whole +// and within int64, else a number. +func printedNumber(token string, v proven, dp int) proven { if dp >= 0 { - half := math.Pow(10, -float64(dp)) / 2 + half, _ := strconv.ParseFloat("5e-"+strconv.Itoa(dp+1), 64) // math.Pow(10, -dp) can land below the tie and let a printed zero through v = proven{lo: v.lo - half, hi: v.hi + half, nonZero: math.Max(0, v.nonZero-half), integral: v.integral || dp == 0} } - if (dp == 0 || dp < 0 && v.integral) && magnitude(v) < math.MaxInt64 { - return v, DataTypeInteger + if dp != 0 && !(dp < 0 && v.integral) { + return printing(token, DataTypeNumber, v) } - return v, DataTypeNumber + v = printing(token, DataTypeInteger, v) + if !(magnitude(v) < math.MaxInt64) { + v.not[DataTypeInteger] = fmt.Sprintf("{%s} is not proven within int64", token) + } + return v } // printing is v for a token whose every render is text of datatype prints, with a reason @@ -235,7 +235,7 @@ func notOneValue(format, calls string) string { // unproven is a render no datatype and no calc can take, for why. func unproven(why string) proven { - v := proven{notNumber: why} + v := proven{notOperand: why} for d := DataTypeInteger; d <= DataTypeBoolean; d++ { v.not[d] = why } @@ -251,7 +251,7 @@ var ( func literalValue(text string) proven { var v proven if f, err := strconv.ParseFloat(strings.TrimSpace(text), 64); err != nil || math.IsNaN(f) || math.IsInf(f, 0) { - v.notNumber = fmt.Sprintf("%q is not a number", text) + v.notOperand = fmt.Sprintf("%q is not a number", text) } else { v = bounded(f, f, f == math.Trunc(f)) } @@ -260,11 +260,24 @@ func literalValue(text string) proven { } else if err != nil { v.not[DataTypeInteger] = fmt.Sprintf("%q is past the int64 range", text) } - if v.notNumber != "" || !numberText.MatchString(text) { + if v.notOperand != "" || !numberText.MatchString(text) { v.not[DataTypeNumber] = fmt.Sprintf("%q is not a number", text) } if text != "true" && text != "false" { v.not[DataTypeBoolean] = fmt.Sprintf("%q is not a boolean", text) } + return signedZero(text, v) +} + +// signedZero refuses a zero written with a sign as a typed value, naming it unsigned. +func signedZero(text string, v proven) proven { + if !strings.HasPrefix(text, "-") || v.notOperand != "" || v.lo != 0 { + return v + } + for _, d := range []DataType{DataTypeInteger, DataTypeNumber} { + if v.not[d] == "" { + v.not[d] = fmt.Sprintf("%q is zero written with a sign; write %q", text, text[1:]) + } + } return v }