diff --git a/change_log.md b/change_log.md index 2bcf8ba3..0f2bd9e8 100644 --- a/change_log.md +++ b/change_log.md @@ -8,6 +8,7 @@ * Dogfood round 2: `apps/notes_cli`, `apps/auth_api`, `apps/grep_cli` built on standalone bytecode; `store_open` accepts a runtime URI expression; AST density sugar `(switch …)` and `(inc x)`. Real-app token harness extended with Python/Go/Node ports and parity. Journal: `docs/journals/2026-10-10_dogfood_apps_round2.md`. ### Fixed +* #151: float and mixed int/float `%` and `//` follow Python exactly. `%` is `math.fmod` floored to the divisor's sign (computed exactly by an existing-construct doubling/subtraction loop, so `(% MaxInt64 1e33)` is `9.223372036854776e+18`, `(% 1 0.1)` is `0.09999999999999995`, `(% 4.0 -2.0)` is `-0`), and `//` is CPython's float floor division returned as an int (`(// MaxInt64 1e33)` is `0`). Integral floats that print like ints (`189944.0`) paired with a big int no longer run the int core in float arithmetic. Inf/NaN operands fail with `VALUE_ERROR: % and // need finite operands`, and a `//` result outside int64 with `VALUE_ERROR: // result outside int64`. Mixed int/float comparisons no longer treat the float 2^63 as MinInt64 or round the int through float64. A non-numeric operand now reports `% requires numeric operands (int or float)` at the form instead of `< compares incompatible types`, and template VALUE_ERRORs (destructuring included) carry `at line L, column C`. Instruction cost (-run-bc): a 20k-iteration loop with one `%` and one `//` takes 4.30M instructions (3.17M after #149, 1.55M before #149), so about 460 iterations fit the default 100k limit; `to_base` of MinInt64 in base 2 takes about 16.2k (13.4k after #149); the exact float path loops about twice the binary-exponent gap (worst case `1e308 % 5e-324`, about 53k). Still pure sugar: no opcode or capability; Go/JS still reject with `UNSUPPORTED_ON_TARGET`. * #149: integer `%`, `//` and `to_base` use exact int64 arithmetic over the whole range with Python floor semantics (previously one float division, wrong above 2^53; `(// (2^53-1) -3)` was already off by one). `(% MaxInt64 10)` is 7, `(// 123456789012345678 1000)` is 123456789012345, `MinInt64 // -1` is the existing `integer overflow` (not `CONVERSION_ERROR`), and `to_base` handles MinInt64. Operands inside (-2^52, 2^52) and float operands keep the old expansion. `let` destructuring of a list of the wrong length fails with `VALUE_ERROR: destructuring expects N elements`. Still pure sugar: no opcode or capability; Go/JS still reject with `UNSUPPORTED_ON_TARGET`. * HOWL-E2E-UX-001 UX-003: browser JavaScript fetch accepts explicit host grants without reading Node process.env. Missing grants still deny before requests; Node environment authority and exit behavior are preserved. Browser bootstrap errors preserve the original failure. * S8/P2.1 filesystem portion: native VM and interpreter enforce repeatable `filesystem:read=` and `filesystem:write=` grants, with symlink containment checks. File stores require read coverage and write coverage for mutations. Coarse `filesystem` remains unrestricted; process/network scoping and generated backends remain open. Journal: `docs/journals/2026-10-06_path_scoped_filesystem.md`. diff --git a/internal/ast/desugar_controlflow.go b/internal/ast/desugar_controlflow.go index b9c2486d..2d844d8c 100644 --- a/internal/ast/desugar_controlflow.go +++ b/internal/ast/desugar_controlflow.go @@ -1,6 +1,9 @@ package ast -import "strings" +import ( + "strings" + "sync" +) // Control-flow and core built-ins (heldout-v2 follow-up). Like the // heldout-v1 core library, every form expands into existing, already lowered @@ -15,8 +18,18 @@ import "strings" // Int operands use exact int64 arithmetic over the // whole range (see cfIntCore); MinInt64 // -1 is the // existing RUNTIME_ERROR "integer overflow". Float -// operands keep the float formula. b = 0 is the -// existing RUNTIME_ERROR "division by zero". +// (or mixed) operands follow Python exactly (#151): +// % is fmod floored to the divisor's sign, // is +// CPython's float floor division returned as an int. +// Inf/NaN operands and // results outside int64 are +// "VALUE_ERROR: ..." (exit 1). b = 0 is the existing +// RUNTIME_ERROR "division by zero". +// Instruction cost (-run-bc): a 20k-iteration loop with +// one % and one // is 4.30M instructions (1.55M before +// #149, 3.17M after); to_base of MinInt64 in base 2 is +// about 16.2k instructions; +// the exact float path loops about twice the binary +// exponent gap (1e308 % 5e-324 is the worst case). // (if c1 a c2 b ... [else]) n-ary if: elif chains without cond clauses. // (break) leaves the innermost for/while. Statement position // only (directly or via do/if/when/unless/let bodies @@ -38,19 +51,61 @@ import "strings" // A list of another length fails with // "VALUE_ERROR: destructuring expects N elements", exit 1. -// cfIsFloat is true when either operand of % or // is not an integer: its -// printed form contains ".", "e" (exponent), "n" (Inf) or "N" (NaN), none of -// which an int64 ever prints. Such operands keep the float formula. -// Integral floats such as 6.0 print like ints and take the exact path; the -// result value is the same (and prints the same) as the float formula's. -const cfIsFloat = "(or (str_contains (str $A) $DOT) (str_contains (str $B) $DOT) (str_contains (str $A) $EXP) (str_contains (str $B) $EXP) (str_contains (str $A) $INF) (str_contains (str $B) $INF) (str_contains (str $A) $NAN) (str_contains (str $B) $NAN))" +// cfFloaty(x) is true when x is a float (#151). Below 2^52 in magnitude, +// x + 2^62 is exact for an int but a float >= 2^52 for a float, and such +// floats always print with an exponent; this also catches integral floats +// that print like ints (189944.0), which the int core would otherwise run in +// float arithmetic. Larger floats print with "e", "Inf" or "NaN" (NaN fails +// both range tests), none of which an int64 ever prints. +func cfFloaty(x string) string { + return "(if (and (< " + x + " $LIM) (> " + x + " $NLIM)) (str_contains (str (+ " + x + " $P62)) $EXP) (or (str_contains (str " + x + ") $EXP) (str_contains (str " + x + ") $INF) (str_contains (str " + x + ") $NAN)))" +} + +var cfIsFloatX = "(or " + cfFloaty("$A") + " " + cfFloaty("$B") + ")" // cfSmall is true when both operands are strictly inside (-2^52, 2^52). There // the float formula is exact: a correctly rounded a/b cannot cross an integer // boundary, and // divides a - r with |a - r| < 2^53 (with the old 2^53 bound // (// (2^53-1) -3) was off by one). % and // keep the cheaper pre-#149 // expansion there, so the instruction cost of ordinary loops is unchanged. -const cfSmall = "(and (< $A $LIM) (> $A $NLIM) (< $B $LIM) (> $B $NLIM))" +// +// Since #151 the cheap path also requires both operands to be whole numbers +// (ints, or integral floats such as 6.0); any other float takes the exact +// float path below. A zero remainder takes the sign of the divisor as in +// Python ((% 4.0 -2.0) is -0.0, (% -0.0 5.0) is 0.0); for ints it is 0. +const cfSmall = "(and (< $A $LIM) (> $A $NLIM) (< $B $LIM) (> $B $NLIM) (= $A (to_int $A)) (= $B (to_int $B)))" + +// cfCheapMod is the pre-#149 remainder formula, exact inside cfSmall. +const cfCheapMod = "(let ($R (- $A (* $B (to_int (/ $A $B))))) (if (and (!= $R 0) (!= (< $R 0) (< $B 0))) (+ $R $B) (if (= $R 0) (if (< $B 0) (* (- 0 1) (* $R $R)) (* $R $R)) $R)))" + +// cfFloatCore (#151) computes R = fmod(A, B) exactly, with the sign of A, +// for float operands (or a mix of int and float, converted to float first +// like Python). Inf or NaN operands fail with "VALUE_ERROR: % and // need +// finite operands"; B = 0 is the existing "division by zero". It uses only +// existing constructs: |B| is doubled into a list of |B|*2^k (exact) while it +// fits under |A|, then subtracted back from the largest down. Each +// subtraction is exact (Sterbenz), so the result is the correctly rounded +// fmod for any magnitudes (1e308 % 5e-324 included). The loop runs about +// twice the binary-exponent gap between A and B. extra runs after the guard; +// tail sees R (fmod), N (float A), DN (float B), AD (|B|). The caller closes +// four parens. +func cfFloatCore(extra string) string { + return "(let ($S (if (= $B 0) (/ $A $B) (/ 0 1)) $ZF (+ (* 0 $A) (* 0 $B))) (let ($N (+ $A $ZF) $DN (+ $B $ZF)) (let ($R (if (< $N 0) (- 0 $N) $N) $AD (if (< $DN 0) (- 0 $DN) $DN) $P (list) $Q $ZF $I 0) (do " + + "(when (or (str_contains (str $A) $INFS) (str_contains (str $A) $NANS) (str_contains (str $B) $INFS) (str_contains (str $B) $NANS)) (do (stderr $FMSG) (exit 1))) " + extra + + " (set $Q $AD) (while (<= $Q $R) (do (append $P $Q) (set $Q (* $Q 2)))) (set $I (- (list_len $P) 1)) (while (>= $I 0) (do (when (>= $R (list_get $P $I)) (set $R (- $R (list_get $P $I)))) (set $I (- $I 1)))) (when (< $N 0) (set $R (- 0 $R))) " +} + +// cfFloatMod floors the exact fmod like Python: a non-zero remainder whose +// sign differs from B gets B added; a zero remainder takes B's sign. +var cfFloatMod = cfFloatCore("") + "(if (= $R 0) (if (< $DN 0) (* (- 0 1) (* $R $R)) (* $R $R)) (if (!= (< $R 0) (< $DN 0)) (+ $R $DN) $R))))))" + +// cfFloatDiv follows CPython's float floor division ((A - fmod)/B, minus one +// when the remainder's sign differs from B, then floor with a round-to-nearest +// fix-up) and returns it as an int like the rest of //. A quotient outside +// int64 fails with "VALUE_ERROR: // result outside int64" (checked before the +// loop when |A| > |B|*2^63, so huge quotients stay cheap). +var cfFloatDiv = cfFloatCore("(when (> $R (* $AD $TWO63)) (do (stderr $RMSG) (exit 1)))") + + "(let ($T (/ (- $N $R) $DN)) (do (when (and (!= $R 0) (!= (< $DN 0) (< $R 0))) (set $T (- $T 1))) (when (or (>= $T $TWO63) (< $T $NTWO63)) (do (stderr $RMSG) (exit 1))) (let ($I (to_int $T)) (do (when (> $I $T) (set $I (- $I 1))) (when (> (- $T $I) $HALF) (set $I (+ $I 1))) $I))))))))" // cfIntCore computes, in exact int64 arithmetic, Q = |trunc(A/B)| and // R = -(|A| mod |B|) (so DN < R <= 0) for |B| >= 2, B = 0 failing with the @@ -75,10 +130,10 @@ var controlflowTemplates = map[string]struct { tpl string binder int }{ - "%": {2, []string{"a", "b"}, []string{"A", "B", "R", "N", "DN", "Q", "T"}, - "(let ($A $a $B $b) (if " + cfSmall + " (let ($R (- $A (* $B (to_int (/ $A $B))))) (if (and (!= $R 0) (!= (< $R 0) (< $B 0))) (+ $R $B) $R)) (if " + cfIsFloat + " (let ($R (- $A (* $B (to_int (/ $A $B))))) (if (and (!= $R 0) (!= (< $R 0) (< $B 0))) (+ $R $B) $R)) (if (or (= $B 1) (= $B (- 0 1))) " + cfW("0") + " " + cfIntCore + " (let ($T (if (< $A 0) $R (- 0 $R))) (if (and (!= $T 0) (!= (< $T 0) (< $B 0))) " + cfW("(+ $T $B)") + " " + cfW("$T") + "))))))))))", -1}, - "//": {2, []string{"a", "b"}, []string{"A", "B", "R", "N", "DN", "Q", "T"}, - "(let ($A $a $B $b) (if (or " + cfSmall + " " + cfIsFloat + ") (to_int (/ (- $A (% $A $B)) $B)) (if (= $B 1) (to_int $A) (if (= $B (- 0 1)) (to_int (- 0 $A)) " + cfIntCore + " (if (= (< $A 0) (< $B 0)) $Q (if (= $R 0) (- 0 $Q) (- 0 (+ $Q 1))))))))))))", -1}, + "%": {2, []string{"a", "b"}, []string{"A", "B", "R", "N", "DN", "Q", "T", "S", "ZF", "AD", "P", "I"}, + "(let ($A $a $B $b) (if " + cfSmall + " " + cfCheapMod + " (if " + cfIsFloatX + " " + cfFloatMod + " (if (or (= $B 1) (= $B (- 0 1))) " + cfW("0") + " " + cfIntCore + " (let ($T (if (< $A 0) $R (- 0 $R))) (if (and (!= $T 0) (!= (< $T 0) (< $B 0))) " + cfW("(+ $T $B)") + " " + cfW("$T") + "))))))))))", -1}, + "//": {2, []string{"a", "b"}, []string{"A", "B", "R", "N", "DN", "Q", "T", "S", "ZF", "AD", "P", "I"}, + "(let ($A $a $B $b) (if " + cfSmall + " (to_int (/ (- $A (% $A $B)) $B)) (if " + cfIsFloatX + " " + cfFloatDiv + " (if (= $B 1) (to_int $A) (if (= $B (- 0 1)) (to_int (- 0 $A)) " + cfIntCore + " (if (= (< $A 0) (< $B 0)) $Q (if (= $R 0) (- 0 $Q) (- 0 (+ $Q 1)))))))))))))", -1}, "range": {2, []string{"a", "b"}, []string{"A", "B", "R"}, "(let ($A $a $B $b $R (list)) (do (while (< $A $B) (do (append $R $A) (set $A (+ $A 1)))) $R))", -1}, "max_by": {3, []string{"x", "lst", "key"}, []string{"L", "KS", "I", "M", "MK", "K"}, @@ -150,7 +205,25 @@ func desugarControlflow(node *Node) bool { "NLIM": func() *Node { return &Node{Type: "INT", Value: "-4503599627370496", Line: node.Line, Column: node.Column, Filename: node.Filename} }, - "EXP": func() *Node { return strNode("e", node) }, + "EXP": func() *Node { return strNode("e", node) }, + "INFS": func() *Node { return strNode("Inf", node) }, + "NANS": func() *Node { return strNode("NaN", node) }, + "FMSG": func() *Node { + return strNode("VALUE_ERROR: % and // need finite operands"+atPos(node)+"\n", node) + }, + "RMSG": func() *Node { return strNode("VALUE_ERROR: // result outside int64"+atPos(node)+"\n", node) }, + "TWO63": func() *Node { + return &Node{Type: "FLOAT", Value: "9223372036854775808.0", Line: node.Line, Column: node.Column, Filename: node.Filename} + }, + "P62": func() *Node { + return &Node{Type: "INT", Value: "4611686018427387904", Line: node.Line, Column: node.Column, Filename: node.Filename} + }, + "HALF": func() *Node { + return &Node{Type: "FLOAT", Value: "0.5", Line: node.Line, Column: node.Column, Filename: node.Filename} + }, + "NTWO63": func() *Node { + return &Node{Type: "FLOAT", Value: "-9223372036854775808.0", Line: node.Line, Column: node.Column, Filename: node.Filename} + }, "INF": func() *Node { return strNode("n", node) }, "NAN": func() *Node { return strNode("N", node) }, "D": func() *Node { return strNode("0123456789abcdefghijklmnopqrstuvwxyz", node) }, @@ -179,9 +252,64 @@ func desugarControlflow(node *Node) bool { args[tmp] = symNode(compactTemp("cl"+tmp), node) } node.Children = buildTemplateFresh(tpl, args, nil, node).Children + if head == "%" || head == "//" { + markNumericSugar(node, head, args["a"], args["b"]) + } return true } +// atPos renders " at line L, column C" for VALUE_ERROR messages emitted by +// templates (they go to stderr as plain text, not the JSON error object). +func atPos(at *Node) string { + if at == nil || at.Line <= 0 { + return "" + } + return " at line " + itoa(at.Line) + ", column " + itoa(at.Column) +} + +var numericSugar = struct { + sync.Mutex + m map[*Node]NumericSugarSite +}{m: map[*Node]NumericSugarSite{}} + +// NumericSugarSite names the % or // form a generated node came from. +type NumericSugarSite struct { + Head string + Root *Node +} + +// markNumericSugar records every node the % or // template generated (not +// the user's operand subtrees), so the checker can report a non-numeric +// operand as "% requires numeric operands" at the form instead of an error +// about an internal comparison. +func markNumericSugar(root *Node, head string, a, b *Node) { + numericSugar.Lock() + defer numericSugar.Unlock() + site := NumericSugarSite{Head: head, Root: root} + if outer, ok := numericSugar.m[root]; ok { + site = outer // (% A B) inside the // expansion reports as // + } + var walk func(n *Node) + walk = func(n *Node) { + if n == nil || n == a || n == b { + return + } + numericSugar.m[n] = site + for _, c := range n.Children { + walk(c) + } + } + walk(root) +} + +// NumericSugarOrigin reports the % or // form that generated n, if any. +func NumericSugarOrigin(n *Node) (NumericSugarSite, bool) { + numericSugar.Lock() + defer numericSugar.Unlock() + s, ok := numericSugar.m[n] + return s, ok +} + // desugarNaryIf turns (if c1 a c2 b ... [else]) with more than three // arguments into nested binary ifs. Returns true when it rewrote the node. func desugarNaryIf(node *Node) bool { @@ -239,7 +367,7 @@ func desugarDestructure(node *Node) { check := mk(symNode("do", at), mk(symNode("when", at), mk(symNode("!=", at), mk(symNode("list_len", at), symNode(tmp, at)), intN(n)), mk(symNode("do", at), - mk(symNode("stderr", at), strNode("VALUE_ERROR: destructuring expects "+itoa(n)+" elements\n", at)), + mk(symNode("stderr", at), strNode("VALUE_ERROR: destructuring expects "+itoa(n)+" elements"+atPos(at)+"\n", at)), mk(symNode("exit", at), intN(1)))), intN(0)) out = append(out, symNode(compactTemp("clDSN"), node), check) diff --git a/internal/checker/types.go b/internal/checker/types.go index c5e58730..a7d883e7 100644 --- a/internal/checker/types.go +++ b/internal/checker/types.go @@ -63,6 +63,8 @@ type Analysis struct { // jsTarget is true for web_app programs, whose generated JavaScript // evaluates a parse_json body expression directly. jsTarget bool + // sugarReported dedupes operand-type diagnostics per % or // form. + sugarReported map[*ast.Node]bool } type typeEnv map[string]ast.TypeInfo @@ -1031,6 +1033,18 @@ func (a *Analysis) inferChild(node *ast.Node, index int, env typeEnv) ast.TypeIn } func (a *Analysis) add(node *ast.Node, reason string) { + // A diagnostic inside a % or // expansion can only come from an operand + // of the wrong type: report it once, at the form, in the user's terms. + if site, ok := ast.NumericSugarOrigin(node); ok { + if a.sugarReported == nil { + a.sugarReported = map[*ast.Node]bool{} + } + if a.sugarReported[site.Root] { + return + } + a.sugarReported[site.Root] = true + node, reason = site.Root, site.Head+" requires numeric operands (int or float)" + } a.Diagnostics = append(a.Diagnostics, Diagnostic{Reason: reason, Line: node.Line, Column: node.Column}) } diff --git a/internal/construct/construct.go b/internal/construct/construct.go index e822a561..a1d18d11 100644 --- a/internal/construct/construct.go +++ b/internal/construct/construct.go @@ -80,12 +80,12 @@ type Entry struct { // a copy on every call. construct_test.go enforces the ordering. var table = []Entry{ {Name: "!=", Support: Supported, Note: "compileNode binop case"}, - {Name: "%", Support: CompileTimeOnly, Note: "control-flow sugar; (% a b) floored remainder via to_int(/)/*/- by ast.Desugar (pure)"}, + {Name: "%", Support: CompileTimeOnly, Note: "control-flow sugar; (% a b) Python-floored remainder by ast.Desugar (pure): to_int(/)/*/- for whole operands below 2^52, exact int64 core otherwise, exact float fmod loop for floats; Inf/NaN operands are VALUE_ERROR"}, {Name: "*", Support: Supported, Note: "compileNode binop case"}, {Name: "+", Support: Supported, Note: "compileNode binop case"}, {Name: "-", Support: Supported, Note: "compileNode binop case"}, {Name: "/", Support: Supported, Note: "compileNode binop case"}, - {Name: "//", Support: CompileTimeOnly, Note: "control-flow sugar; (// a b) floored integer division via % and / by ast.Desugar (pure)"}, + {Name: "//", Support: CompileTimeOnly, Note: "control-flow sugar; (// a b) floored division returning int by ast.Desugar (pure): via % and / for whole operands below 2^52, exact int64 core otherwise, CPython float floor division for floats; Inf/NaN operands and results outside int64 are VALUE_ERROR"}, {Name: "<", Support: Supported, Note: "compileNode binop case"}, {Name: "<=", Support: Supported, Note: "compileNode binop case"}, {Name: "=", Support: Supported, Note: "compileNode binop case, normalized to =="}, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 97c6b66e..51fefa90 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -1322,6 +1322,16 @@ func compareNumeric(a, b any) (int, bool) { return 0, false } // Mixed int/float: keep precision by comparing as integers when possible. + // A float outside the int64 range or with a fraction never equals an + // int64; order it without rounding the int through float64 (2^63 used to + // compare as MinInt64 and MaxInt64 as equal to 2^63). NaN keeps the + // float comparison below. + if aInt && bFloat && !math.IsNaN(bf) && !floatIsWholeInt64(bf) { + return cmpIntNonWholeFloat(ai, bf), true + } + if aFloat && bInt && !math.IsNaN(af) && !floatIsWholeInt64(af) { + return -cmpIntNonWholeFloat(bi, af), true + } if aInt && bFloat && floatIsWholeInt64(bf) { bi2 := int64(bf) switch { @@ -1351,8 +1361,23 @@ func compareNumeric(a, b any) (int, bool) { return 0, true } +// cmpIntNonWholeFloat orders int64 i against a non-NaN float f that is not +// a whole number inside the int64 range. +func cmpIntNonWholeFloat(i int64, f float64) int { + if f >= 9223372036854775808.0 { + return -1 + } + if f < math.MinInt64 { + return 1 + } + if i <= int64(math.Floor(f)) { + return -1 + } + return 1 +} + func floatIsWholeInt64(f float64) bool { - if math.IsNaN(f) || math.IsInf(f, 0) || f < math.MinInt64 || f > math.MaxInt64 { + if math.IsNaN(f) || math.IsInf(f, 0) || f < math.MinInt64 || f >= 9223372036854775808.0 { return false } return math.Trunc(f) == f diff --git a/issue151_float_math_cli_test.go b/issue151_float_math_cli_test.go new file mode 100644 index 00000000..6feffe21 --- /dev/null +++ b/issue151_float_math_cli_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "math" + "os/exec" + "strconv" + "strings" + "testing" +) + +// Issue #151: float (and mixed int/float) % and // follow Python: % is +// math.fmod floored so the result takes the divisor's sign (a zero result +// takes the divisor's sign too), // is CPython's float floor division +// returned as an int. Inf/NaN operands and // results outside int64 are a +// VALUE_ERROR. Expected values were generated with Python 3 (a % b, a // b). +var issue151Cases = [][4]string{ + {"9223372036854775807", "189944.0", "33112.0", "48558375294059"}, + {"0.0", "-9223372036854775808", "-0.0", "0"}, + {"-9223372036854775808", "-95093.0", "-86339.0", "96993175489833"}, + {"9223372036854775807", "1e+33", "9.223372036854776e+18", "0"}, + {"9223372036854775807", "-1e+33", "-9.999999999999907e+32", "-1"}, + {"-9223372036854775807", "1e+33", "9.999999999999907e+32", "-1"}, + {"-9223372036854775808", "1e+33", "9.999999999999907e+32", "-1"}, + {"-9223372036854775808", "-1e+33", "-9.223372036854776e+18", "0"}, + {"1e+308", "3.0", "2.0", ""}, + {"-1e+308", "3.0", "1.0", ""}, + {"1e+308", "-7.5", "-4.0", ""}, + {"1e+308", "1e-308", "3.498445546245627e-309", ""}, + {"1e+308", "5e-324", "0.0", ""}, + {"5e-324", "1e+308", "5e-324", "0"}, + {"-5e-324", "1e+308", "1e+308", "-1"}, + {"1e+308", "1e+308", "0.0", "1"}, + {"-1e+308", "1e+308", "0.0", "-1"}, + {"1", "0.1", "0.09999999999999995", "9"}, + {"-1", "0.1", "5.551115123125783e-17", "-10"}, + {"1", "-0.1", "-5.551115123125783e-17", "-10"}, + {"0.3", "0.1", "0.09999999999999998", "2"}, + {"7.5", "2", "1.5", "3"}, + {"-7.5", "2", "0.5", "-4"}, + {"7.5", "-2", "-0.5", "-4"}, + {"7", "2.5", "2.0", "2"}, + {"-7", "2.5", "0.5", "-3"}, + {"9223372036854775807", "2.5", "0.5", "3689348814741910528"}, + {"-9223372036854775808", "0.5", "0.0", ""}, + {"9007199254740992.0", "3", "2.0", "3002399751580330"}, + {"9007199254740994.0", "-3", "-2.0", "-3002399751580332"}, + {"-0.0", "5.0", "0.0", "0"}, + {"-0.0", "-5.0", "-0.0", "0"}, + {"0.0", "-5.0", "-0.0", "0"}, + {"4.0", "-2.0", "-0.0", "-2"}, + {"-4.0", "2.0", "0.0", "-2"}, + {"6.0", "4", "2.0", "1"}, + {"9.0", "-2", "-1.0", "-5"}, + {"9223372036854775807", "9.223372036854776e+18", "0.0", "1"}, + {"-9223372036854775808", "-9.223372036854776e+18", "-0.0", "1"}, + {"1e+33", "9223372036854775807", "4.0352252661239644e+18", "108420217248550"}, + {"1.2345678901234568e+17", "1000", "680.0", "123456789012345"}, +} + +// issue151Lit renders a case operand as a HowlFrame literal: ints as is, +// floats as their shortest round-tripping decimal (no exponent syntax). +func issue151Lit(s string) string { + if !strings.ContainsAny(s, ".e") { + return s + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + panic(err) + } + if f == 0 { + if math.Signbit(f) { + return "-0.0" + } + return "0.0" + } + out := strconv.FormatFloat(f, 'f', -1, 64) + if !strings.Contains(out, ".") { + out += ".0" + } + return out +} + +func issue151Run(t *testing.T, bin string, args []string) (string, error) { + t.Helper() + if args[0] == "-run-bc" { + args = append([]string{"-max-instructions", "2000000"}, args...) + } + out, err := exec.Command(bin, args...).CombinedOutput() + return string(out), err +} + +// Boundaries 1e308, 5e-324, +-2^63, MaxInt64/MinInt64 mixed with floats, +// -0.0 and non-integral divisors, identical on the interpreter, governed AST +// bytecode and HFIR bytecode. +func TestIssue151FloatModDivOnEveryPath(t *testing.T) { + bin := c1c2Binary(t) + var src strings.Builder + var wantMod []float64 + var wantDiv []string + src.WriteString("(do") + for _, c := range issue151Cases { + src.WriteString(" (print (% " + issue151Lit(c[0]) + " " + issue151Lit(c[1]) + "))") + w, _ := strconv.ParseFloat(c[2], 64) + wantMod = append(wantMod, w) + } + for _, c := range issue151Cases { + if c[3] != "" { + src.WriteString(" (print (// " + issue151Lit(c[0]) + " " + issue151Lit(c[1]) + "))") + wantDiv = append(wantDiv, c[3]) + } + } + src.WriteString(")") + for _, args := range issue149Paths(t, bin, src.String()) { + out, err := issue151Run(t, bin, args) + if err != nil { + t.Fatalf("%v: %v %s", args[0], err, out) + } + lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") + if len(lines) != len(wantMod)+len(wantDiv) { + t.Fatalf("%v: %d lines, want %d: %q", args, len(lines), len(wantMod)+len(wantDiv), out) + } + for i, w := range wantMod { + g, err := strconv.ParseFloat(lines[i], 64) + if err != nil || g != w || math.Signbit(g) != math.Signbit(w) { + t.Errorf("%v (%% %s %s) = %q, want %v", args, issue151Cases[i][0], issue151Cases[i][1], lines[i], w) + } + } + for i, w := range wantDiv { + if lines[len(wantMod)+i] != w { + t.Errorf("%v // #%d = %q, want %s", args, i, lines[len(wantMod)+i], w) + } + } + } +} + +func TestIssue151ErrorsOnEveryPath(t *testing.T) { + bin := c1c2Binary(t) + inf := "(* 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000.0 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000.0)" + nan := "(let (i " + inf + ") (- i i))" + cases := map[string]string{ + "(do (print (% " + inf + " 2.0)))": "VALUE_ERROR: % and // need finite operands", + "(do (print (% 2.0 " + inf + ")))": "VALUE_ERROR: % and // need finite operands", + "(do (print (% -2 (- 0.0 " + inf + "))))": "VALUE_ERROR: % and // need finite operands", + "(do (print (// " + inf + " 3)))": "VALUE_ERROR: % and // need finite operands", + "(do (print (% " + nan + " 2.0)))": "VALUE_ERROR: % and // need finite operands", + "(do (print (// 1.5 " + nan + ")))": "VALUE_ERROR: % and // need finite operands", + "(do (print (% 7.5 0.0)))": "division by zero", + "(do (print (// 7.5 -0.0)))": "division by zero", + "(do (print (% 9223372036854775807 0.0)))": "division by zero", + "(do (print (// -9223372036854775808 0.5)))": "VALUE_ERROR: // result outside int64", + "(do (print (// 9223372036854775807 1.0)))": "VALUE_ERROR: // result outside int64", + "(do (print (// 1000000000000000000000.0 -3)))": "VALUE_ERROR: // result outside int64", + "(do\n (print (// 1.5 " + nan + ")))": "at line 2, column 10", + "(do\n (let ((a b) (list 1 2 3)) (print a b)))": "VALUE_ERROR: destructuring expects 2 elements at line 2, column 8", + } + for src, want := range cases { + for _, args := range issue149Paths(t, bin, src) { + out, err := issue151Run(t, bin, args) + if err == nil || !strings.Contains(out, want) { + t.Errorf("%s %v: err=%v out=%q want %q", src, args[0], err, out, want) + } + } + } +} + +// A non-numeric operand is reported at the % or // form in the user's terms, +// not as an error about a comparison inside the expansion. +func TestIssue151NonNumericOperandMessage(t *testing.T) { + bin := c1c2Binary(t) + for _, c := range [][2]string{ + {`(do (print (% "a" 2)))`, "% requires numeric operands (int or float)"}, + {`(do (print (// 1 "x")))`, "// requires numeric operands (int or float)"}, + {`(do (print (% true 2.5)))`, "% requires numeric operands (int or float)"}, + {`(do (print (// (list 1) 3)))`, "// requires numeric operands (int or float)"}, + } { + dir := t.TempDir() + p := writeTemp(t, dir, "p.howl", c[0]) + out, err := exec.Command(bin, "-run", p).CombinedOutput() + if err == nil || !strings.Contains(string(out), c[1]) || strings.Contains(string(out), "compares incompatible") { + t.Errorf("%s: err=%v out=%s", c[0], err, out) + } + } +} + +// Mixed int/float comparisons order values outside the int64 range and +// fractional floats without rounding the int through float64 (2^63 used to +// compare as MinInt64, which made (% MaxInt64 1e33) add the divisor). +func TestIssue151MixedCompareOnEveryPath(t *testing.T) { + bin := c1c2Binary(t) + src := `(do (print (< 9223372036854775807 9223372036854775808.0) (> 9223372036854775808.0 0) (< -9223372036854775808 -9223372036854777856.0) (= 9223372036854775807 9223372036854775808.0) (< 3 3.5) (> 4 3.5) (< -4 -3.5) (> -3 -3.5) (<= 3 3.0) (< (- 9223372036854775807 0.0) 0)))` + want := "true true false false true true true true true false\n" + for _, args := range issue149Paths(t, bin, src) { + out, err := issue151Run(t, bin, args) + if err != nil || out != want { + t.Errorf("%v: err=%v out=%q want %q", args[0], err, out, want) + } + } +} + +// Go and JS still reject % and // with UNSUPPORTED_ON_TARGET. +func TestIssue151RejectedOnGoJS(t *testing.T) { + bin := c1c2Binary(t) + for _, f := range []string{`(print (% 9223372036854775807 1.5))`, `(print (// -0.0 2.5))`, `(print (% 1.0 0.1))`} { + for _, target := range []string{"go", "js"} { + dir := t.TempDir() + p := writeTemp(t, dir, "g.howl", "(do "+f+")") + out, err := exec.Command(bin, "build", "--target", target, p, "-o", dir+"/out").CombinedOutput() + if err == nil || !strings.Contains(string(out), "UNSUPPORTED_ON_TARGET") { + t.Errorf("%s on %s: err=%v out=%s", f, target, err, out) + } + } + } +}