From 6cc3b2354fb89b1438fa09b1db79d76931b309e2 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 12:37:18 +0200 Subject: [PATCH 01/31] =?UTF-8?q?feat:=20replace=20numeric=20subtyping=20w?= =?UTF-8?q?ith=20explicit=20modes=20=F0=9F=94=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 11 - Cargo.toml | 1 - manual/src/features/augmented-assignment.md | 2 +- manual/src/reference/types.md | 8 +- manual/src/reference/types/number.md | 174 +- manual/src/reference/types/struct.md | 5 +- manual/src/reference/variables-and-scopes.md | 10 +- ndc_analyser/src/analyser.rs | 23 +- ndc_analyser/src/scope.rs | 5 +- ndc_bin/src/highlighter.rs | 2 + ndc_core/Cargo.toml | 1 - ndc_core/src/num.rs | 518 ++--- ndc_core/src/static_type.rs | 49 +- ndc_lexer/src/number.rs | 80 +- ndc_lexer/src/token.rs | 9 + ndc_lsp/src/scope_resolve.rs | 2 + ndc_lsp/src/visitor.rs | 4 + ndc_macros/src/function.rs | 2 +- ndc_macros/src/types.rs | 6 +- ndc_macros/src/vm_convert.rs | 41 +- ndc_parser/src/expression.rs | 2 + ndc_parser/src/parser.rs | 2 + ndc_stdlib/src/index.rs | 11 +- ndc_stdlib/src/lib.rs | 1 - ndc_stdlib/src/math.rs | 1821 +++++++++++------ ndc_stdlib/src/rand.rs | 10 +- ndc_stdlib/src/serde.rs | 33 +- ndc_vm/Cargo.toml | 1 - ndc_vm/src/compiler.rs | 14 + ndc_vm/src/value/mod.rs | 198 +- .../001_math/004_integer_division.ndc | 6 +- .../001_math/010_rational_numbers.ndc | 3 +- .../013_negative_integer_exponents.ndc | 2 +- .../014_exponentiation_right_associative.ndc | 2 +- .../programs/001_math/019_bit_operations.ndc | 5 +- .../programs/001_math/022_spaceship.ndc | 14 +- .../001_math/025_modulo_by_zero_bigint.ndc | 4 +- .../001_math/026_floor_div_by_zero.ndc | 2 +- .../001_math/028_rational_modulo_by_zero.ndc | 6 +- .../029_floor_div_by_zero_rational.ndc | 4 +- .../030_pow_zero_negative_exponent.ndc | 2 +- ...31_pow_zero_negative_rational_exponent.ndc | 2 +- .../programs/001_math/032_neg_abs_i64_min.ndc | 7 +- .../programs/001_math/033_numeric_modes.ndc | 40 + .../001_math/034_exact_numeric_equality.ndc | 24 + .../programs/001_math/035_number_literals.ndc | 18 + .../programs/001_math/036_number_stdlib.ndc | 25 + .../001_math/037_oversized_int_literal.ndc | 2 + .../038_arbitrary_radix_number_suffix.ndc | 2 + .../001_math/039_removed_rational_type.ndc | 2 + .../001_math/040_removed_complex_type.ndc | 2 + .../001_math/041_number_bitwise_error.ndc | 2 + .../programs/004_basic/039_binary_literal.ndc | 3 +- .../programs/004_basic/040_hex_literal.ndc | 3 +- .../004_basic/046_annotated_let_binding.ndc | 4 +- .../051_annotated_let_subtype_accepted.ndc | 2 +- .../055_annotated_let_op_assign_rejected.ndc | 2 +- ...058_annotated_list_index_op_assignment.ndc | 2 +- .../059_annotated_map_index_op_assignment.ndc | 2 +- .../062_non_widenable_index_op_assignment.ndc | 2 +- .../008_iterators/002_range_contains.ndc | 2 +- .../011_heap/011_numeric_types_ordering.ndc | 28 +- .../009_vector_exact_match_precision.ndc | 8 +- .../015_struct/017_struct_in_container.ndc | 8 +- .../601_stdlib_list/007_list_index_maybe.ndc | 4 - .../009_list_get_too_large_error.ndc | 6 +- .../programs/604_stdlib_math/001_sum.ndc | 4 +- .../programs/604_stdlib_math/002_product.ndc | 4 +- .../604_stdlib_math/003_mixed_sum_error.ndc | 3 + .../programs/605_stdlib_serde/003_numbers.ndc | 4 +- .../programs/605_stdlib_serde/004_lossy.ndc | 2 +- .../010_encode_error_rational.ndc | 2 +- .../900_bugs/bug0020_pow_huge_exponent.ndc | 2 +- ...bug0025_pow_negative_rational_exponent.ndc | 2 +- .../bug0026_neg_abs_i64_min_overflow.ndc | 7 +- .../998_not_desired/big_int_ranges.ndc | 2 +- .../programs/999_cursed/comical_factorial.ndc | 4 +- 77 files changed, 2016 insertions(+), 1318 deletions(-) create mode 100644 tests/functional/programs/001_math/033_numeric_modes.ndc create mode 100644 tests/functional/programs/001_math/034_exact_numeric_equality.ndc create mode 100644 tests/functional/programs/001_math/035_number_literals.ndc create mode 100644 tests/functional/programs/001_math/036_number_stdlib.ndc create mode 100644 tests/functional/programs/001_math/037_oversized_int_literal.ndc create mode 100644 tests/functional/programs/001_math/038_arbitrary_radix_number_suffix.ndc create mode 100644 tests/functional/programs/001_math/039_removed_rational_type.ndc create mode 100644 tests/functional/programs/001_math/040_removed_complex_type.ndc create mode 100644 tests/functional/programs/001_math/041_number_bitwise_error.ndc create mode 100644 tests/functional/programs/604_stdlib_math/003_mixed_sum_error.ndc diff --git a/Cargo.lock b/Cargo.lock index 7c1e0531..5cfd3afb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,7 +1173,6 @@ dependencies = [ "ahash", "itertools 0.15.0", "num", - "ordered-float", "ryu", "thiserror", ] @@ -1263,7 +1262,6 @@ dependencies = [ "ndc_lexer", "ndc_parser", "num", - "ordered-float", "thiserror", ] @@ -1373,15 +1371,6 @@ version = "11.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" -[[package]] -name = "ordered-float" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" -dependencies = [ - "num-traits", -] - [[package]] name = "page_size" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 5935d5a1..8ca58b87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,6 @@ ndc_macros = { path = "ndc_macros", version = "0.1.0" } ndc_stdlib = { path = "ndc_stdlib", version = "0.3.0" } num = "0.4.3" once_cell = "1.21.4" -ordered-float = "5.3.0" proptest = "1.11" yansi = { version = "1.0.1", features = ["detect-tty", "detect-env"] } rand = "0.10.2" diff --git a/manual/src/features/augmented-assignment.md b/manual/src/features/augmented-assignment.md index 3a7b8c76..40f79128 100644 --- a/manual/src/features/augmented-assignment.md +++ b/manual/src/features/augmented-assignment.md @@ -96,7 +96,7 @@ and may widen the inferred type of the target, just like a regular assignment: ```ndc let numbers = [1]; -numbers[0] += 0.5; // fine: the element type widens from Int to Number +numbers[0] += 0.5; // fine: the element type widens from Int to Any assert_eq(numbers, [1.5]); ``` diff --git a/manual/src/reference/types.md b/manual/src/reference/types.md index aa0559a5..1fbced09 100644 --- a/manual/src/reference/types.md +++ b/manual/src/reference/types.md @@ -7,11 +7,9 @@ The type system is hierarchical with `Any` at the root: * `Any` * [`Option`](./types/option.md) * [`Bool`](./types/boolean.md) - * [`Number`](./types/number.md) - * `Int` — machine `i64` or arbitrary-precision `BigInt`, picked automatically - * `Float` - * `Complex` - * `Rational` + * [`Int`](./types/number.md) — checked signed 64-bit integers + * [`Float`](./types/number.md) — IEEE 754 `f64` values + * [`Number`](./types/number.md) — arbitrary-size, rational, floating-point, and complex values * `Sequence` * [`String`](./types/string.md): a mutable list of characters * [`List`](./types/list.md): a mutable list diff --git a/manual/src/reference/types/number.md b/manual/src/reference/types/number.md index 604698d0..b257a50a 100644 --- a/manual/src/reference/types/number.md +++ b/manual/src/reference/types/number.md @@ -1,84 +1,124 @@ # Numbers -Andy C++ has four number types: - - * Int: which is subdivided into `Int64` and `BigInt` to support arbitrarily large numbers - * Float: which is backed by an `f64` - * Rational: which consists of two `BigInt`s and represents a fraction - * Complex: which is a pair of two `f64`'s - -## Operators - -| Operator | Function | Support augmented assignment [[1]](../../features/augmented-assignment.md) | Augmentable with `not` | -| :-: | --- | --- | --- | -| `+` | Addition | `true` | `false` | -| `-` | Subtraction | `true` | `false` | -| unary `-` | Negation | `true` | `false` | -| `*` | Multiplication | `true` | `false` | -| `/` | Division (returns rational for integers) | `true` | `false` | -| `\` | Floor division (integer result, rounds toward negative infinity) | `true` | `false` | -| `^` | Exponentiation | `true` | `false` | -| `%` | C-style modulo (can be negative) | `true` | `false` | -| `%%` | Remainder of [euclidean division](https://en.wikipedia.org/wiki/Euclidean_division) | `true` | `false` | -| `==` | Strict equality | `false` | `true` | -| `<=` | Less or equal | `false` | `true` | -| `<` | Less than | `false` | `true` | -| `>=` | Greater or equal | `false` | `true` | -| `>` | Greater than | `false` | `true` | -| `!=` | Not equal | `false` | `true` | -| `<=>` | Compare | `false` | `false` | -| `>=<` | Reverse compare | `false` | `false` | -| `<>` | Concatenate string values | `true` | `false` | - -### Division by zero - -Dividing by zero with `/` or `\` (floor division) follows floating-point -semantics: the result is promoted to a float and yields infinity (for example -`1 / 0` and `1 \ 0` are both `inf`). The remainder operators `%` (modulo) and -`%%` (euclidean remainder) have no meaningful result for a zero divisor, so they -raise a runtime error (`division by zero`). Floating-point `%` follows IEEE -semantics and returns `NaN`. - -Integers also support these operations: - -| Operator | Function | Support augmented assignment [[1]](../../features/augmented-assignment.md) | Augmentable with `not` | -| :-: | --- | --- | --- | -| `\|` | Bitwise OR | `true` | `false` | -| `&` | Bitwise AND | `true` | `false` | -| `~` | Bitwise XOR, or bitwise NOT in unary position | `true` | `false` | -| unary `~` | bitwise NOT | `true` | `false` | -| `>>` | Bitshift right | `true` | `false` | -| `<<` | Bitshift left | `true` | `false` | - -### Integers - -Andy C++ uses signed 64-bit integers until an expression overflows. At that point it switches to `BigInt` and computes the result with the [num crate](https://crates.io/crates/num). You can compute very large numbers this way, but code such as a naive solution to [Advent of Code 2022 - Day 11](https://adventofcode.com/2022/day/11) may keep allocating until you run out of memory. +Andy C++ exposes three sibling numeric types: + +* `Int` stores a signed 64-bit integer. Checked arithmetic reports overflow. +* `Float` stores an IEEE 754 `f64`. +* `Number` supports arbitrary-size integers, exact rational values, floats, and complex values. + +`Int`, `Float`, and `Number` share `Any` as their nearest common supertype. An `Int` does not satisfy a `Number` annotation. Use an `n` literal or the `Number` constructor when you need the advanced mode: + +```ndc +let count: Int = 42; +let measurement: Float = 42.0; +let exact: Number = 42n; + +assert_eq(Number(42), 42n); +assert_eq(Number(42.0), 42.0n); +``` + +## Literals + +The `n` suffix creates a `Number` from a decimal integer or float. Binary, octal, and hexadecimal integers also accept it: + +```ndc +let large = 123456789123456789123456789n; +let decimal = 1.25n; +let binary = 0b101010n; +let octal = 0o52n; +let hexadecimal = 0x2an; +``` + +An integer literal without `n` must fit in `i64`. The analyser reports an error and suggests the suffixed form when it does not fit. + +Arbitrary-radix literals such as `16r2a` remain `Int` literals and do not accept `n`. The `i` and `j` suffixes create complex `Number` values: + +```ndc +let z: Number = 2 + 3i; +assert_eq(z, 2 + 3j); +``` + +## Arithmetic modes + +The arithmetic operators `+`, `-`, `*`, `/`, `\`, `%`, `%%`, and `^` define all nine pairs of `Int`, `Float`, and `Number`. The operands select the result type: + +| Operands | Result | +| --- | --- | +| `Int`, `Int` | `Int` | +| `Int`, `Float` or `Float`, `Int` | `Float` | +| `Float`, `Float` | `Float` | +| Any pair containing `Number` | `Number` | + +`Int` uses checked `i64` arithmetic. `/` truncates toward zero, while `\` rounds toward negative infinity. `%` pairs with truncating division and `%%` returns a Euclidean remainder: + +```ndc +assert_eq(-7 / 2, -3); +assert_eq(-7 \ 2, -4); +assert_eq(-7 % 2, -1); +assert_eq(-7 %% 2, 1); +``` + +`Float` follows IEEE 754 behavior. `Number` keeps integer and rational operations exact when it can: ```ndc -let result = 2 ^ 1024; +assert_eq(7 / 2, 3); +assert_eq(7n / 2n, 7n / 2n); +assert_eq(2n ^ 100n, 1267650600228229401496703205376n); +``` + +Integer powers and shifts must fit their checked `Int` result. Use `Number` for negative exponents, arbitrary-size powers, and complex continuation. Roots, logarithms, inverse trigonometric functions, and fractional powers return a complex `Number` when the real result does not exist: -// Result: 179769313486231590772930519078902473361797697894230657273430081157732675805500963132708477322407536021120113879871393357658789768814416622492847430639474124377767893424865485276302219601246094119453082952085005768838150682342462881473913110540827237163350510684586298239947245938479716304835356329624224137216 +```ndc +assert_eq(5n ^ -1n, 1n / 5n); +assert_eq(sqrt(-1n), 1i); ``` -### Rational numbers and Floats -The math system keeps exact values unless you ask for a float. Dividing integers produces a rational number, not a float. An expression produces a float only when one of its operands is a float. +## Division by zero -One exception exists: raising an integer to a rational power produces a float. +`Int` division and remainder by zero report an error. `Float` returns IEEE infinity or NaN. `Number` also falls back to a wrapped Float result when an exact zero divisor has no rational representation: -For example: ```ndc -let result = 5^(1/2); +assert_eq(1n / 0n, Inf); -// result is Float because Int^ on Rational results in Float -assert_eq(result, 2.23606797749979); // Using == assertions on floats is risky +let nan = 0n / 0n; +assert(nan == nan); ``` -### Complex numbers +## Equality, hashing, and ordering -Andy C++ supports complex numbers. Use either `i` or `j` as the imaginary unit. Create a complex number by combining a real part with an imaginary part. +Numeric equality compares exact values across all three modes. Equal values produce the same map or set hash. Andy C++ converts each finite Float to its exact binary rational value for this comparison, so decimal approximation does not make values equal: ```ndc -let complex = 5.0 + 3.1j; -let result = complex * 1.3; // 6.5+4.03i -let result = complex + 5.3; // 10.3+3.1i +assert(1 == 1.0); +assert(1.0 == 1n); +assert(1n == 1 + 0i); +assert(0.1 != 1n / 10n); +assert_eq(%{1, 1.0, 1n, 1 + 0i}.len(), 1); ``` + +Positive and negative zero compare equal. All NaN values compare equal and hash alike. Real scalars sort in this order: + +```text +-Inf < finite values < Inf < NaN +``` + +Complex values keep lexicographic ordering. The comparison checks the real part, then the imaginary part: + +```ndc +assert((2 + 0i) > (1 + 100i)); +assert((1 + 2i) < (1 + 3i)); +``` + +## Integer-only operators + +Only `Int` supports bitwise operations and shifts: + +| Operator | Function | +| :-: | --- | +| `\|` | Bitwise OR | +| `&` | Bitwise AND | +| `~` | Binary XOR or unary NOT | +| `>>` | Checked right shift | +| `<<` | Checked left shift | + +Use `Int` values for list indices, range bounds, and APIs that take counts. Convert with `int(value)` when the value fits in `i64`. diff --git a/manual/src/reference/types/struct.md b/manual/src/reference/types/struct.md index b91c40cc..8d40b5b1 100644 --- a/manual/src/reference/types/struct.md +++ b/manual/src/reference/types/struct.md @@ -154,11 +154,10 @@ assert_eq(c.hits, 5); ``` A field is a typed location, so an augmented assignment whose result would not -fit the field type is rejected. For example `/` and `^` on integers may produce -non-integer numbers: +fit the field type is rejected: ```ndc -c.hits /= 2; // ERROR: mismatched types: found Number but expected Int +c.hits += 0.5; // ERROR: mismatched types: found Float but expected Int ``` ## Reference semantics diff --git a/manual/src/reference/variables-and-scopes.md b/manual/src/reference/variables-and-scopes.md index b8ff7f14..069c0e36 100644 --- a/manual/src/reference/variables-and-scopes.md +++ b/manual/src/reference/variables-and-scopes.md @@ -47,7 +47,7 @@ The `=` operator can be used to reassign a value to an existing variable. When y ```ndc let x = 1; // type is Int x = 2; // type is still Int -x = 3.14; // type widens to Number (LUB of Int and Float) +x = 3.14; // type widens to Any (Int and Float are siblings) ``` ```ndc @@ -68,11 +68,11 @@ let name: String = "world"; let xs: List = [1, 2, 3]; ``` -A subtype is fine — `Int` fits where `Number` is asked for, and so on: +A subtype is fine. All concrete types fit where `Any` is requested: ```ndc -let n: Number = 3; // OK: Int is a Number -let x: Any = "anything"; // OK: everything is Any +let n: Number = 3n; +let x: Any = "anything"; // every value fits Any ``` A mismatch is rejected with a `mismatched types` error: @@ -95,7 +95,7 @@ Once a binding has an annotation, it stays locked to that type. Reassignment and ```ndc let x: Int = 5; x = "test"; // ERROR: mismatched types -x /= 2; // ERROR: division can produce a Rational, which doesn't fit in Int +x += 0.5; // ERROR: Float doesn't fit in Int ``` If you want a binding that widens freely, just leave the annotation off. Annotations are opt-in. diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index c2c0e1a8..4442c55f 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -255,9 +255,16 @@ impl Analyser { match expression { Expression::BoolLiteral(_) => Ok(StaticType::Bool), Expression::StringLiteral(_) => Ok(StaticType::String), - Expression::Int64Literal(_) | Expression::BigIntLiteral(_) => Ok(StaticType::Int), + Expression::Int64Literal(_) => Ok(StaticType::Int), + Expression::BigIntLiteral(value) => { + self.emit(AnalysisError::integer_literal_out_of_range(value, *span)); + Ok(StaticType::Int) + } Expression::Float64Literal(_) => Ok(StaticType::Float), - Expression::ComplexLiteral(_) => Ok(StaticType::Complex), + Expression::NumberIntLiteral(_) | Expression::NumberFloatLiteral(_) => { + Ok(StaticType::Number) + } + Expression::ComplexLiteral(_) => Ok(StaticType::Number), Expression::Continue | Expression::Break => Ok(StaticType::Never), Expression::Identifier { name: ident, @@ -1279,6 +1286,16 @@ impl AnalysisError { self.help_text.as_deref() } + fn integer_literal_out_of_range(value: &impl std::fmt::Display, span: Span) -> Self { + Self { + text: format!( + "integer literal does not fit in Int; use the advanced literal `{value}n`" + ), + span, + help_text: None, + } + } + fn invalid_type_annotation(err: &StaticTypeConstructionError, span: Span) -> Self { Self { text: err.to_string(), @@ -1521,7 +1538,7 @@ mod tests { "let values = [1]; values[0] += 0.5; values", vec![("+".to_string(), add)], ), - StaticType::List(Box::new(StaticType::Number)), + StaticType::List(Box::new(StaticType::Any)), ); } diff --git a/ndc_analyser/src/scope.rs b/ndc_analyser/src/scope.rs index 950a8094..4e57e299 100644 --- a/ndc_analyser/src/scope.rs +++ b/ndc_analyser/src/scope.rs @@ -1469,9 +1469,8 @@ mod tests { assert_resolves_to(&mut tree, "locate", &call_sig, 0); } - // From the spec: distance(fn(Int) -> String, fn(Number) -> String) = 1. - // When two overloads in the same scope both match, the one whose params - // are a strict subtype (here `Int` is a subtype of `Number`) wins. + // Int and Number are siblings. An Int call must select the Int overload + // without treating the Number overload as a fallback candidate. #[test] fn more_specific_numeric_overload_wins() { let mut tree = ScopeTree::from_global_scope(vec![ diff --git a/ndc_bin/src/highlighter.rs b/ndc_bin/src/highlighter.rs index 9696660a..66c9d908 100644 --- a/ndc_bin/src/highlighter.rs +++ b/ndc_bin/src/highlighter.rs @@ -246,6 +246,8 @@ fn collect_function_spans(expr: &ExpressionLocation, spans: &mut AHashSet | Expression::Int64Literal(_) | Expression::Float64Literal(_) | Expression::BigIntLiteral(_) + | Expression::NumberIntLiteral(_) + | Expression::NumberFloatLiteral(_) | Expression::ComplexLiteral(_) | Expression::Identifier { .. } | Expression::StructDeclaration { .. } diff --git a/ndc_core/Cargo.toml b/ndc_core/Cargo.toml index 61c174c3..bf1606ae 100644 --- a/ndc_core/Cargo.toml +++ b/ndc_core/Cargo.toml @@ -7,6 +7,5 @@ version.workspace = true ahash.workspace = true itertools.workspace = true num.workspace = true -ordered-float.workspace = true ryu.workspace = true thiserror.workspace = true diff --git a/ndc_core/src/num.rs b/ndc_core/src/num.rs index ab3bba33..ba04e14a 100644 --- a/ndc_core/src/num.rs +++ b/ndc_core/src/num.rs @@ -9,157 +9,105 @@ use crate::int::Int; use num::bigint::TryFromBigIntError; use num::complex::{Complex64, ComplexFloat}; use num::{BigInt, BigRational, Complex, FromPrimitive, Signed, ToPrimitive, Zero}; -use ordered_float::OrderedFloat; #[derive(Debug, Clone)] -pub enum Number { +pub enum AdvancedNumber { Int(Int), Float(f64), Rational(Box), Complex(Complex64), } -#[derive(Debug)] -pub enum RealNumber<'a> { - Int(&'a Int), - Float(OrderedFloat), +#[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)] +enum CanonicalScalar { + NegInfinity, + Finite(BigRational), + PosInfinity, + NaN, +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)] +struct CanonicalNumber { + real: CanonicalScalar, + imaginary: CanonicalScalar, +} + +impl CanonicalScalar { + fn from_float(value: f64) -> Self { + if value.is_nan() { + Self::NaN + } else if value == f64::NEG_INFINITY { + Self::NegInfinity + } else if value == f64::INFINITY { + Self::PosInfinity + } else { + Self::Finite( + BigRational::from_float(value) + .expect("finite f64 values have an exact rational representation"), + ) + } + } + + fn zero() -> Self { + Self::Finite(BigRational::from_integer(BigInt::from(0))) + } } -impl From for Number { +impl From for AdvancedNumber { fn from(value: Int) -> Self { Self::Int(value) } } -impl From for Number { +impl From for AdvancedNumber { fn from(value: i32) -> Self { Self::Int(Int::from(value)) } } -impl From for Number { +impl From for AdvancedNumber { fn from(value: f64) -> Self { Self::Float(value) } } -impl From for Number { +impl From for AdvancedNumber { fn from(value: BigRational) -> Self { Self::Rational(Box::new(value)) } } -impl From for Number { +impl From for AdvancedNumber { fn from(value: Complex64) -> Self { Self::Complex(value) } } -impl PartialOrd for Number { +impl PartialOrd for AdvancedNumber { fn partial_cmp(&self, other: &Self) -> Option { - self.to_reals().partial_cmp(&other.to_reals()) + Some(self.canonical().cmp(&other.canonical())) } } -impl PartialEq for Number { +impl PartialEq for AdvancedNumber { fn eq(&self, other: &Self) -> bool { - self.partial_cmp(other) == Some(Ordering::Equal) + self.canonical() == other.canonical() } } -impl Default for Number { +impl Default for AdvancedNumber { fn default() -> Self { Self::Int(Int::Int64(0)) } } -impl Hash for Number { +impl Hash for AdvancedNumber { fn hash(&self, state: &mut H) { - match self { - Self::Int(i) => { - state.write_u8(1); - i.hash(state); - } - Self::Float(f) => { - // If this float happens to be an integer (such as 1.0) hash it as if it's an int - match Int::from_f64_if_int(*f) { - None => { - state.write_u8(2); - OrderedFloat(*f).hash(state); - } - Some(int) => { - state.write_u8(1); - int.hash(state); - } - } - } - Self::Rational(r) => { - if r.is_integer() { - // simplify rational - state.write_u8(1); - Int::BigInt(r.to_integer()).hash(state); - } else { - state.write_u8(3); - r.hash(state); - } - } - Self::Complex(c) => { - state.write_u8(4); - OrderedFloat(c.re).hash(state); - OrderedFloat(c.im).hash(state); - } - } + self.canonical().hash(state); } } -impl PartialEq for RealNumber<'_> { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::Int(left), Self::Int(right)) => left.eq(right), - (Self::Float(left), Self::Float(right)) => left.eq(right), - (Self::Int(left), Self::Float(right)) => { - compare_int_to_float(left, *right) == Some(Ordering::Equal) - } - (Self::Float(left), Self::Int(right)) => { - compare_int_to_float(right, *left) == Some(Ordering::Equal) - } - } - } -} - -fn compare_int_to_float(a: &Int, b: OrderedFloat) -> Option { - if b.is_infinite() { - if b.is_sign_positive() { - Some(Ordering::Less) - } else { - Some(Ordering::Greater) - } - } else if b.is_nan() { - Some(OrderedFloat(f64::from(a)).cmp(&b)) - } else { - let x = BigInt::from_f64(b.trunc()).expect("b can't be NaN"); - a.to_bigint() - .partial_cmp(&x) - .map(|ord| ord.then(0.0f64.total_cmp(&b.fract()))) - } -} - -impl PartialOrd for RealNumber<'_> { - fn partial_cmp(&self, other: &Self) -> Option { - match (self, other) { - (RealNumber::Int(a), RealNumber::Int(b)) => Some(a.cmp(b)), - (RealNumber::Int(a), RealNumber::Float(b)) => compare_int_to_float(a, *b), - (RealNumber::Float(a), RealNumber::Int(b)) => { - compare_int_to_float(b, *a).map(Ordering::reverse) - } - (RealNumber::Float(a), RealNumber::Float(b)) => { - Some(OrderedFloat(*a).cmp(&OrderedFloat(*b))) - } - } - } -} - -impl Neg for Number { +impl Neg for AdvancedNumber { type Output = Self; fn neg(self) -> Self::Output { @@ -172,7 +120,7 @@ impl Neg for Number { } } -impl Not for Number { +impl Not for AdvancedNumber { type Output = Self; fn not(self) -> Self::Output { @@ -228,38 +176,44 @@ impl BinaryOperatorError { macro_rules! impl_binary_operator { ($self:ty, $other:ty, $trait:ident, $method:ident,$intmethod:expr,$floatmethod:expr,$rationalmethod:expr,$complexmethod:expr) => { impl $trait<$other> for $self { - type Output = Result; + type Output = Result; fn $method(self, other: $other) -> Self::Output { Ok(match (self, other) { // Integer - (Number::Int(left), Number::Int(right)) => Number::Int($intmethod(left, right)), + (AdvancedNumber::Int(left), AdvancedNumber::Int(right)) => { + AdvancedNumber::Int($intmethod(left, right)) + } // Complex - (Number::Complex(left), right) => { - Number::Complex($complexmethod(left, right.to_complex())) + (AdvancedNumber::Complex(left), right) => { + AdvancedNumber::Complex($complexmethod(left, right.to_complex())) } - (left, Number::Complex(right)) => { - Number::Complex($complexmethod(left.to_complex(), right)) + (left, AdvancedNumber::Complex(right)) => { + AdvancedNumber::Complex($complexmethod(left.to_complex(), right)) } // Float // NOTE: these `expect` calls are safe because complex has already been handled - (Number::Float(left), right) => Number::Float($floatmethod( + (AdvancedNumber::Float(left), right) => AdvancedNumber::Float($floatmethod( left, right.to_f64().expect("cannot convert complex to float"), )), - (left, Number::Float(right)) => Number::Float($floatmethod( + (left, AdvancedNumber::Float(right)) => AdvancedNumber::Float($floatmethod( left.to_f64().expect("cannot convert complex to float"), right, )), // Rational // NOTE: these `expect` calls are safe because complex and float are handled - (left, Number::Rational(right)) => Number::rational($rationalmethod( - left.to_rational().expect("cannot convert to rational"), - right.unbox(), - )), - (Number::Rational(left), right) => Number::rational($rationalmethod( - left.unbox(), - right.to_rational().expect("cannot convert to rational"), - )), + (left, AdvancedNumber::Rational(right)) => { + AdvancedNumber::rational($rationalmethod( + left.to_rational().expect("cannot convert to rational"), + right.unbox(), + )) + } + (AdvancedNumber::Rational(left), right) => { + AdvancedNumber::rational($rationalmethod( + left.unbox(), + right.to_rational().expect("cannot convert to rational"), + )) + } }) } } @@ -269,8 +223,8 @@ macro_rules! impl_binary_operator { macro_rules! impl_binary_operator_all { ($implement:ident,$method:ident,$intmethod:expr,$floatmethod:expr,$rationalmethod:expr,$complexmethod:expr) => { impl_binary_operator!( - Number, - Number, + AdvancedNumber, + AdvancedNumber, $implement, $method, $intmethod, @@ -279,8 +233,8 @@ macro_rules! impl_binary_operator_all { $complexmethod ); impl_binary_operator!( - Number, - &Number, + AdvancedNumber, + &AdvancedNumber, $implement, $method, $intmethod, @@ -289,8 +243,8 @@ macro_rules! impl_binary_operator_all { $complexmethod ); impl_binary_operator!( - &Number, - Number, + &AdvancedNumber, + AdvancedNumber, $implement, $method, $intmethod, @@ -299,8 +253,8 @@ macro_rules! impl_binary_operator_all { $complexmethod ); impl_binary_operator!( - &Number, - &Number, + &AdvancedNumber, + &AdvancedNumber, $implement, $method, $intmethod, @@ -316,21 +270,22 @@ impl_binary_operator_all!(Sub, sub, Sub::sub, Sub::sub, Sub::sub, Sub::sub); impl_binary_operator_all!(Mul, mul, Mul::mul, Mul::mul, Mul::mul, Mul::mul); /// Returns `true` for the number kinds that use exact (integer/rational) -/// arithmetic. Remainder of an exact value by zero panics in `num` and -/// `num-bigint`; floats and complex numbers produce `NaN` instead, so they -/// are handled by the normal arithmetic path. -fn is_exact(n: &Number) -> bool { - matches!(n, Number::Int(_) | Number::Rational(_)) +/// arithmetic. +fn is_exact(n: &AdvancedNumber) -> bool { + matches!(n, AdvancedNumber::Int(_) | AdvancedNumber::Rational(_)) } -impl Rem for Number { +impl Rem for AdvancedNumber { type Output = Result; fn rem(self, rhs: Self) -> Self::Output { - // Reject exact remainder by zero up front; without this the integer - // and rational arms below panic ("attempt to divide by zero"). + // Exact arithmetic cannot represent a zero-divisor result. Number + // operations deliberately fall back to IEEE floating-point values in + // that case, just like exact division does. if is_exact(&self) && is_exact(&rhs) && rhs.is_zero() { - return Err(BinaryOperatorError::new("division by zero".to_string())); + return Ok(Self::Float( + self.to_f64().unwrap_or(f64::NAN) % rhs.to_f64().unwrap_or(f64::NAN), + )); } Ok(match (self, rhs) { // Integer @@ -356,7 +311,7 @@ impl Rem for Number { } } -impl Rem<&Self> for Number { +impl Rem<&Self> for AdvancedNumber { type Output = Result; fn rem(self, rhs: &Self) -> Self::Output { @@ -364,59 +319,80 @@ impl Rem<&Self> for Number { } } -impl Rem for &Number { - type Output = Result; +impl Rem for &AdvancedNumber { + type Output = Result; - fn rem(self, rhs: Number) -> Self::Output { + fn rem(self, rhs: AdvancedNumber) -> Self::Output { self.clone() % rhs } } -impl Rem<&Number> for &Number { - type Output = Result; +impl Rem<&AdvancedNumber> for &AdvancedNumber { + type Output = Result; - fn rem(self, rhs: &Number) -> Self::Output { + fn rem(self, rhs: &AdvancedNumber) -> Self::Output { self.clone() % rhs.clone() } } -impl Div<&Number> for &Number { - type Output = Number; +impl Div<&AdvancedNumber> for &AdvancedNumber { + type Output = AdvancedNumber; - fn div(self, rhs: &Number) -> Self::Output { + fn div(self, rhs: &AdvancedNumber) -> Self::Output { match (self.to_rational(), rhs.to_rational()) { - (Some(left), Some(right)) if !right.is_zero() => Number::rational(left / right), + (Some(left), Some(right)) if !right.is_zero() => AdvancedNumber::rational(left / right), _ => match (self.to_f64(), rhs.to_f64()) { - (Some(left), Some(right)) => Number::Float(left / right), - _ => Number::Complex(self.to_complex() / rhs.to_complex()), + (Some(left), Some(right)) => AdvancedNumber::Float(left / right), + _ => AdvancedNumber::Complex(self.to_complex() / rhs.to_complex()), }, } } } -impl Div for Number { +impl Div for AdvancedNumber { type Output = Result; fn div(self, rhs: Self) -> Self::Output { Ok(&self / &rhs) } } -impl Div<&Self> for Number { +impl Div<&Self> for AdvancedNumber { type Output = Result; fn div(self, rhs: &Self) -> Self::Output { Ok(&self / rhs) } } -impl Div for &Number { - type Output = Result; +impl Div for &AdvancedNumber { + type Output = Result; - fn div(self, rhs: Number) -> Self::Output { + fn div(self, rhs: AdvancedNumber) -> Self::Output { Ok(self / &rhs) } } -impl Number { +impl AdvancedNumber { + fn canonical(&self) -> CanonicalNumber { + match self { + Self::Int(value) => CanonicalNumber { + real: CanonicalScalar::Finite(BigRational::from(value)), + imaginary: CanonicalScalar::zero(), + }, + Self::Float(value) => CanonicalNumber { + real: CanonicalScalar::from_float(*value), + imaginary: CanonicalScalar::zero(), + }, + Self::Rational(value) => CanonicalNumber { + real: CanonicalScalar::Finite(value.as_ref().clone()), + imaginary: CanonicalScalar::zero(), + }, + Self::Complex(value) => CanonicalNumber { + real: CanonicalScalar::from_float(value.re), + imaginary: CanonicalScalar::from_float(value.im), + }, + } + } + #[must_use] pub fn complex(re: f64, im: f64) -> Self { Self::Complex(Complex64 { re, im }) @@ -433,12 +409,7 @@ impl Number { } pub fn static_type(&self) -> StaticType { - match self { - Self::Int(_) => StaticType::Int, - Self::Float(_) => StaticType::Float, - Self::Rational(_) => StaticType::Rational, - Self::Complex(_) => StaticType::Complex, - } + StaticType::Number } #[must_use] @@ -451,40 +422,37 @@ impl Number { } } - pub fn checked_rem_euclid(self, rhs: Self) -> Result { - match (self, rhs) { - (Self::Int(p1), Self::Int(p2)) => { - if p2.is_zero() { - return Err(BinaryOperatorError::new("division by zero".to_string())); - } - p1.checked_rem_euclid(&p2) - .ok_or(BinaryOperatorError::new("operation failed".to_string())) - .map(Self::Int) - } - - (Self::Float(p1), Self::Float(p2)) => Ok(Self::Float(p1.rem_euclid(p2))), - (left, right) => Err(BinaryOperatorError::undefined_operation( + pub fn checked_rem_euclid(self, rhs: &Self) -> Result { + if matches!(self, Self::Complex(_)) || matches!(rhs, Self::Complex(_)) { + return Err(BinaryOperatorError::undefined_operation( "%%", - &left.static_type(), - &right.static_type(), - )), + &self.static_type(), + &rhs.static_type(), + )); + } + if rhs.is_zero() { + return Ok(Self::Float( + self.to_f64() + .unwrap_or(f64::NAN) + .rem_euclid(rhs.to_f64().unwrap_or(f64::NAN)), + )); } + if let (Some(left), Some(right)) = (self.to_rational(), rhs.to_rational()) { + let mut remainder = left % &right; + if remainder.is_negative() { + remainder += right.abs(); + } + return Ok(Self::rational(remainder)); + } + Ok(Self::Float( + self.to_f64() + .expect("complex operands were rejected") + .rem_euclid(rhs.to_f64().expect("complex operands were rejected")), + )) } pub fn floor_div(self, rhs: Self) -> Result { - match (self, rhs) { - // Fast path for two i64s. `div_euclid` panics on a zero divisor - // (and on `i64::MIN / -1`), so fall back to the general path in - // those cases — it promotes to a float and yields infinity for a - // zero divisor, matching `/` and the BigInt/rational paths. - (Self::Int(Int::Int64(l)), Self::Int(Int::Int64(r))) => match l.checked_div_euclid(r) { - Some(q) => Ok(Self::Int(Int::Int64(q))), - None => Self::Int(Int::Int64(l)) - .div(Self::Int(Int::Int64(r))) - .map(|n| n.floor()), - }, - (l, r) => Ok(l.div(r)?.floor()), - } + Ok(self.div(rhs)?.floor()) } /// Raise an integer base to a (possibly negative) integer exponent. @@ -526,7 +494,14 @@ impl Number { Ok(match (self, rhs) { // Int vs others (Self::Int(p1), Self::Int(p2)) => return Self::int_pow(&p1, &p2), - (Self::Int(p1), Self::Float(p2)) => Self::Float(f64::from(p1).powf(p2)), + (Self::Int(p1), Self::Float(p2)) => { + let p1 = f64::from(p1); + if p1 < 0.0 && p2.fract() != 0.0 { + Self::Complex(Complex64::from(p1).powf(p2)) + } else { + Self::Float(p1.powf(p2)) + } + } (Self::Int(p1), Self::Complex(p2)) => { Self::Complex(Complex::from(f64::from(p1)).powc(p2)) } @@ -535,7 +510,13 @@ impl Number { return Self::int_pow(&p1, &Int::BigInt(p2.to_integer())); } - Self::Float(f64::from(p1).powf(rational_to_float(&p2))) + let p1 = f64::from(p1); + let p2 = rational_to_float(&p2); + if p1 < 0.0 { + Self::Complex(Complex64::from(p1).powf(p2)) + } else { + Self::Float(p1.powf(p2)) + } } // Rational vs Others @@ -549,18 +530,44 @@ impl Number { return Ok(Self::Rational(Box::new(p1.pow(p2)))); } - Self::Float(rational_to_float(&p1).powf(rational_to_float(&p2))) + let p1 = rational_to_float(&p1); + let p2 = rational_to_float(&p2); + if p1 < 0.0 { + Self::Complex(Complex64::from(p1).powf(p2)) + } else { + Self::Float(p1.powf(p2)) + } + } + (Self::Rational(p1), Self::Float(p2)) => { + let p1 = rational_to_float(&p1); + if p1 < 0.0 && p2.fract() != 0.0 { + Self::Complex(Complex64::from(p1).powf(p2)) + } else { + Self::Float(p1.powf(p2)) + } } - (Self::Rational(p1), Self::Float(p2)) => Self::Float(rational_to_float(&p1).powf(p2)), (Self::Rational(p1), Self::Complex(p2)) => { Self::Complex(rational_to_complex(&p1).powc(p2)) } // Float vs others - (Self::Float(p1), Self::Float(p2)) => Self::Float(p1.powf(p2)), + (Self::Float(p1), Self::Float(p2)) => { + if p1 < 0.0 && p2.fract() != 0.0 { + Self::Complex(Complex64::from(p1).powf(p2)) + } else { + Self::Float(p1.powf(p2)) + } + } (Self::Float(p1), Self::Complex(p2)) => Self::Complex(Complex::from(p1).powc(p2)), (Self::Float(p1), Self::Int(p2)) => Self::Float(p1.powf(f64::from(p2))), - (Self::Float(p1), Self::Rational(p2)) => Self::Float(p1.powf(rational_to_float(&p2))), + (Self::Float(p1), Self::Rational(p2)) => { + let p2 = rational_to_float(&p2); + if p1 < 0.0 && p2.fract() != 0.0 { + Self::Complex(Complex64::from(p1).powf(p2)) + } else { + Self::Float(p1.powf(p2)) + } + } // Complex vs others (Self::Complex(p1), Self::Complex(p2)) => Self::Complex(p1.powc(p2)), @@ -625,27 +632,6 @@ impl Number { } } - /// Converts this number into a real (complex) number with the imaginary part set to 0.0 - /// which makes it easy to do comparison on all numbers (and possibly other things) - #[must_use] - pub fn to_reals(&self) -> (RealNumber<'_>, RealNumber<'_>) { - match self { - Self::Int(i) => (RealNumber::Int(i), RealNumber::Float(OrderedFloat(0.0))), - Self::Float(f) => ( - RealNumber::Float(OrderedFloat(*f)), - RealNumber::Float(OrderedFloat(0.0)), - ), - Self::Rational(r) => ( - RealNumber::Float(OrderedFloat(rational_to_float(r))), - RealNumber::Float(OrderedFloat(0.0)), - ), - Self::Complex(c) => ( - RealNumber::Float(OrderedFloat(c.re)), - RealNumber::Float(OrderedFloat(c.im)), - ), - } - } - #[must_use] pub fn abs(&self) -> Self { match self { @@ -676,21 +662,25 @@ impl Number { macro_rules! implement_rounding { ($method:ident) => { - impl Number { + impl AdvancedNumber { #[must_use] - pub fn $method(&self) -> Number { + pub fn $method(&self) -> AdvancedNumber { match self { - Number::Int(i) => Number::Int(i.clone()), - Number::Float(f) => { + AdvancedNumber::Int(i) => AdvancedNumber::Int(i.clone()), + AdvancedNumber::Float(f) => { let f = f.$method(); if let Some(i) = Int::from_f64_trunc(f) { - Number::Int(i) + AdvancedNumber::Int(i) } else { - Number::Float(f) + AdvancedNumber::Float(f) } } - Number::Rational(r) => Number::Int(Int::BigInt(r.$method().to_integer())), - Number::Complex(c) => Complex::new(c.re.$method(), c.im.$method()).into(), + AdvancedNumber::Rational(r) => { + AdvancedNumber::Int(Int::BigInt(r.$method().to_integer())) + } + AdvancedNumber::Complex(c) => { + Complex::new(c.re.$method(), c.im.$method()).into() + } } } } @@ -711,13 +701,13 @@ pub enum NumberToUsizeError { FromBigIntError(#[from] TryFromBigIntError), } -impl TryFrom for usize { +impl TryFrom for usize { type Error = NumberToUsizeError; - fn try_from(value: Number) -> Result { + fn try_from(value: AdvancedNumber) -> Result { match value { - Number::Int(Int::Int64(i)) => Ok(Self::try_from(i)?), - Number::Int(Int::BigInt(b)) => Ok(Self::try_from(b)?), + AdvancedNumber::Int(Int::Int64(i)) => Ok(Self::try_from(i)?), + AdvancedNumber::Int(Int::BigInt(b)) => Ok(Self::try_from(b)?), n => Err(NumberToUsizeError::UnsupportedVariant(n.static_type())), } } @@ -728,18 +718,18 @@ pub enum NumberToFloatError { #[error("cannot convert {0} to float")] UnsupportedType(StaticType), #[error("cannot convert {0} to float")] - UnsupportedValue(Number), + UnsupportedValue(AdvancedNumber), } -impl TryFrom<&Number> for f64 { +impl TryFrom<&AdvancedNumber> for f64 { type Error = NumberToFloatError; - fn try_from(value: &Number) -> Result { + fn try_from(value: &AdvancedNumber) -> Result { match value { - Number::Int(Int::BigInt(bi)) => bi.to_f64(), - Number::Int(Int::Int64(i)) => i.to_f64(), - Number::Float(f) => Some(*f), - Number::Rational(r) => r.to_f64(), + AdvancedNumber::Int(Int::BigInt(bi)) => bi.to_f64(), + AdvancedNumber::Int(Int::Int64(i)) => i.to_f64(), + AdvancedNumber::Float(f) => Some(*f), + AdvancedNumber::Rational(r) => r.to_f64(), _ => return Err(Self::Error::UnsupportedType(value.static_type())), } .ok_or_else(|| Self::Error::UnsupportedValue(value.clone())) @@ -751,24 +741,24 @@ pub enum NumberToIntError { #[error("cannot convert {0} to int")] UnsupportedType(StaticType), #[error("cannot convert {0} to int")] - UnsupportedValue(Number), + UnsupportedValue(AdvancedNumber), } -impl TryFrom<&Number> for i64 { +impl TryFrom<&AdvancedNumber> for i64 { type Error = NumberToIntError; - fn try_from(value: &Number) -> Result { + fn try_from(value: &AdvancedNumber) -> Result { match value { - Number::Int(Int::BigInt(bi)) => bi + AdvancedNumber::Int(Int::BigInt(bi)) => bi .try_into() .map_err(|_err| NumberToIntError::UnsupportedValue(value.clone())), - Number::Int(Int::Int64(i)) => Ok(*i), + AdvancedNumber::Int(Int::Int64(i)) => Ok(*i), _ => Err(Self::Error::UnsupportedType(value.static_type())), } } } -impl fmt::Display for Number { +impl fmt::Display for AdvancedNumber { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Int(i) => write!(f, "{i}"), @@ -794,11 +784,47 @@ fn rational_to_complex(r: &BigRational) -> Complex { #[error("{0}")] pub struct NumberConversionError(String); -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -#[deprecated = "use static type instead?"] -pub enum NumberType { - Int, - Float, - Rational, - Complex, +#[cfg(test)] +mod tests { + use super::*; + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + fn hash(value: &AdvancedNumber) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() + } + + #[test] + fn equality_and_hash_use_exact_numeric_values() { + let integer = AdvancedNumber::Int(Int::Int64(1)); + let float = AdvancedNumber::Float(1.0); + let complex = AdvancedNumber::complex(1.0, -0.0); + + assert_eq!(integer, float); + assert_eq!(float, complex); + assert_eq!(hash(&integer), hash(&float)); + assert_eq!(hash(&float), hash(&complex)); + + let tenth = AdvancedNumber::rational(BigRational::new(1.into(), 10.into())); + assert_ne!(tenth, AdvancedNumber::Float(0.1)); + } + + #[test] + fn nan_is_equal_and_sorts_after_infinity() { + let nan = AdvancedNumber::Float(f64::NAN); + let complex_nan = AdvancedNumber::complex(f64::NAN, 0.0); + let infinity = AdvancedNumber::Float(f64::INFINITY); + + assert_eq!(nan, nan.clone()); + assert_eq!(nan, complex_nan); + assert!(nan > infinity); + } + + #[test] + fn complex_order_is_lexicographic() { + assert!(AdvancedNumber::complex(2.0, 0.0) > AdvancedNumber::complex(1.0, 100.0)); + assert!(AdvancedNumber::complex(1.0, 2.0) < AdvancedNumber::complex(1.0, 3.0)); + } } diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 9c9a8c40..994da2d9 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -15,9 +15,8 @@ impl Default for TypeSignature { } impl TypeSignature { - /// Matches a list of `ValueTypes` to a type signature. It can return `None` if there is no match or - /// `Some(num)` where num is the sum of the distances of the types. The type `Int`, is distance 1 - /// away from `Number`, and `Number` is 1 distance from `Any`, then `Int` is distance 2 from `Any`. + /// Matches argument types to a signature. Returns `None` for a mismatch, + /// or a score where exact matches cost zero and subtype matches cost one. pub fn calc_type_score(&self, types: &[StaticType]) -> Option { match self { Self::Variadic => Some(0), @@ -106,10 +105,8 @@ pub enum StaticType { Number, Float, Int, - Rational, - Complex, - // Sequences List -> List + // Sequences List -> Sequence Sequence(Box), List(Box), String, @@ -154,8 +151,8 @@ impl StaticType { /// Must stay in sync with its match arms; the unit test /// `builtin_type_names_are_constructible` guards this. pub const BUILTIN_TYPE_NAMES: &'static [&'static str] = &[ - "Any", "Never", "Bool", "Number", "Float", "Int", "Rational", "Complex", "String", - "Option", "Sequence", "List", "Iterator", "MinHeap", "MaxHeap", "Deque", "Tuple", "Map", + "Any", "Never", "Bool", "Number", "Float", "Int", "String", "Option", "Sequence", "List", + "Iterator", "MinHeap", "MaxHeap", "Deque", "Tuple", "Map", ]; pub fn from_name_and_args( @@ -169,8 +166,6 @@ impl StaticType { "Number" => Self::require_no_args(name, &args).map(|_| Self::Number), "Float" => Self::require_no_args(name, &args).map(|_| Self::Float), "Int" => Self::require_no_args(name, &args).map(|_| Self::Int), - "Rational" => Self::require_no_args(name, &args).map(|_| Self::Rational), - "Complex" => Self::require_no_args(name, &args).map(|_| Self::Complex), "String" => Self::require_no_args(name, &args).map(|_| Self::String), "Option" => { Self::require_exactly_one_arg(name, args).map(|elem| Self::Option(Box::new(elem))) @@ -260,7 +255,7 @@ impl StaticType { /// /// # Key Rules /// - `Any` is the top type (supertype of all types) - /// - `Number` > `{Float, Int, Rational, Complex}` + /// - `Int`, `Float`, and `Number` are sibling types /// - `Sequence` > sequence types with element type T /// - Generic types are covariant in their type parameters /// - Function parameters are **contravariant**, returns are **covariant** @@ -288,9 +283,6 @@ impl StaticType { // Reflexivity: every type is a subtype of itself _ if self == other => true, - // Number hierarchy: all numeric types are subtypes of Number - (Self::Float | Self::Int | Self::Rational | Self::Complex, Self::Number) => true, - // Sequence hierarchy: specific sequences are subtypes of Sequence // where T is their element type (covariant) (Self::List(s), Self::Sequence(t)) @@ -377,8 +369,8 @@ impl StaticType { /// The LUB is the most specific type that is a supertype of both inputs. /// /// # Examples - /// - `lub(Int, Float) = Number` - /// - `lub(List, List) = List` + /// - `lub(Int, Float) = Any` + /// - `lub(List, List) = List` /// - `lub(List, Iterator) = Sequence` /// - `lub(Int, String) = Any` pub fn lub(&self, other: &Self) -> Self { @@ -409,12 +401,6 @@ impl StaticType { } match (self, other) { - // Number type lattice: all numeric types join to Number - (Self::Float, Self::Int | Self::Rational | Self::Complex) - | (Self::Int, Self::Float | Self::Rational | Self::Complex) - | (Self::Rational, Self::Float | Self::Int | Self::Complex) - | (Self::Complex, Self::Float | Self::Int | Self::Rational) => Self::Number, - // Covariant generic types: compute LUB pointwise (Self::Option(s), Self::Option(t)) => Self::Option(Box::new(s.lub(t))), (Self::List(s), Self::List(t)) => Self::List(Box::new(s.lub(t))), @@ -575,10 +561,7 @@ impl StaticType { } pub fn is_number(&self) -> bool { - matches!( - self, - Self::Number | Self::Float | Self::Int | Self::Rational | Self::Complex - ) + matches!(self, Self::Number | Self::Float | Self::Int) } /// Checks whether some runtime value could satisfy both `self` and `other`. @@ -751,8 +734,6 @@ impl StaticType { | Self::Number | Self::Float | Self::Int - | Self::Rational - | Self::Complex | Self::Map { .. } | Self::Struct { .. } // for now, we won't have positional unpacking | Self::Never => None, @@ -781,8 +762,6 @@ impl fmt::Display for StaticType { Self::Number => write!(f, "Number"), Self::Float => write!(f, "Float"), Self::Int => write!(f, "Int"), - Self::Rational => write!(f, "Rational"), - Self::Complex => write!(f, "Complex"), Self::Sequence(elem) => write!(f, "Sequence<{elem}>"), Self::List(elem) => write!(f, "List<{elem}>"), Self::String => write!(f, "String"), @@ -887,4 +866,14 @@ mod test { ); } } + + #[test] + fn public_numeric_types_are_siblings() { + assert!(!StaticType::Int.is_subtype(&StaticType::Number)); + assert!(!StaticType::Float.is_subtype(&StaticType::Number)); + assert_eq!(StaticType::Int.lub(&StaticType::Float), StaticType::Any); + assert_eq!(StaticType::Int.lub(&StaticType::Number), StaticType::Any); + assert!(StaticType::from_name_and_args("Rational", vec![]).is_err()); + assert!(StaticType::from_name_and_args("Complex", vec![]).is_err()); + } } diff --git a/ndc_lexer/src/number.rs b/ndc_lexer/src/number.rs index eb3fc994..7f465bc0 100644 --- a/ndc_lexer/src/number.rs +++ b/ndc_lexer/src/number.rs @@ -45,6 +45,13 @@ impl NumberLexer for Lexer<'_> { self.lex_to_buffer(&mut buf, |c| c == '1' || c == '0'); + let is_number = if matches!(self.source.peek(), Some('n')) { + self.source.next(); + true + } else { + false + }; + match self.source.peek() { Some(c) if c.is_ascii_digit() => { self.source.next(); @@ -63,7 +70,12 @@ impl NumberLexer for Lexer<'_> { _ => {} } - return match buf_to_token_with_radix(&buf, 2) { + let token = if is_number { + buf_to_number_token_with_radix(&buf, 2) + } else { + buf_to_token_with_radix(&buf, 2) + }; + return match token { Some(token) => Ok(TokenLocation { token, span: self.source.create_span(start_offset), @@ -80,7 +92,19 @@ impl NumberLexer for Lexer<'_> { self.lex_to_buffer(&mut buf, |c| c.is_ascii_hexdigit()); - return match buf_to_token_with_radix(&buf, 16) { + let is_number = if matches!(self.source.peek(), Some('n')) { + self.source.next(); + true + } else { + false + }; + + let token = if is_number { + buf_to_number_token_with_radix(&buf, 16) + } else { + buf_to_token_with_radix(&buf, 16) + }; + return match token { Some(token) => Ok(TokenLocation { token, span: self.source.create_span(start_offset), @@ -97,7 +121,19 @@ impl NumberLexer for Lexer<'_> { self.lex_to_buffer(&mut buf, |c| matches!(c, '0'..='7')); - return match buf_to_token_with_radix(&buf, 8) { + let is_number = if matches!(self.source.peek(), Some('n')) { + self.source.next(); + true + } else { + false + }; + + let token = if is_number { + buf_to_number_token_with_radix(&buf, 8) + } else { + buf_to_token_with_radix(&buf, 8) + }; + return match token { Some(token) => Ok(TokenLocation { token, span: self.source.create_span(start_offset), @@ -158,6 +194,14 @@ impl NumberLexer for Lexer<'_> { let mut buf = String::new(); self.lex_to_buffer(&mut buf, validator_for_radix(usize::from(radix))); + if matches!(self.source.peek(), Some('n')) { + return Err(Error::text( + "the `n` suffix is not supported on arbitrary-radix literals" + .to_string(), + self.source.create_span(start_offset), + )); + } + return match buf_to_token_with_radix(&buf, u32::from(radix)) { Some(token) => Ok(TokenLocation { token, @@ -177,6 +221,30 @@ impl NumberLexer for Lexer<'_> { } } } + 'n' => { + self.source.next(); + let token = if is_float { + buf.parse::() + .map(Token::NumberFloat) + .map_err(|_error| { + Error::text( + format!("invalid Number literal '{buf}n'"), + self.source.create_span(start_offset), + ) + })? + } else { + buf_to_number_token_with_radix(&buf, 10).ok_or_else(|| { + Error::text( + format!("invalid Number literal '{buf}n'"), + self.source.create_span(start_offset), + ) + })? + }; + return Ok(TokenLocation { + token, + span: self.source.create_span(start_offset), + }); + } 'j' | 'i' => { self.source.next(); @@ -221,6 +289,12 @@ fn buf_to_token_with_radix(buf: &str, radix: u32) -> Option { } } +fn buf_to_number_token_with_radix(buf: &str, radix: u32) -> Option { + BigInt::from_str_radix(buf, radix) + .ok() + .map(Token::NumberInt) +} + fn validator_for_radix(radix: usize) -> impl Fn(char) -> bool { move |c| "0123456789abcdefghijlkmnopqrstuvwxyz"[0..radix].contains(c.to_ascii_lowercase()) } diff --git a/ndc_lexer/src/token.rs b/ndc_lexer/src/token.rs index 9a432614..cd433e7e 100644 --- a/ndc_lexer/src/token.rs +++ b/ndc_lexer/src/token.rs @@ -10,6 +10,8 @@ pub enum Token { Int64(i64), Float64(f64), BigInt(BigInt), + NumberInt(BigInt), + NumberFloat(f64), Complex(Complex64), Infinity, @@ -108,6 +110,13 @@ impl fmt::Display for Token { Self::BigInt(n) => { return write!(f, "{n}"); } + Self::NumberInt(n) => { + return write!(f, "{n}n"); + } + Self::NumberFloat(n) => { + let mut buffer = ryu::Buffer::new(); + return write!(f, "{}n", buffer.format(*n)); + } Self::Complex(n) => { return write!(f, "{n}"); } diff --git a/ndc_lsp/src/scope_resolve.rs b/ndc_lsp/src/scope_resolve.rs index 855b83a6..dcad6b08 100644 --- a/ndc_lsp/src/scope_resolve.rs +++ b/ndc_lsp/src/scope_resolve.rs @@ -206,6 +206,8 @@ fn collect(expr: &ExpressionLocation, scope: Span, out: &mut Vec) { | Expression::Int64Literal(_) | Expression::Float64Literal(_) | Expression::BigIntLiteral(_) + | Expression::NumberIntLiteral(_) + | Expression::NumberFloatLiteral(_) | Expression::ComplexLiteral(_) | Expression::Break | Expression::Continue => {} diff --git a/ndc_lsp/src/visitor.rs b/ndc_lsp/src/visitor.rs index 350f3bd6..0ba0eea1 100644 --- a/ndc_lsp/src/visitor.rs +++ b/ndc_lsp/src/visitor.rs @@ -191,6 +191,8 @@ fn child_expressions(expr: &ExpressionLocation) -> Vec<&ExpressionLocation> { | Expression::Int64Literal(_) | Expression::Float64Literal(_) | Expression::BigIntLiteral(_) + | Expression::NumberIntLiteral(_) + | Expression::NumberFloatLiteral(_) | Expression::ComplexLiteral(_) | Expression::Break | Expression::Continue @@ -361,6 +363,8 @@ fn walk_expression(visitor: &mut impl AstVisitor, expr: &ExpressionLocation) { | Expression::Int64Literal(_) | Expression::Float64Literal(_) | Expression::BigIntLiteral(_) + | Expression::NumberIntLiteral(_) + | Expression::NumberFloatLiteral(_) | Expression::ComplexLiteral(_) | Expression::Break | Expression::Continue diff --git a/ndc_macros/src/function.rs b/ndc_macros/src/function.rs index 4bcc3801..6f174047 100644 --- a/ndc_macros/src/function.rs +++ b/ndc_macros/src/function.rs @@ -263,7 +263,7 @@ fn map_type_path(p: &syn::TypePath) -> syn::Result { "Result requires angle bracketed arguments", )), }, - "Number" => Ok(quote! { ndc_core::StaticType::Number }), + "AdvancedNumber" => Ok(quote! { ndc_core::StaticType::Number }), "MapValue" => Ok(quote! { ndc_core::StaticType::Map { key: Box::new(ndc_core::StaticType::Any), diff --git a/ndc_macros/src/types.rs b/ndc_macros/src/types.rs index 0947770e..3753fa2b 100644 --- a/ndc_macros/src/types.rs +++ b/ndc_macros/src/types.rs @@ -11,9 +11,9 @@ /// since the generated code differs based on ownership. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NdcType { - /// Owned `Number` + /// Owned `AdvancedNumber` Number, - /// `&Number` + /// `&AdvancedNumber` NumberRef, /// `f64` F64, @@ -112,7 +112,7 @@ fn classify_owned(ty: &syn::Type) -> Option { let last = segments.last()?; match last.ident.to_string().as_str() { - "Number" => Some(NdcType::Number), + "AdvancedNumber" => Some(NdcType::Number), "f64" => Some(NdcType::F64), "bool" => Some(NdcType::Bool), "i64" => Some(NdcType::I64), diff --git a/ndc_macros/src/vm_convert.rs b/ndc_macros/src/vm_convert.rs index f53bd8fa..f2e034b3 100644 --- a/ndc_macros/src/vm_convert.rs +++ b/ndc_macros/src/vm_convert.rs @@ -99,7 +99,10 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { let err = arg_error(position, "float"); VmInputArg { extract: quote! { - let #temp = #raw.to_f64().ok_or_else(|| #err)?; + let ndc_vm::value::Value::Float(#temp) = #raw else { + return Err(#err); + }; + let #temp = *#temp; }, pass: quote! { #temp }, static_type: quote! { ndc_core::StaticType::Float }, @@ -160,11 +163,14 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { let err = arg_error(position, "int"); VmInputArg { extract: quote! { - let #temp = { - let num = #raw.to_number().ok_or_else(|| #err)?; - usize::try_from(num).map_err(|e| { - ndc_vm::error::VmError::native(format!("arg {}: {}", #position, e)) - })? + let #temp = match #raw { + ndc_vm::value::Value::Int(value) => usize::try_from(*value).map_err(|_| { + ndc_vm::error::VmError::native(format!( + "arg {}: expected a non-negative integer, but the value was negative", + #position, + )) + })?, + _ => return Err(#err), }; }, pass: quote! { #temp }, @@ -176,12 +182,9 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { let err = arg_error(position, "int"); VmInputArg { extract: quote! { - let #temp = { - let num = #raw.to_number().ok_or_else(|| #err)?; - match num { - ndc_core::num::Number::Int(i) => i.to_bigint(), - _ => return Err(#err), - } + let #temp = match #raw { + ndc_vm::value::Value::Int(value) => num::BigInt::from(*value), + _ => return Err(#err), }; }, pass: quote! { &#temp }, @@ -196,13 +199,13 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { let #temp = { let num = #raw.to_number().ok_or_else(|| #err)?; match num { - ndc_core::num::Number::Rational(r) => *r, + ndc_core::num::AdvancedNumber::Rational(r) => *r, _ => return Err(#err), } }; }, pass: quote! { &#temp }, - static_type: quote! { ndc_core::StaticType::Rational }, + static_type: quote! { ndc_core::StaticType::Number }, } } @@ -213,13 +216,13 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { let #temp = { let num = #raw.to_number().ok_or_else(|| #err)?; match num { - ndc_core::num::Number::Complex(c) => c, + ndc_core::num::AdvancedNumber::Complex(c) => c, _ => return Err(#err), } }; }, pass: quote! { #temp }, - static_type: quote! { ndc_core::StaticType::Complex }, + static_type: quote! { ndc_core::StaticType::Number }, } } @@ -520,7 +523,7 @@ fn vm_return_for_classified(ty: &syn::Type) -> Option<(TokenStream, TokenStream) NdcType::BigInt => Some(( quote! { Ok(ndc_vm::value::Value::from_number( - ndc_core::num::Number::Int( + ndc_core::num::AdvancedNumber::Int( ndc_core::int::Int::BigInt(result).simplified() ) )) @@ -530,10 +533,10 @@ fn vm_return_for_classified(ty: &syn::Type) -> Option<(TokenStream, TokenStream) NdcType::BigRational => Some(( quote! { Ok(ndc_vm::value::Value::from_number( - ndc_core::num::Number::Rational(Box::new(result)) + ndc_core::num::AdvancedNumber::Rational(Box::new(result)) )) }, - quote! { ndc_core::StaticType::Rational }, + quote! { ndc_core::StaticType::Number }, )), // Input-only types — not valid as return types _ => None, diff --git a/ndc_parser/src/expression.rs b/ndc_parser/src/expression.rs index 41dd20c1..5d2e138f 100644 --- a/ndc_parser/src/expression.rs +++ b/ndc_parser/src/expression.rs @@ -98,6 +98,8 @@ pub enum Expression { Int64Literal(i64), Float64Literal(f64), BigIntLiteral(BigInt), + NumberIntLiteral(BigInt), + NumberFloatLiteral(f64), ComplexLiteral(Complex64), Identifier { name: String, diff --git a/ndc_parser/src/parser.rs b/ndc_parser/src/parser.rs index 4356dffe..f9f5ae72 100644 --- a/ndc_parser/src/parser.rs +++ b/ndc_parser/src/parser.rs @@ -1070,6 +1070,8 @@ impl Parser { Token::Int64(num) => Expression::Int64Literal(num), Token::Float64(num) => Expression::Float64Literal(num), Token::BigInt(num) => Expression::BigIntLiteral(num), + Token::NumberInt(num) => Expression::NumberIntLiteral(num), + Token::NumberFloat(num) => Expression::NumberFloatLiteral(num), Token::Complex(num) => Expression::ComplexLiteral(num), Token::String(value) => Expression::StringLiteral(value), Token::Identifier(identifier) => Expression::Identifier { diff --git a/ndc_stdlib/src/index.rs b/ndc_stdlib/src/index.rs index 968c4ced..3955578e 100644 --- a/ndc_stdlib/src/index.rs +++ b/ndc_stdlib/src/index.rs @@ -259,18 +259,9 @@ fn extract_vm_offset(index_value: &Value, size: usize) -> Result *i, - Value::Object(obj) => match obj.as_ref() { - Object::BigInt(n) => num::ToPrimitive::to_i64(n) - .ok_or_else(|| VmError::native("index too large for i64"))?, - _ => { - return Err(VmError::native( - "Invalid list index. List indices must be convertible to a signed 64-bit integer.", - )); - } - }, _ => { return Err(VmError::native( - "Invalid list index. List indices must be convertible to a signed 64-bit integer.", + "Invalid list index. List indices must be Int values.", )); } }; diff --git a/ndc_stdlib/src/lib.rs b/ndc_stdlib/src/lib.rs index eb1066a5..ef1f503a 100644 --- a/ndc_stdlib/src/lib.rs +++ b/ndc_stdlib/src/lib.rs @@ -37,7 +37,6 @@ pub fn register(env: &mut FunctionRegistry>) { index::register(env); list::ops::register(env); list::register(env); - math::f64::register(env); math::register(env); #[cfg(feature = "rand")] rand::register(env); diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index e19da491..5074a375 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -1,747 +1,1224 @@ use factorial::Factorial; -use ndc_core::num::{BinaryOperatorError, Number}; -use ndc_macros::export_module; -use ndc_vm::value::{Object, SeqValue, Value}; -use num::ToPrimitive; -use std::ops::{Add, Mul}; +use ndc_core::int::Int; +use ndc_core::num::{AdvancedNumber, BinaryOperatorError}; +use ndc_core::{FunctionRegistry, StaticType}; +use ndc_vm::error::VmError; +use ndc_vm::value::{NativeFunc, NativeFunction, Object, Value}; +use num::complex::Complex64; +use num::{BigInt, BigUint, FromPrimitive, Integer, ToPrimitive}; +use std::cmp::Ordering; +use std::ops::{Add, Div, Mul, Neg, Not, Rem, Sub}; +use std::rc::Rc; -#[export_module] -mod inner { - use std::ops::Sub; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NumericKind { + Int, + Float, + Number, +} - use anyhow::Context; - use ndc_core::int::Int; - use ndc_core::num::Number; - use num::{BigInt, BigRational, BigUint, Integer, complex::Complex64}; +impl NumericKind { + fn static_type(self) -> StaticType { + match self { + Self::Int => StaticType::Int, + Self::Float => StaticType::Float, + Self::Number => StaticType::Number, + } + } +} - /// Returns the sign of a number. - /// - /// For `int`, `float`, and `rational` types, this function returns `-1` if the number is negative, `0` if zero, and `1` if positive. - /// For complex numbers, it returns the number divided by its magnitude (`z / |z|`) if non-zero, or `0` if the number is `0`. - pub fn signum(n: &Number) -> Number { - n.signum() +#[derive(Clone, Copy)] +enum BinaryOperation { + Add, + Sub, + Mul, + Div, + FloorDiv, + Rem, + RemEuclid, + Pow, +} + +impl BinaryOperation { + fn name(self) -> &'static str { + match self { + Self::Add => "+", + Self::Sub => "-", + Self::Mul => "*", + Self::Div => "/", + Self::FloorDiv => "\\", + Self::Rem => "%", + Self::RemEuclid => "%%", + Self::Pow => "^", + } } - /// Returns the real part of a complex number. - pub fn real(c: Complex64) -> f64 { - c.re + fn documentation(self) -> &'static str { + match self { + Self::Add => "Adds two numbers.", + Self::Sub => "Subtracts two numbers.", + Self::Mul => "Multiplies two numbers.", + Self::Div => "Divides two numbers.", + Self::FloorDiv => { + "Divides two numbers and rounds the quotient toward negative infinity." + } + Self::Rem => "Returns the remainder paired with truncating division.", + Self::RemEuclid => "Returns the Euclidean remainder.", + Self::Pow => "Raises the left operand to the power of the right operand.", + } } +} + +pub fn register(env: &mut FunctionRegistry>) { + register_binary_arithmetic(env); + register_unary_arithmetic(env); + register_comparisons(env); + register_bitwise(env); + register_constructors(env); + register_aggregates(env); + register_number_helpers(env); + register_integer_helpers(env); + register_conversions(env); + register_transcendentals(env); +} + +fn declare( + env: &mut FunctionRegistry>, + name: &str, + parameters: Vec, + return_type: StaticType, + documentation: &str, + func: impl Fn(&[Value]) -> Result + 'static, +) { + env.declare_global_fn(Rc::new(NativeFunction { + name: name.to_string(), + documentation: Some(documentation.to_string()), + static_type: StaticType::Function { + parameters: Some(parameters), + return_type: Box::new(return_type), + }, + func: NativeFunc::Simple(Box::new(func)), + })); +} - /// Returns the imaginary part of a complex number. - pub fn imag(c: Complex64) -> f64 { - c.im +fn arity(args: &[Value], expected: usize) -> Result<(), VmError> { + if args.len() == expected { + Ok(()) + } else { + Err(VmError::native(format!( + "expected {expected} arguments, got {}", + args.len() + ))) } +} + +fn native_error(error: impl std::fmt::Display) -> VmError { + VmError::native(error.to_string()) +} - /// Returns the numerator of a rational number. - pub fn numerator(r: &BigRational) -> BigInt { - r.numer().clone() +fn result_kind(left: NumericKind, right: NumericKind) -> NumericKind { + if left == NumericKind::Number || right == NumericKind::Number { + NumericKind::Number + } else if left == NumericKind::Float || right == NumericKind::Float { + NumericKind::Float + } else { + NumericKind::Int } +} - /// Returns the denominator of a rational number. - pub fn denominator(r: &BigRational) -> BigInt { - r.denom().clone() +fn register_binary_arithmetic(env: &mut FunctionRegistry>) { + const KINDS: [NumericKind; 3] = [NumericKind::Int, NumericKind::Float, NumericKind::Number]; + const OPERATIONS: [BinaryOperation; 8] = [ + BinaryOperation::Add, + BinaryOperation::Sub, + BinaryOperation::Mul, + BinaryOperation::Div, + BinaryOperation::FloorDiv, + BinaryOperation::Rem, + BinaryOperation::RemEuclid, + BinaryOperation::Pow, + ]; + + for operation in OPERATIONS { + for left_kind in KINDS { + for right_kind in KINDS { + let output_kind = result_kind(left_kind, right_kind); + declare( + env, + operation.name(), + vec![left_kind.static_type(), right_kind.static_type()], + output_kind.static_type(), + operation.documentation(), + move |args| { + arity(args, 2)?; + eval_binary( + operation, + left_kind, + right_kind, + output_kind, + &args[0], + &args[1], + ) + }, + ); + } + } } +} - /// Returns the sum of all elements in a sequence. - pub fn sum(seq: SeqValue) -> anyhow::Result { - if matches!(&seq, Value::Object(o) if matches!(o.as_ref(), Object::String(_))) { - anyhow::bail!("string cannot be summed"); +fn eval_binary( + operation: BinaryOperation, + left_kind: NumericKind, + right_kind: NumericKind, + output_kind: NumericKind, + left: &Value, + right: &Value, +) -> Result { + match output_kind { + NumericKind::Int => { + let (Value::Int(left), Value::Int(right)) = (left, right) else { + return Err(VmError::native("expected two Int operands".to_string())); + }; + eval_int_binary(operation, *left, *right).map(Value::Int) + } + NumericKind::Float => { + let left = primitive_float(left_kind, left)?; + let right = primitive_float(right_kind, right)?; + Ok(Value::Float(eval_float_binary(operation, left, right))) } - seq.try_into_iter() - .ok_or_else(|| anyhow::anyhow!("cannot sum non-sequence"))? - .try_fold(Number::from(0), |acc, val| { - let n = val - .to_number() - .ok_or_else(|| anyhow::anyhow!("cannot sum {}", val.static_type()))?; - acc.add(&n).map_err(|e| anyhow::anyhow!("{e}")) - }) - } - - /// Returns the product of all elements in a sequence. - pub fn product(seq: SeqValue) -> anyhow::Result { - if matches!(&seq, Value::Object(o) if matches!(o.as_ref(), Object::String(_))) { - anyhow::bail!("string cannot be multiplied"); + NumericKind::Number => { + let left = promoted_number(left_kind, left)?; + let right = promoted_number(right_kind, right)?; + eval_number_binary(operation, left, right) + .map(Value::from_number) + .map_err(native_error) } - seq.try_into_iter() - .ok_or_else(|| anyhow::anyhow!("cannot multiply non-sequence"))? - .try_fold(Number::from(1), |acc, val| { - let n = val - .to_number() - .ok_or_else(|| anyhow::anyhow!("cannot multiply {}", val.static_type()))?; - acc.mul(&n).map_err(|e| anyhow::anyhow!("{e}")) - }) } +} - /// Returns the factorial of a non-negative integer. - pub fn factorial(a: &BigInt) -> anyhow::Result { - let num = - BigUint::try_from(a).context("cannot compute the factorial of a negative number")?; - - Ok(num.factorial().into()) +fn primitive_float(kind: NumericKind, value: &Value) -> Result { + match (kind, value) { + (NumericKind::Int, Value::Int(value)) => Ok(*value as f64), + (NumericKind::Float, Value::Float(value)) => Ok(*value), + _ => Err(VmError::native(format!( + "expected {}, got {}", + kind.static_type(), + value.static_type() + ))), } +} - /// Returns the greatest common divisor of two integers. - pub fn gcd(a: &BigInt, b: &BigInt) -> BigInt { - a.gcd(b) +fn promoted_number(kind: NumericKind, value: &Value) -> Result { + match (kind, value) { + (NumericKind::Int, Value::Int(value)) => Ok(AdvancedNumber::Int(Int::Int64(*value))), + (NumericKind::Float, Value::Float(value)) => Ok(AdvancedNumber::Float(*value)), + (NumericKind::Number, Value::Number(value)) => Ok(value.as_ref().clone()), + _ => Err(VmError::native(format!( + "expected {}, got {}", + kind.static_type(), + value.static_type() + ))), } +} - /// Returns the least common multiple of two integers. - pub fn lcm(a: &BigInt, b: &BigInt) -> BigInt { - a.lcm(b) +fn eval_int_binary(operation: BinaryOperation, left: i64, right: i64) -> Result { + if right == 0 + && matches!( + operation, + BinaryOperation::Div + | BinaryOperation::FloorDiv + | BinaryOperation::Rem + | BinaryOperation::RemEuclid + ) + { + return Err(VmError::native("division by zero".to_string())); + } + if right < 0 && matches!(operation, BinaryOperation::Pow) { + return Err(VmError::native( + "negative integer exponents require Number operands".to_string(), + )); } + let failed = || { + VmError::native(format!( + "integer operation overflowed or is undefined: {left} {} {right}", + operation.name() + )) + }; + match operation { + BinaryOperation::Add => left.checked_add(right).ok_or_else(failed), + BinaryOperation::Sub => left.checked_sub(right).ok_or_else(failed), + BinaryOperation::Mul => left.checked_mul(right).ok_or_else(failed), + BinaryOperation::Div => left.checked_div(right).ok_or_else(failed), + BinaryOperation::FloorDiv => checked_floor_div(left, right).ok_or_else(failed), + BinaryOperation::Rem => left.checked_rem(right).ok_or_else(failed), + BinaryOperation::RemEuclid => left.checked_rem_euclid(right).ok_or_else(failed), + BinaryOperation::Pow => u32::try_from(right) + .ok() + .and_then(|right| left.checked_pow(right)) + .ok_or_else(failed), + } +} - /// Returns the smallest integer greater than or equal to the number. - pub fn ceil(number: &Number) -> Number { - number.ceil() +fn checked_floor_div(left: i64, right: i64) -> Option { + let quotient = left.checked_div(right)?; + let remainder = left.checked_rem(right)?; + if remainder != 0 && (left < 0) != (right < 0) { + quotient.checked_sub(1) + } else { + Some(quotient) } +} - /// Rounds the number to the nearest integer, with ties rounding away from zero. - pub fn round(number: &Number) -> Number { - number.round() +fn eval_float_binary(operation: BinaryOperation, left: f64, right: f64) -> f64 { + match operation { + BinaryOperation::Add => left + right, + BinaryOperation::Sub => left - right, + BinaryOperation::Mul => left * right, + BinaryOperation::Div => left / right, + BinaryOperation::FloorDiv => (left / right).floor(), + BinaryOperation::Rem => left % right, + BinaryOperation::RemEuclid => left.rem_euclid(right), + BinaryOperation::Pow => left.powf(right), } +} - /// Returns the largest integer less than or equal to the number. - pub fn floor(number: &Number) -> Number { - number.floor() +fn eval_number_binary( + operation: BinaryOperation, + left: AdvancedNumber, + right: AdvancedNumber, +) -> Result { + match operation { + BinaryOperation::Add => left.add(right), + BinaryOperation::Sub => left.sub(right), + BinaryOperation::Mul => left.mul(right), + BinaryOperation::Div => left.div(right), + BinaryOperation::FloorDiv => left.floor_div(right), + BinaryOperation::Rem => left.rem(right), + BinaryOperation::RemEuclid => left.checked_rem_euclid(&right), + BinaryOperation::Pow => left.pow(right), } +} - /// Returns the absolute value of a number. - pub fn abs(number: &Number) -> Number { - number.abs() +fn register_unary_arithmetic(env: &mut FunctionRegistry>) { + declare( + env, + "-", + vec![StaticType::Int], + StaticType::Int, + "Negates an integer.", + |args| { + let [Value::Int(value)] = args else { + return Err(VmError::native("expected one Int argument".to_string())); + }; + value + .checked_neg() + .map(Value::Int) + .ok_or_else(|| VmError::native("integer negation overflowed".to_string())) + }, + ); + declare( + env, + "-", + vec![StaticType::Float], + StaticType::Float, + "Negates a floating-point number.", + |args| { + let [Value::Float(value)] = args else { + return Err(VmError::native("expected one Float argument".to_string())); + }; + Ok(Value::Float(-value)) + }, + ); + declare( + env, + "-", + vec![StaticType::Number], + StaticType::Number, + "Negates an advanced number.", + |args| { + let [Value::Number(value)] = args else { + return Err(VmError::native("expected one Number argument".to_string())); + }; + Ok(Value::from_number(value.as_ref().clone().neg())) + }, + ); +} + +fn register_comparisons(env: &mut FunctionRegistry>) { + for (name, predicate, docs) in [ + ( + ">", + (|ordering| ordering == Ordering::Greater) as fn(Ordering) -> bool, + "Returns whether the left value is greater than the right.", + ), + ( + ">=", + |ordering| matches!(ordering, Ordering::Greater | Ordering::Equal), + "Returns whether the left value is greater than or equal to the right.", + ), + ( + "<", + |ordering| ordering == Ordering::Less, + "Returns whether the left value is less than the right.", + ), + ( + "<=", + |ordering| matches!(ordering, Ordering::Less | Ordering::Equal), + "Returns whether the left value is less than or equal to the right.", + ), + ] { + declare( + env, + name, + vec![StaticType::Any, StaticType::Any], + StaticType::Bool, + docs, + move |args| { + arity(args, 2)?; + let ordering = args[0].partial_cmp(&args[1]).ok_or_else(|| { + VmError::native(format!( + "cannot compare {} and {}", + args[0].static_type(), + args[1].static_type() + )) + })?; + Ok(Value::Bool(predicate(ordering))) + }, + ); } - /// Returns the absolute difference between two numbers. - pub fn abs_diff(left: &Number, right: &Number) -> Result { - Ok(left.sub(right)?.abs()) + declare( + env, + "==", + vec![StaticType::Any, StaticType::Any], + StaticType::Bool, + "Returns whether two values are equal.", + |args| { + arity(args, 2)?; + Ok(Value::Bool(args[0] == args[1])) + }, + ); + declare( + env, + "!=", + vec![StaticType::Any, StaticType::Any], + StaticType::Bool, + "Returns whether two values are not equal.", + |args| { + arity(args, 2)?; + Ok(Value::Bool(args[0] != args[1])) + }, + ); + + for (name, reverse, docs) in [ + ("<=>", false, "Performs a three-way comparison."), + (">=<", true, "Performs a reverse three-way comparison."), + ] { + declare( + env, + name, + vec![StaticType::Any, StaticType::Any], + StaticType::Int, + docs, + move |args| { + arity(args, 2)?; + let ordering = args[0].partial_cmp(&args[1]).ok_or_else(|| { + VmError::native(format!( + "cannot compare {} and {}", + args[0].static_type(), + args[1].static_type() + )) + })?; + let result = match ordering { + Ordering::Less => -1, + Ordering::Equal => 0, + Ordering::Greater => 1, + }; + Ok(Value::Int(if reverse { -result } else { result })) + }, + ); } +} - /// Converts a value to a floating-point number. - pub fn float(value: Value) -> anyhow::Result { - match &value { - Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }), - Value::Object(obj) => match obj.as_ref() { - Object::String(s) => Ok(s.borrow().parse::()?), - _ => value.to_f64().ok_or_else(|| { - anyhow::anyhow!("cannot convert {} to float", value.static_type()) - }), +fn register_bitwise(env: &mut FunctionRegistry>) { + for (name, operation, docs) in [ + ( + "&", + (|left, right| left & right) as fn(i64, i64) -> i64, + "Computes bitwise AND of two integers.", + ), + ( + "|", + |left, right| left | right, + "Computes bitwise OR of two integers.", + ), + ( + "~", + |left, right| left ^ right, + "Computes bitwise XOR of two integers.", + ), + ] { + declare( + env, + name, + vec![StaticType::Int, StaticType::Int], + StaticType::Int, + docs, + move |args| { + let [Value::Int(left), Value::Int(right)] = args else { + return Err(VmError::native("expected two Int arguments".to_string())); + }; + Ok(Value::Int(operation(*left, *right))) }, - _ => value - .to_f64() - .ok_or_else(|| anyhow::anyhow!("cannot convert {} to float", value.static_type())), - } + ); } - /// Computes the four-quadrant arctangent of `y` and `x` in radians. - pub fn atan2(y: f64, x: f64) -> f64 { - y.atan2(x) - } - - /// Converts a given `Value` to an `Int`. - /// - /// Conversion rules: - /// - Integers are unchanged - /// - Floating-point numbers have their decimal part truncated - /// - Rational numbers are rounded down - /// - `true` is converted to `1`, and `false` to `0` - /// - Strings are parsed as decimal integers; other representations result in an error - pub fn int(value: Value) -> anyhow::Result { - match &value { - Value::Bool(b) => Ok(Number::from(if *b { 1i32 } else { 0i32 })), - Value::Object(obj) => match obj.as_ref() { - Object::String(s) => { - let bi = s.borrow().parse::()?; - Ok(Number::Int(Int::BigInt(bi).simplified())) - } - _ => value - .to_number() - .ok_or_else(|| { - anyhow::anyhow!("cannot convert {} to int", value.static_type()) - })? - .to_int_lossy() - .map_err(|e| anyhow::anyhow!("{e}")), + for (name, operation, docs) in [ + ( + "&", + (|left, right| left & right) as fn(bool, bool) -> bool, + "Computes logical AND of two booleans.", + ), + ( + "|", + |left, right| left | right, + "Computes logical OR of two booleans.", + ), + ( + "~", + |left, right| left ^ right, + "Computes logical XOR of two booleans.", + ), + ] { + declare( + env, + name, + vec![StaticType::Bool, StaticType::Bool], + StaticType::Bool, + docs, + move |args| { + let [Value::Bool(left), Value::Bool(right)] = args else { + return Err(VmError::native("expected two Bool arguments".to_string())); + }; + Ok(Value::Bool(operation(*left, *right))) }, - _ => value - .to_number() - .ok_or_else(|| anyhow::anyhow!("cannot convert {} to int", value.static_type()))? - .to_int_lossy() - .map_err(|e| anyhow::anyhow!("{e}")), - } + ); } -} -pub mod f64 { - use super::{Number, ToPrimitive, f64}; - use ndc_core::StaticType; - use ndc_core::int::Int; - use ndc_core::num::BinaryOperatorError; - use ndc_vm::error::VmError; - use ndc_vm::value::{NativeFunc, NativeFunction, Value}; - use std::cmp::Ordering; - use std::ops::Not; - use std::rc::Rc; - - pub fn register(env: &mut ndc_core::FunctionRegistry>) { - macro_rules! implement_binary_operator_on_num { - ($operator:literal,$method:expr,$docs:literal) => { - env.declare_global_fn(Rc::new(NativeFunction { - name: $operator.to_string(), - documentation: Some($docs.to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Number, StaticType::Number]), - return_type: Box::new(StaticType::Number), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [left, right] => { - let l = left.to_number().ok_or_else(|| { - VmError::native(format!( - "expected number, got {}", - left.static_type() - )) - })?; - let r = right.to_number().ok_or_else(|| { - VmError::native(format!( - "expected number, got {}", - right.static_type() - )) - })?; - $method(l, r) - .map(Value::from_number) - .map_err(|e: BinaryOperatorError| VmError::native(e.to_string())) - } - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); + declare( + env, + "~", + vec![StaticType::Int], + StaticType::Int, + "Computes bitwise NOT of an integer.", + |args| { + let [Value::Int(value)] = args else { + return Err(VmError::native("expected one Int argument".to_string())); }; - } + Ok(Value::Int(value.not())) + }, + ); - implement_binary_operator_on_num!("-", std::ops::Sub::sub, "Subtracts two numbers."); - implement_binary_operator_on_num!("+", std::ops::Add::add, "Adds two numbers."); - implement_binary_operator_on_num!("*", std::ops::Mul::mul, "Multiplies two numbers."); - implement_binary_operator_on_num!("/", std::ops::Div::div, "Divides two numbers."); - implement_binary_operator_on_num!( - "\\", - Number::floor_div, - "Integer (floor) division of two numbers." - ); - implement_binary_operator_on_num!( - "^", - Number::pow, - "Raises the first number to the power of the second." + for name in ["!", "not"] { + declare( + env, + name, + vec![StaticType::Bool], + StaticType::Bool, + "Computes logical negation.", + |args| { + let [Value::Bool(value)] = args else { + return Err(VmError::native("expected one Bool argument".to_string())); + }; + Ok(Value::Bool(!value)) + }, ); - implement_binary_operator_on_num!( - "%", - std::ops::Rem::rem, - "Returns the remainder of dividing two numbers." + } + + for (name, left_shift) in [("<<", true), (">>", false)] { + declare( + env, + name, + vec![StaticType::Int, StaticType::Int], + StaticType::Int, + "Shifts an integer by a checked non-negative amount.", + move |args| { + let [Value::Int(left), Value::Int(right)] = args else { + return Err(VmError::native("expected two Int arguments".to_string())); + }; + let right = u32::try_from(*right) + .map_err(|_error| VmError::native("invalid shift amount".to_string()))?; + let result = if left_shift { + left.checked_shl(right) + } else { + left.checked_shr(right) + }; + result + .map(Value::Int) + .ok_or_else(|| VmError::native("invalid shift amount".to_string())) + }, ); - implement_binary_operator_on_num!( - "%%", - Number::checked_rem_euclid, - "Returns the Euclidean remainder of dividing two numbers. The result is always non-negative." + } +} + +fn register_constructors(env: &mut FunctionRegistry>) { + for kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + declare( + env, + "Number", + vec![kind.static_type()], + StaticType::Number, + "Wraps a primitive numeric value as a Number.", + move |args| { + arity(args, 1)?; + promoted_number(kind, &args[0]).map(Value::from_number) + }, ); + } +} - // Int-specific overloads: fast path on i64, fall back to Number on overflow/BigInt. - macro_rules! implement_binary_operator_on_int { - ($operator:literal, $checked_method:ident, $fallback:expr, $docs:literal) => { - env.declare_global_fn(Rc::new(NativeFunction { - name: $operator.to_string(), - documentation: Some($docs.to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Int, StaticType::Int]), - return_type: Box::new(StaticType::Int), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [Value::Int(l), Value::Int(r)] => { - if let Some(result) = l.$checked_method(*r) { - Ok(Value::Int(result)) - } else { - let l = Int::Int64(*l); - let r = Int::Int64(*r); - Ok(Value::from_int($fallback(l, r))) - } - } - [left, right] => { - let l = left.to_int().ok_or_else(|| { - VmError::native(format!("expected int, got {}", left.static_type())) - })?; - let r = right.to_int().ok_or_else(|| { - VmError::native(format!( - "expected int, got {}", - right.static_type() - )) - })?; - Ok(Value::from_int($fallback(l, r))) - } - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); - }; +fn register_aggregates(env: &mut FunctionRegistry>) { + for kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + for (name, product) in [("sum", false), ("product", true)] { + declare( + env, + name, + vec![StaticType::Sequence(Box::new(kind.static_type()))], + kind.static_type(), + if product { + "Returns the product of a numeric sequence." + } else { + "Returns the sum of a numeric sequence." + }, + move |args| aggregate(args, kind, product), + ); } + } +} - implement_binary_operator_on_int!( - "+", - checked_add, - std::ops::Add::add, - "Adds two integers." - ); - implement_binary_operator_on_int!( - "-", - checked_sub, - std::ops::Sub::sub, - "Subtracts two integers." - ); - implement_binary_operator_on_int!( - "*", - checked_mul, - std::ops::Mul::mul, - "Multiplies two integers." - ); - // Integer remainder needs an explicit division-by-zero guard: the - // generic fast-path fallback (`Int % Int`) panics on a zero divisor. - env.declare_global_fn(Rc::new(NativeFunction { - name: "%".to_string(), - documentation: Some("Returns the remainder of dividing two integers.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Int, StaticType::Int]), - return_type: Box::new(StaticType::Int), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [Value::Int(l), Value::Int(r)] => { - if *r == 0 { - return Err(VmError::native("division by zero".to_string())); - } - if let Some(result) = l.checked_rem(*r) { - Ok(Value::Int(result)) - } else { - // The fast path only overflows for `i64::MIN % -1`, - // whose mathematical result is 0; fall back to Int. - Ok(Value::from_int(Int::Int64(*l) % Int::Int64(*r))) - } +fn aggregate(args: &[Value], kind: NumericKind, product: bool) -> Result { + arity(args, 1)?; + let mut values = args[0] + .clone() + .try_into_iter() + .ok_or_else(|| VmError::native("expected a sequence".to_string()))?; + match kind { + NumericKind::Int => { + let initial: i64 = if product { 1 } else { 0 }; + let value = values.try_fold(initial, |accumulator, value| { + let Value::Int(value) = value else { + return Err(VmError::native("expected a sequence of Int".to_string())); + }; + if product { + accumulator.checked_mul(value) + } else { + accumulator.checked_add(value) } - [left, right] => { - let l = left.to_int().ok_or_else(|| { - VmError::native(format!("expected int, got {}", left.static_type())) - })?; - let r = right.to_int().ok_or_else(|| { - VmError::native(format!("expected int, got {}", right.static_type())) - })?; - if r.is_zero() { - return Err(VmError::native("division by zero".to_string())); - } - Ok(Value::from_int(l % r)) + .ok_or_else(|| VmError::native("integer aggregate overflowed".to_string())) + })?; + Ok(Value::Int(value)) + } + NumericKind::Float => { + let initial = if product { 1.0 } else { 0.0 }; + let value = values.try_fold(initial, |accumulator, value| { + let Value::Float(value) = value else { + return Err(VmError::native("expected a sequence of Float".to_string())); + }; + Ok::<_, VmError>(if product { + accumulator * value + } else { + accumulator + value + }) + })?; + Ok(Value::Float(value)) + } + NumericKind::Number => { + let initial = AdvancedNumber::Int(Int::Int64(if product { 1 } else { 0 })); + let value = values.try_fold(initial, |accumulator, value| { + let Value::Number(value) = value else { + return Err(VmError::native("expected a sequence of Number".to_string())); + }; + if product { + accumulator.mul(value.as_ref().clone()) + } else { + accumulator.add(value.as_ref().clone()) } - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); - - // Float-specific overloads: operate directly on f64. - macro_rules! implement_binary_operator_on_float { - ($operator:literal, $op:expr, $docs:literal) => { - env.declare_global_fn(Rc::new(NativeFunction { - name: $operator.to_string(), - documentation: Some($docs.to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Float, StaticType::Float]), - return_type: Box::new(StaticType::Float), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [Value::Float(l), Value::Float(r)] => Ok(Value::Float($op(*l, *r))), - _ => Err(VmError::native(format!( - "expected 2 float arguments, got {}", - args.len() - ))), - })), - })); - }; + .map_err(native_error) + })?; + Ok(Value::from_number(value)) } + } +} + +#[derive(Clone, Copy)] +enum PreservingUnary { + Signum, + Ceil, + Floor, + Round, + Abs, +} - implement_binary_operator_on_float!("+", std::ops::Add::add, "Adds two floats."); - implement_binary_operator_on_float!("-", std::ops::Sub::sub, "Subtracts two floats."); - implement_binary_operator_on_float!("*", std::ops::Mul::mul, "Multiplies two floats."); - implement_binary_operator_on_float!("/", std::ops::Div::div, "Divides two floats."); - implement_binary_operator_on_float!( - "%", - std::ops::Rem::rem, - "Returns the remainder of dividing two floats." +fn register_number_helpers(env: &mut FunctionRegistry>) { + for kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + declare( + env, + "signum", + vec![kind.static_type()], + kind.static_type(), + "Returns the sign of a number.", + move |args| unary_preserving(args, kind, PreservingUnary::Signum), ); + for (name, operation) in [ + ("ceil", PreservingUnary::Ceil), + ("floor", PreservingUnary::Floor), + ("round", PreservingUnary::Round), + ("abs", PreservingUnary::Abs), + ] { + declare( + env, + name, + vec![kind.static_type()], + kind.static_type(), + "Applies a numeric operation while preserving the numeric mode.", + move |args| unary_preserving(args, kind, operation), + ); + } + } - env.declare_global_fn(Rc::new(NativeFunction { - name: "-".to_string(), - documentation: Some("Negates a number.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Number]), - return_type: Box::new(StaticType::Number), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [v] => v - .to_number() - .map(std::ops::Neg::neg) - .map(Value::from_number) - .ok_or_else(|| { - VmError::native(format!("expected number, got {}", v.static_type())) - }), - _ => Err(VmError::native(format!( - "expected 1 argument, got {}", - args.len() - ))), - })), - })); - - macro_rules! impl_cmp { - ($operator:literal,$expected:pat,$docs:literal) => { - env.declare_global_fn(Rc::new(NativeFunction { - name: $operator.to_string(), - documentation: Some($docs.to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Any, StaticType::Any]), - return_type: Box::new(StaticType::Bool), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [left, right] => match left.partial_cmp(right) { - Some($expected) => Ok(Value::Bool(true)), - Some(_) => Ok(Value::Bool(false)), - None => Err(VmError::native(format!( - "cannot compare {} and {}", - left.static_type(), - right.static_type() - ))), - }, - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); - }; + for left in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + for right in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + let output = result_kind(left, right); + declare( + env, + "abs_diff", + vec![left.static_type(), right.static_type()], + output.static_type(), + "Returns the absolute difference between two numbers.", + move |args| { + arity(args, 2)?; + let difference = eval_binary( + BinaryOperation::Sub, + left, + right, + output, + &args[0], + &args[1], + )?; + unary_preserving(&[difference], output, PreservingUnary::Abs) + }, + ); } + } - impl_cmp!( - ">", - Ordering::Greater, - "Returns true if the left value is greater than the right." - ); - impl_cmp!( - ">=", - Ordering::Greater | Ordering::Equal, - "Returns true if the left value is greater than or equal to the right." - ); - impl_cmp!( - "<", - Ordering::Less, - "Returns true if the left value is less than the right." - ); - impl_cmp!( - "<=", - Ordering::Less | Ordering::Equal, - "Returns true if the left value is less than or equal to the right." + for (name, imaginary) in [("real", false), ("imag", true)] { + declare( + env, + name, + vec![StaticType::Number], + StaticType::Number, + "Returns a component of an advanced number.", + move |args| { + let [Value::Number(value)] = args else { + return Err(VmError::native("expected one Number argument".to_string())); + }; + let component = match (value.as_ref(), imaginary) { + (AdvancedNumber::Complex(value), false) => AdvancedNumber::Float(value.re), + (AdvancedNumber::Complex(value), true) => AdvancedNumber::Float(value.im), + (_, false) => value.as_ref().clone(), + (_, true) => AdvancedNumber::Int(Int::Int64(0)), + }; + Ok(Value::from_number(component)) + }, ); + } - env.declare_global_fn(Rc::new(NativeFunction { - name: "==".to_string(), - documentation: Some("Returns true if two values are equal.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Any, StaticType::Any]), - return_type: Box::new(StaticType::Bool), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [left, right] => Ok(Value::Bool(left == right)), - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); - - env.declare_global_fn(Rc::new(NativeFunction { - name: "!=".to_string(), - documentation: Some("Returns true if two values are not equal.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Any, StaticType::Any]), - return_type: Box::new(StaticType::Bool), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [left, right] => Ok(Value::Bool(left != right)), - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); - - env.declare_global_fn(Rc::new(NativeFunction { - name: "<=>".to_string(), - documentation: Some("Three-way comparison (spaceship operator). Returns -1 if left < right, 0 if equal, 1 if left > right.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Any, StaticType::Any]), - return_type: Box::new(StaticType::Int), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [left, right] => match left.partial_cmp(right) { - Some(Ordering::Equal) => Ok(Value::Int(0)), - Some(Ordering::Less) => Ok(Value::Int(-1)), - Some(Ordering::Greater) => Ok(Value::Int(1)), - None => Err(VmError::native(format!( - "cannot compare {} and {}", - left.static_type(), - right.static_type() - ))), - }, - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); - - env.declare_global_fn(Rc::new(NativeFunction { - name: ">=<".to_string(), - documentation: Some("Reverse three-way comparison. Returns 1 if left < right, 0 if equal, -1 if left > right.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Any, StaticType::Any]), - return_type: Box::new(StaticType::Int), + for (name, numerator) in [("numerator", true), ("denominator", false)] { + declare( + env, + name, + vec![StaticType::Number], + StaticType::Number, + "Returns a component of an exact Number fraction.", + move |args| { + let [Value::Number(value)] = args else { + return Err(VmError::native("expected one Number argument".to_string())); + }; + let value = match value.as_ref() { + AdvancedNumber::Int(value) if numerator => AdvancedNumber::Int(value.clone()), + AdvancedNumber::Int(_) => AdvancedNumber::Int(Int::Int64(1)), + AdvancedNumber::Rational(value) if numerator => { + AdvancedNumber::Int(Int::BigInt(value.numer().clone()).simplified()) + } + AdvancedNumber::Rational(value) => { + AdvancedNumber::Int(Int::BigInt(value.denom().clone()).simplified()) + } + _ => { + return Err(VmError::native( + "expected an exact integer or rational Number".to_string(), + )); + } + }; + Ok(Value::from_number(value)) }, - func: NativeFunc::Simple(Box::new(|args| match args { - [left, right] => match left.partial_cmp(right) { - Some(Ordering::Equal) => Ok(Value::Int(0)), - Some(Ordering::Less) => Ok(Value::Int(1)), - Some(Ordering::Greater) => Ok(Value::Int(-1)), - None => Err(VmError::native(format!( - "cannot compare {} and {}", - left.static_type(), - right.static_type() - ))), - }, - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); - - macro_rules! impl_bitop { - ($operator:literal,$operation:expr,$docs_bool:literal,$docs_int:literal) => { - env.declare_global_fn(Rc::new(NativeFunction { - name: $operator.to_string(), - documentation: Some($docs_bool.to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Bool, StaticType::Bool]), - return_type: Box::new(StaticType::Bool), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [Value::Bool(l), Value::Bool(r)] => Ok(Value::Bool($operation(*l, *r))), - _ => Err(VmError::native(format!( - "expected 2 bool arguments, got {}", - args.len() - ))), - })), - })); - env.declare_global_fn(Rc::new(NativeFunction { - name: $operator.to_string(), - documentation: Some($docs_int.to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Int, StaticType::Int]), - return_type: Box::new(StaticType::Int), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [left, right] => { - let l = left.to_int().ok_or_else(|| { - VmError::native(format!("expected int, got {}", left.static_type())) - })?; - let r = right.to_int().ok_or_else(|| { - VmError::native(format!( - "expected int, got {}", - right.static_type() - )) - })?; - Ok(Value::from_int($operation(l, r))) - } - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); + ); + } +} + +fn unary_preserving( + args: &[Value], + kind: NumericKind, + operation: PreservingUnary, +) -> Result { + arity(args, 1)?; + match (kind, &args[0]) { + (NumericKind::Int, Value::Int(value)) => { + let result = match operation { + PreservingUnary::Signum => value.signum(), + PreservingUnary::Ceil | PreservingUnary::Floor | PreservingUnary::Round => *value, + PreservingUnary::Abs => value.checked_abs().ok_or_else(|| { + VmError::native("integer absolute value overflowed".to_string()) + })?, }; + Ok(Value::Int(result)) } + (NumericKind::Float, Value::Float(value)) => { + let result = match operation { + PreservingUnary::Signum => value.signum(), + PreservingUnary::Ceil => value.ceil(), + PreservingUnary::Floor => value.floor(), + PreservingUnary::Round => value.round(), + PreservingUnary::Abs => value.abs(), + }; + Ok(Value::Float(result)) + } + (NumericKind::Number, Value::Number(value)) => { + let result = match operation { + PreservingUnary::Signum => value.signum(), + PreservingUnary::Ceil => value.ceil(), + PreservingUnary::Floor => value.floor(), + PreservingUnary::Round => value.round(), + PreservingUnary::Abs => value.abs(), + }; + Ok(Value::from_number(result)) + } + _ => Err(VmError::native(format!( + "expected {}, got {}", + kind.static_type(), + args[0].static_type() + ))), + } +} - impl_bitop!( - "&", - std::ops::BitAnd::bitand, - "Logical AND of two booleans.", - "Bitwise AND of two integers." - ); - impl_bitop!( - "|", - std::ops::BitOr::bitor, - "Logical OR of two booleans.", - "Bitwise OR of two integers." +fn register_integer_helpers(env: &mut FunctionRegistry>) { + declare( + env, + "factorial", + vec![StaticType::Int], + StaticType::Int, + "Returns the checked factorial of a non-negative Int.", + |args| { + let [Value::Int(value)] = args else { + return Err(VmError::native("expected one Int argument".to_string())); + }; + if *value < 0 { + return Err(VmError::native( + "cannot compute the factorial of a negative number".to_string(), + )); + } + let result = (1..=*value) + .try_fold(1i64, i64::checked_mul) + .ok_or_else(|| { + VmError::native( + "integer factorial overflowed; use a Number argument".to_string(), + ) + })?; + Ok(Value::Int(result)) + }, + ); + declare( + env, + "factorial", + vec![StaticType::Number], + StaticType::Number, + "Returns the arbitrary-precision factorial of an exact non-negative Number.", + |args| { + let value = exact_number_integer(args)?; + let value = BigUint::try_from(value).map_err(|_error| { + VmError::native("cannot compute the factorial of a negative number".to_string()) + })?; + Ok(bigint_number(value.factorial().into())) + }, + ); + + for (name, operation) in [ + ( + "gcd", + (|left: &BigInt, right: &BigInt| left.gcd(right)) as fn(&BigInt, &BigInt) -> BigInt, + ), + ("lcm", |left: &BigInt, right: &BigInt| left.lcm(right)), + ] { + declare( + env, + name, + vec![StaticType::Int, StaticType::Int], + StaticType::Int, + "Computes an integer divisor operation with checked i64 output.", + move |args| { + let [Value::Int(left), Value::Int(right)] = args else { + return Err(VmError::native("expected two Int arguments".to_string())); + }; + operation(&BigInt::from(*left), &BigInt::from(*right)) + .to_i64() + .map(Value::Int) + .ok_or_else(|| VmError::native("integer result overflowed".to_string())) + }, ); - impl_bitop!( - "~", - std::ops::BitXor::bitxor, - "Logical XOR of two booleans.", - "Bitwise XOR of two integers." + declare( + env, + name, + vec![StaticType::Number, StaticType::Number], + StaticType::Number, + "Computes an arbitrary-precision exact integer divisor operation.", + move |args| { + arity(args, 2)?; + let left = exact_integer(&args[0])?; + let right = exact_integer(&args[1])?; + Ok(bigint_number(operation(&left, &right))) + }, ); + } +} - env.declare_global_fn(Rc::new(NativeFunction { - name: "~".to_string(), - documentation: Some("Bitwise NOT of a number.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Number]), - return_type: Box::new(StaticType::Number), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [v] => v - .to_number() - .map(Not::not) - .map(Value::from_number) - .ok_or_else(|| { - VmError::native(format!("expected number, got {}", v.static_type())) - }), - _ => Err(VmError::native(format!( - "expected 1 argument, got {}", - args.len() - ))), - })), - })); - - for ident in ["!", "not"] { - env.declare_global_fn(Rc::new(NativeFunction { - name: ident.to_string(), - documentation: Some( - "Logical negation. Returns the opposite boolean value.".to_string(), - ), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Bool]), - return_type: Box::new(StaticType::Bool), +fn exact_number_integer(args: &[Value]) -> Result { + arity(args, 1)?; + exact_integer(&args[0]) +} + +fn exact_integer(value: &Value) -> Result { + let Value::Number(value) = value else { + return Err(VmError::native( + "expected an exact integer Number".to_string(), + )); + }; + match value.as_ref() { + AdvancedNumber::Int(value) => Ok(value.to_bigint()), + AdvancedNumber::Rational(value) if value.is_integer() => Ok(value.to_integer()), + _ => Err(VmError::native( + "expected an exact integer Number".to_string(), + )), + } +} + +fn bigint_number(value: BigInt) -> Value { + Value::from_number(AdvancedNumber::Int(Int::BigInt(value).simplified())) +} + +fn register_conversions(env: &mut FunctionRegistry>) { + declare( + env, + "int", + vec![StaticType::Any], + StaticType::Int, + "Converts a value to a checked i64 Int.", + |args| { + arity(args, 1)?; + convert_to_int(&args[0]).map(Value::Int) + }, + ); + declare( + env, + "float", + vec![StaticType::Any], + StaticType::Float, + "Converts a value to a Float.", + |args| { + arity(args, 1)?; + convert_to_float(&args[0]).map(Value::Float) + }, + ); + + for left_kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + for right_kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + let output = result_kind(left_kind, right_kind); + let output = if output == NumericKind::Int { + NumericKind::Float + } else { + output + }; + declare( + env, + "atan2", + vec![left_kind.static_type(), right_kind.static_type()], + output.static_type(), + "Computes the four-quadrant arctangent of y and x.", + move |args| { + arity(args, 2)?; + if output == NumericKind::Number { + let left = promoted_number(left_kind, &args[0])?; + let right = promoted_number(right_kind, &args[1])?; + let left = left.to_f64().ok_or_else(|| { + VmError::native("atan2 requires real Number operands".to_string()) + })?; + let right = right.to_f64().ok_or_else(|| { + VmError::native("atan2 requires real Number operands".to_string()) + })?; + Ok(Value::from_number(AdvancedNumber::Float(left.atan2(right)))) + } else { + let left = primitive_float(left_kind, &args[0])?; + let right = primitive_float(right_kind, &args[1])?; + Ok(Value::Float(left.atan2(right))) + } }, - func: NativeFunc::Simple(Box::new(|args| match args { - [Value::Bool(b)] => Ok(Value::Bool(b.not())), - _ => Err(VmError::native(format!( - "expected 1 bool argument, got {}", - args.len() - ))), - })), - })); + ); } + } +} - env.declare_global_fn(Rc::new(NativeFunction { - name: ">>".to_string(), - documentation: Some("Right bit shift.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Int, StaticType::Int]), - return_type: Box::new(StaticType::Int), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [left, right] => { - let l = left.to_int().ok_or_else(|| { - VmError::native(format!("expected int, got {}", left.static_type())) - })?; - let r = right.to_int().ok_or_else(|| { - VmError::native(format!("expected int, got {}", right.static_type())) - })?; - l.checked_shr(r).map(Value::from_int).ok_or_else(|| { - VmError::native("cannot apply >> operator to operands".to_string()) - }) - } - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); - - env.declare_global_fn(Rc::new(NativeFunction { - name: "<<".to_string(), - documentation: Some("Left bit shift.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Int, StaticType::Int]), - return_type: Box::new(StaticType::Int), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [left, right] => { - let l = left.to_int().ok_or_else(|| { - VmError::native(format!("expected int, got {}", left.static_type())) - })?; - let r = right.to_int().ok_or_else(|| { - VmError::native(format!("expected int, got {}", right.static_type())) - })?; - l.checked_shl(r).map(Value::from_int).ok_or_else(|| { - VmError::native("cannot apply << operator to operands".to_string()) - }) - } - _ => Err(VmError::native(format!( - "expected 2 arguments, got {}", - args.len() - ))), - })), - })); - - macro_rules! delegate_to_f64 { - ($method:ident,$docs:literal) => { - env.declare_global_fn(Rc::new(NativeFunction { - name: stringify!($method).to_string(), - documentation: Some($docs.to_string()), - static_type: StaticType::Function { - parameters: Some(vec![StaticType::Number]), - return_type: Box::new(StaticType::Number), - }, - func: NativeFunc::Simple(Box::new(|args| match args { - [v] => v - .to_number() - .map(|num| match num { - Number::Int(i) => Number::Float(f64::from(i).$method()), - Number::Float(f) => Number::Float(f.$method()), - Number::Rational(r) => { - Number::Float(r.to_f64().unwrap_or(f64::NAN).$method()) - } - Number::Complex(c) => Number::Complex(c.$method()), - }) - .map(Value::from_number) - .ok_or_else(|| { - VmError::native(format!("expected number, got {}", v.static_type())) - }), - _ => Err(VmError::native(format!( - "expected 1 argument, got {}", - args.len() - ))), - })), - })); - }; +fn convert_to_int(value: &Value) -> Result { + let static_type = value.static_type(); + let converted = match value { + Value::Int(value) => return Ok(*value), + Value::Float(value) => float_to_i64(*value), + Value::Number(value) => match value.as_ref() { + AdvancedNumber::Int(value) => value.to_bigint().to_i64(), + AdvancedNumber::Float(value) => float_to_i64(*value), + AdvancedNumber::Rational(value) => value.to_integer().to_i64(), + AdvancedNumber::Complex(_) => None, + }, + Value::Bool(value) => return Ok(if *value { 1 } else { 0 }), + Value::Object(value) => match value.as_ref() { + Object::String(value) => return value.borrow().parse::().map_err(native_error), + _ => None, + }, + Value::None => None, + }; + converted.ok_or_else(|| VmError::native(format!("cannot convert {static_type} to Int"))) +} + +fn float_to_i64(value: f64) -> Option { + if value.is_finite() { + BigInt::from_f64(value.trunc())?.to_i64() + } else { + None + } +} + +fn convert_to_float(value: &Value) -> Result { + match value { + Value::Int(value) => Ok(*value as f64), + Value::Float(value) => Ok(*value), + Value::Number(value) => value + .to_f64() + .ok_or_else(|| VmError::native("cannot convert a complex Number to Float".to_string())), + Value::Bool(value) => Ok(if *value { 1.0 } else { 0.0 }), + Value::Object(value) => match value.as_ref() { + Object::String(value) => value.borrow().parse::().map_err(native_error), + _ => Err(VmError::native("cannot convert value to Float".to_string())), + }, + Value::None => Err(VmError::native("cannot convert None to Float".to_string())), + } +} + +#[derive(Clone, Copy)] +enum Transcendental { + Acos, + Acosh, + Asin, + Asinh, + Atan, + Atanh, + Cbrt, + Cos, + Exp, + Ln, + Log2, + Log10, + Sin, + Sqrt, + Tan, + Tanh, +} + +impl Transcendental { + fn name(self) -> &'static str { + match self { + Self::Acos => "acos", + Self::Acosh => "acosh", + Self::Asin => "asin", + Self::Asinh => "asinh", + Self::Atan => "atan", + Self::Atanh => "atanh", + Self::Cbrt => "cbrt", + Self::Cos => "cos", + Self::Exp => "exp", + Self::Ln => "ln", + Self::Log2 => "log2", + Self::Log10 => "log10", + Self::Sin => "sin", + Self::Sqrt => "sqrt", + Self::Tan => "tan", + Self::Tanh => "tanh", } + } + + fn apply_float(self, value: f64) -> f64 { + match self { + Self::Acos => value.acos(), + Self::Acosh => value.acosh(), + Self::Asin => value.asin(), + Self::Asinh => value.asinh(), + Self::Atan => value.atan(), + Self::Atanh => value.atanh(), + Self::Cbrt => value.cbrt(), + Self::Cos => value.cos(), + Self::Exp => value.exp(), + Self::Ln => value.ln(), + Self::Log2 => value.log2(), + Self::Log10 => value.log10(), + Self::Sin => value.sin(), + Self::Sqrt => value.sqrt(), + Self::Tan => value.tan(), + Self::Tanh => value.tanh(), + } + } - delegate_to_f64!( - acos, - "Computes the arccosine of a number. Return value is in radians in the range [0, pi] or NaN if the number is outside the range [-1, 1]." + fn apply_complex(self, value: Complex64) -> Complex64 { + match self { + Self::Acos => value.acos(), + Self::Acosh => value.acosh(), + Self::Asin => value.asin(), + Self::Asinh => value.asinh(), + Self::Atan => value.atan(), + Self::Atanh => value.atanh(), + Self::Cbrt => value.powf(1.0 / 3.0), + Self::Cos => value.cos(), + Self::Exp => value.exp(), + Self::Ln => value.ln(), + Self::Log2 => value.ln() / std::f64::consts::LN_2, + Self::Log10 => value.ln() / std::f64::consts::LN_10, + Self::Sin => value.sin(), + Self::Sqrt => value.sqrt(), + Self::Tan => value.tan(), + Self::Tanh => value.tanh(), + } + } +} + +fn register_transcendentals(env: &mut FunctionRegistry>) { + const FUNCTIONS: [Transcendental; 16] = [ + Transcendental::Acos, + Transcendental::Acosh, + Transcendental::Asin, + Transcendental::Asinh, + Transcendental::Atan, + Transcendental::Atanh, + Transcendental::Cbrt, + Transcendental::Cos, + Transcendental::Exp, + Transcendental::Ln, + Transcendental::Log2, + Transcendental::Log10, + Transcendental::Sin, + Transcendental::Sqrt, + Transcendental::Tan, + Transcendental::Tanh, + ]; + + for function in FUNCTIONS { + declare( + env, + function.name(), + vec![StaticType::Int], + StaticType::Float, + "Applies a transcendental function and returns a Float.", + move |args| { + let [Value::Int(value)] = args else { + return Err(VmError::native("expected one Int argument".to_string())); + }; + Ok(Value::Float(function.apply_float(*value as f64))) + }, ); - delegate_to_f64!(acosh, "Inverse hyperbolic cosine function."); - delegate_to_f64!( - asin, - "Computes the arcsine of a number. Return value is in radians in the range [-pi/2, pi/2] or NaN if the number is outside the range [-1, 1]." + declare( + env, + function.name(), + vec![StaticType::Float], + StaticType::Float, + "Applies a transcendental function to a Float.", + move |args| { + let [Value::Float(value)] = args else { + return Err(VmError::native("expected one Float argument".to_string())); + }; + Ok(Value::Float(function.apply_float(*value))) + }, ); - delegate_to_f64!(asinh, "Inverse hyperbolic sine function."); - delegate_to_f64!( - atan, - "Computes the arctangent of a number. Return value is in radians in the range [-pi/2, pi/2]." + declare( + env, + function.name(), + vec![StaticType::Number], + StaticType::Number, + "Applies a transcendental function with complex continuation.", + move |args| { + let [Value::Number(value)] = args else { + return Err(VmError::native("expected one Number argument".to_string())); + }; + let result = match value.as_ref() { + AdvancedNumber::Complex(value) => { + AdvancedNumber::Complex(function.apply_complex(*value)) + } + value => { + let input = value.to_f64().expect("non-complex Number is real"); + let result = function.apply_float(input); + if result.is_nan() && !input.is_nan() { + AdvancedNumber::Complex( + function.apply_complex(Complex64::new(input, 0.0)), + ) + } else { + AdvancedNumber::Float(result) + } + } + }; + Ok(Value::from_number(result)) + }, ); - delegate_to_f64!(atanh, "Inverse hyperbolic tangent function."); - delegate_to_f64!(cbrt, "Returns the cube root of a number."); - delegate_to_f64!(cos, "Computes the cosine of a number (in radians)."); - delegate_to_f64!(exp, "Returns `e^(arg)`, (the exponential function)."); - delegate_to_f64!(ln, "Returns the natural logarithm of the number."); - delegate_to_f64!(log2, "Returns the base 2 logarithm of the number."); - delegate_to_f64!(log10, "Returns the base 10 logarithm of the number."); - delegate_to_f64!(sin, "Computes the sine of a number (in radians)."); - delegate_to_f64!(sqrt, "Returns the square root of a number."); - delegate_to_f64!(tan, "Computes the tangent of a number (in radians)."); - delegate_to_f64!(tanh, "Hyperbolic tangent function."); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_arithmetic_operator_has_a_three_by_three_numeric_matrix() { + let mut registry = FunctionRegistry::default(); + register(&mut registry); + + let kinds = [StaticType::Int, StaticType::Float, StaticType::Number]; + for operator in ["+", "-", "*", "/", "\\", "%", "%%", "^"] { + for left in &kinds { + for right in &kinds { + let expected_return = if matches!(left, StaticType::Number) + || matches!(right, StaticType::Number) + { + StaticType::Number + } else if matches!(left, StaticType::Float) + || matches!(right, StaticType::Float) + { + StaticType::Float + } else { + StaticType::Int + }; + + assert!(registry.iter().any(|function| { + function.name == operator + && function.static_type + == StaticType::Function { + parameters: Some(vec![left.clone(), right.clone()]), + return_type: Box::new(expected_return.clone()), + } + })); + } + } + } } } diff --git a/ndc_stdlib/src/rand.rs b/ndc_stdlib/src/rand.rs index b0394c90..5eac6147 100644 --- a/ndc_stdlib/src/rand.rs +++ b/ndc_stdlib/src/rand.rs @@ -22,7 +22,7 @@ pub fn random_n( #[export_module] mod inner { use itertools::Itertools; - use ndc_core::num::Number; + use ndc_core::num::AdvancedNumber; /// Randomly shuffles the elements of the list in place. pub fn shuffle(list: &mut [Value]) { @@ -50,13 +50,13 @@ mod inner { #[function(name = "randf")] /// Generate a random number between 0 (inclusive) and `upper` (exclusive) - pub fn randf_1(upper: &Number) -> anyhow::Result { + pub fn randf_1(upper: &AdvancedNumber) -> anyhow::Result { random_n(0.0, upper.try_into()?) } #[function(name = "randf")] /// Generate a random number between `lower` (inclusive) and `upper` (exclusive) - pub fn randf_2(lower: &Number, upper: &Number) -> anyhow::Result { + pub fn randf_2(lower: &AdvancedNumber, upper: &AdvancedNumber) -> anyhow::Result { random_n(lower.try_into()?, upper.try_into()?) } @@ -68,13 +68,13 @@ mod inner { #[function(name = "randi")] /// Generate a random number between 0 (inclusive) and `upper` (exclusive) - pub fn randi_1(upper: &Number) -> anyhow::Result { + pub fn randi_1(upper: &AdvancedNumber) -> anyhow::Result { random_n(0, upper.try_into()?) } #[function(name = "randi")] /// Generate a random number between `lower` (inclusive) and `upper` (exclusive) - pub fn randi_2(lower: &Number, upper: &Number) -> anyhow::Result { + pub fn randi_2(lower: &AdvancedNumber, upper: &AdvancedNumber) -> anyhow::Result { random_n(lower.try_into()?, upper.try_into()?) } } diff --git a/ndc_stdlib/src/serde.rs b/ndc_stdlib/src/serde.rs index bb054e9d..2da22c73 100644 --- a/ndc_stdlib/src/serde.rs +++ b/ndc_stdlib/src/serde.rs @@ -1,5 +1,7 @@ use anyhow::{Context, bail}; use ndc_core::hash_map::HashMap; +use ndc_core::int::Int; +use ndc_core::num::AdvancedNumber; use ndc_macros::export_module; use ndc_vm::value::{Object, Value}; use num::ToPrimitive; @@ -32,6 +34,7 @@ fn value_to_json( Value::Float(f) if f.is_finite() => Ok(json!(f)), Value::Float(_) if lossy => Ok(JsonValue::Null), Value::Float(f) => bail!("cannot convert non-finite float {f} to JSON"), + Value::Number(number) => advanced_number_to_json(number, lossy), Value::Object(obj) => { // Only these variants have interior mutability through which a // value can contain itself; all other variants are leaves or @@ -58,6 +61,27 @@ fn value_to_json( } } +fn advanced_number_to_json( + number: &AdvancedNumber, + lossy: bool, +) -> Result { + match number { + AdvancedNumber::Int(Int::Int64(i)) => Ok(json!(i)), + AdvancedNumber::Int(Int::BigInt(big_int)) => Number::from_str(&big_int.to_string()) + .map(JsonValue::Number) + .context("cannot convert bigint to JSON number"), + AdvancedNumber::Float(f) if f.is_finite() => Ok(json!(f)), + AdvancedNumber::Float(_) if lossy => Ok(JsonValue::Null), + AdvancedNumber::Float(f) => bail!("cannot convert non-finite float {f} to JSON"), + AdvancedNumber::Rational(ratio) if lossy => Ok(json!(ratio.to_f64())), + AdvancedNumber::Rational(_) => { + bail!("cannot convert a rational number to JSON, convert it to a float first") + } + AdvancedNumber::Complex(complex) if lossy => Ok(json!(format!("{complex}"))), + AdvancedNumber::Complex(_) => bail!("cannot convert a complex number to JSON"), + } +} + fn object_to_json( obj: &Rc, lossy: bool, @@ -72,15 +96,6 @@ fn object_to_json( }; match obj.as_ref() { - Object::BigInt(big_int) => Number::from_str(&big_int.to_string()) - .map(JsonValue::Number) - .context("cannot convert bigint to JSON number"), - Object::Rational(ratio) if lossy => Ok(json!(ratio.to_f64())), - Object::Rational(_) => { - bail!("cannot convert a rational number to JSON, convert it to a float first") - } - Object::Complex(complex) if lossy => Ok(json!(format!("{complex}"))), - Object::Complex(_) => bail!("cannot convert a complex number to JSON"), Object::Some(inner) if lossy => value_to_json(inner, lossy, active), Object::Some(_) => bail!("cannot convert an option to JSON, unwrap it first"), Object::Iterator(i) if lossy => { diff --git a/ndc_vm/Cargo.toml b/ndc_vm/Cargo.toml index 0bc05aa4..a3d1a268 100644 --- a/ndc_vm/Cargo.toml +++ b/ndc_vm/Cargo.toml @@ -12,5 +12,4 @@ ndc_core.workspace = true ndc_lexer.workspace = true ndc_parser.workspace = true num.workspace = true -ordered-float.workspace = true thiserror.workspace = true diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index 8341a122..f6dd1961 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -224,6 +224,20 @@ impl Compiler { let idx = self.ir.add_constant(Value::bigint(i)); self.ir.write(OpCode::Constant(idx), span); } + Expression::NumberIntLiteral(i) => { + let idx = self + .ir + .add_constant(Value::number(ndc_core::num::AdvancedNumber::Int( + ndc_core::int::Int::BigInt(i).simplified(), + ))); + self.ir.write(OpCode::Constant(idx), span); + } + Expression::NumberFloatLiteral(f) => { + let idx = self + .ir + .add_constant(Value::number(ndc_core::num::AdvancedNumber::Float(f))); + self.ir.write(OpCode::Constant(idx), span); + } Expression::ComplexLiteral(c) => { let idx = self.ir.add_constant(Value::complex(c)); self.ir.write(OpCode::Constant(idx), span); diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index bb44dd62..ed093a2a 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -7,10 +7,9 @@ use ndc_core::StaticType; use ndc_core::compare::FallibleOrd; use ndc_core::hash_map::{DefaultHasher, HashMap}; use ndc_core::int::Int; -use ndc_core::num::Number; +use ndc_core::num::AdvancedNumber; use ndc_core::r#struct::StructInfo; use ndc_parser::ResolvedVar; -use ordered_float::OrderedFloat; use std::cell::RefCell; use std::cmp::{Ordering, Reverse}; use std::collections::BinaryHeap; @@ -54,6 +53,7 @@ fn bounded_element_type<'a>( pub enum Value { Int(i64), Float(f64), + Number(Rc), Bool(bool), None, Object(Rc), @@ -62,9 +62,6 @@ pub enum Value { #[derive(Clone)] pub enum Object { Some(Value), - BigInt(num::BigInt), - Complex(num::Complex), - Rational(num::BigRational), String(Rc>), List(RefCell>), Tuple(Vec), @@ -200,18 +197,21 @@ impl Value { } pub fn bigint(i: num::BigInt) -> Self { - Self::Object(Rc::new(Object::BigInt(i))) + Self::number(AdvancedNumber::Int(Int::BigInt(i))) } pub fn complex(c: num::complex::Complex64) -> Self { - Self::Object(Rc::new(Object::Complex(c))) + Self::number(AdvancedNumber::Complex(c)) + } + + pub fn number(number: AdvancedNumber) -> Self { + Self::Number(Rc::new(number)) } /// Creates a shallow copy: scalars are copied by value; mutable collection /// types (String, List, Map, Deque, heaps, structs) get new independent /// containers with cloned contents; immutable / identity types (Tuple, - /// Function, Iterator, `BigInt`, Rational, Complex, Some, `OverloadSet`) - /// share the Rc. + /// Function, Iterator, Some, `OverloadSet`) share the Rc. pub fn shallow_clone(&self) -> Self { match self { Self::Object(obj) => match obj.as_ref() { @@ -285,25 +285,19 @@ impl Value { match self { Self::Int(_) => StaticType::Int, Self::Float(_) => StaticType::Float, + Self::Number(_) => StaticType::Number, Self::Bool(_) => StaticType::Bool, Self::None => StaticType::Option(Box::new(StaticType::Any)), Self::Object(obj) => obj.static_type_with_budget(depth, budget), } } - /// Returns `true` if this value is a numeric type (Int, Float, Rational, or Complex). + /// Returns `true` if this value is an Int, Float, or Number. /// /// Prefer this over `self.static_type().is_number()` in hot paths — this is O(1) /// and never allocates, whereas `static_type()` on containers is O(n). pub fn is_number(&self) -> bool { - match self { - Self::Int(_) | Self::Float(_) => true, - Self::Object(obj) => matches!( - obj.as_ref(), - Object::BigInt(_) | Object::Rational(_) | Object::Complex(_) - ), - _ => false, - } + matches!(self, Self::Int(_) | Self::Float(_) | Self::Number(_)) } /// Check whether this value satisfies a function parameter type at runtime, @@ -318,11 +312,10 @@ impl Value { StaticType::Any => true, StaticType::Int => { matches!(self, Self::Int(_)) - || matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::BigInt(_))) } StaticType::Float => matches!(self, Self::Float(_)), StaticType::Bool => matches!(self, Self::Bool(_)), - StaticType::Number => self.is_number(), + StaticType::Number => matches!(self, Self::Number(_)), StaticType::String => { matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::String(_))) } @@ -561,33 +554,32 @@ impl Value { obj.function_prototype() } - /// Convert a numeric VM value to a `ndc_core::Number`. - /// Returns `None` for non-numeric values (Bool, None, String, List, …). - pub fn to_number(&self) -> Option { - vm_value_to_number(self) + /// Clone the payload of a Number value. + pub fn to_number(&self) -> Option { + self.as_number().cloned() } - /// Convert a `ndc_core::Number` to a VM value. - /// `Int64` maps to `Value::Int`; all other variants become `Value::Object`. - pub fn from_number(n: Number) -> Self { - match n { - Number::Int(Int::Int64(i)) => Self::Int(i), - Number::Int(Int::BigInt(b)) => Self::Object(Rc::new(Object::BigInt(b))), - Number::Float(f) => Self::Float(f), - Number::Rational(r) => Self::Object(Rc::new(Object::Rational(*r))), - Number::Complex(c) => Self::Object(Rc::new(Object::Complex(c))), + pub fn as_number(&self) -> Option<&AdvancedNumber> { + match self { + Self::Number(number) => Some(number.as_ref()), + _ => None, } } + pub fn to_advanced_number(&self) -> Option { + vm_value_to_number(self) + } + + /// Wrap an advanced numeric payload as a Number value. + pub fn from_number(n: AdvancedNumber) -> Self { + Self::number(n) + } + /// Extract an integer VM value as a `ndc_core::Int`. /// Returns `None` for non-integer values. pub fn to_int(&self) -> Option { match self { Self::Int(i) => Some(Int::Int64(*i)), - Self::Object(obj) => match obj.as_ref() { - Object::BigInt(b) => Some(Int::BigInt(b.clone())), - _ => None, - }, _ => None, } } @@ -596,7 +588,7 @@ impl Value { pub fn from_int(i: Int) -> Self { match i { Int::Int64(n) => Self::Int(n), - Int::BigInt(b) => Self::Object(Rc::new(Object::BigInt(b))), + Int::BigInt(b) => Self::number(AdvancedNumber::Int(Int::BigInt(b))), } } @@ -607,11 +599,7 @@ impl Value { match self { Self::Float(f) => Some(*f), Self::Int(i) => i.to_f64(), - Self::Object(obj) => match obj.as_ref() { - Object::BigInt(b) => b.to_f64(), - Object::Rational(r) => r.to_f64(), - _ => None, - }, + Self::Number(number) => number.to_f64(), _ => None, } } @@ -693,9 +681,6 @@ impl Object { } StaticType::Option(Box::new(inner.static_type_with_budget(depth - 1, budget))) } - Self::BigInt(_) => StaticType::Int, - Self::Complex(_) => StaticType::Complex, - Self::Rational(_) => StaticType::Rational, Self::String(_) => StaticType::String, Self::List(elements) => { if depth == 0 { @@ -792,6 +777,7 @@ impl fmt::Display for Value { match self { Self::Int(n) => write!(f, "{n}"), Self::Float(n) => write!(f, "{n}"), + Self::Number(n) => write!(f, "{n}"), Self::Bool(b) => write!(f, "{b}"), Self::None => write!(f, "None"), Self::Object(obj) => write!(f, "{obj}"), @@ -803,9 +789,6 @@ impl fmt::Display for Object { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { Self::Some(v) => write!(f, "Some({v})"), - Self::BigInt(n) => write!(f, "{n}"), - Self::Complex(c) => write!(f, "{c}"), - Self::Rational(r) => write!(f, "{r}"), // Strings display without quotes at the top level. Self::String(s) => write!(f, "{}", s.borrow()), Self::List(vs) => { @@ -893,9 +876,6 @@ impl fmt::Debug for Object { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { Self::Some(v) => f.debug_tuple("Some").field(v).finish(), - Self::BigInt(n) => f.debug_tuple("BigInt").field(n).finish(), - Self::Complex(c) => f.debug_tuple("Complex").field(c).finish(), - Self::Rational(r) => f.debug_tuple("Rational").field(r).finish(), // Strings in debug/repr context are shown quoted. Self::String(s) => write!(f, "\"{}\"", s.borrow()), Self::List(vs) => f.debug_tuple("List").field(&vs.borrow()).finish(), @@ -935,48 +915,25 @@ impl fmt::Debug for Object { impl PartialOrd for Value { fn partial_cmp(&self, other: &Self) -> Option { + if self.is_number() && other.is_number() { + return vm_value_to_number(self)?.partial_cmp(&vm_value_to_number(other)?); + } + match (self, other) { - // Same-type fast paths - (Self::Int(a), Self::Int(b)) => Some(a.cmp(b)), - (Self::Float(a), Self::Float(b)) => OrderedFloat(*a).partial_cmp(&OrderedFloat(*b)), (Self::Bool(a), Self::Bool(b)) => a.partial_cmp(b), (Self::Object(a), Self::Object(b)) => a.partial_cmp(b), - // Cross-type int/float fast paths (avoid BigInt allocation) - (Self::Float(a), Self::Int(b)) => { - OrderedFloat(*a).partial_cmp(&OrderedFloat(*b as f64)) - } - (Self::Int(a), Self::Float(b)) => { - OrderedFloat(*a as f64).partial_cmp(&OrderedFloat(*b)) - } - // Any numeric cross-type comparison (Int/Float vs BigInt/Rational/Complex etc.) - // delegates to ndc_core::Number which handles all cases the interpreter does. - (a, b) => vm_value_to_number(a)?.partial_cmp(&vm_value_to_number(b)?), + _ => None, } } } -/// Convert a VM numeric value to a `ndc_core::Number` for cross-type comparison. +/// Convert a VM numeric value to an `AdvancedNumber` for cross-type comparison. /// Returns `None` for non-numeric values (Bool, None, String, List, …). -fn vm_value_to_number(v: &Value) -> Option { +fn vm_value_to_number(v: &Value) -> Option { match v { - Value::Int(i) => Some(Number::Int(Int::Int64(*i))), - Value::Float(f) => Some(Number::Float(*f)), - Value::Object(obj) => match obj.as_ref() { - Object::BigInt(b) => Some(Number::Int(Int::BigInt(b.clone()))), - Object::Rational(r) => Some(Number::Rational(Box::new(r.clone()))), - Object::Complex(c) => Some(Number::Complex(*c)), - _ => None, - }, - _ => None, - } -} - -/// Convert an `Object` numeric value to a `ndc_core::Number` for cross-type comparison. -fn obj_to_number(obj: &Object) -> Option { - match obj { - Object::BigInt(b) => Some(Number::Int(Int::BigInt(b.clone()))), - Object::Rational(r) => Some(Number::Rational(Box::new(r.clone()))), - Object::Complex(c) => Some(Number::Complex(*c)), + Value::Int(i) => Some(AdvancedNumber::Int(Int::Int64(*i))), + Value::Float(f) => Some(AdvancedNumber::Float(*f)), + Value::Number(number) => Some(number.as_ref().clone()), _ => None, } } @@ -984,13 +941,10 @@ fn obj_to_number(obj: &Object) -> Option { impl PartialOrd for Object { fn partial_cmp(&self, other: &Self) -> Option { match (self, other) { - (Self::BigInt(a), Self::BigInt(b)) => a.partial_cmp(b), - (Self::Rational(a), Self::Rational(b)) => a.partial_cmp(b), (Self::String(a), Self::String(b)) => a.borrow().partial_cmp(&*b.borrow()), (Self::List(a), Self::List(b)) => a.borrow().partial_cmp(&*b.borrow()), (Self::Tuple(a), Self::Tuple(b)) => a.partial_cmp(b), - // Cross-type numeric: BigInt vs Rational, BigInt vs Complex, Rational vs Complex, etc. - (a, b) => obj_to_number(a)?.partial_cmp(&obj_to_number(b)?), + _ => None, } } } @@ -1018,9 +972,11 @@ impl Value { Self::Float(f) => f .partial_cmp(&0.0) .ok_or_else(|| "NaN in comparator result".to_string()), - Self::Object(obj) => match obj.as_ref() { - Object::BigInt(b) => Ok(b.cmp(&num::BigInt::from(0))), - Object::Rational(r) => Ok(r.cmp(&num::BigRational::from(num::BigInt::from(0)))), + Self::Number(number) => match number.as_ref() { + AdvancedNumber::Int(i) => Ok(i.cmp(&Int::Int64(0))), + AdvancedNumber::Rational(r) => Ok(r + .as_ref() + .cmp(&num::BigRational::from(num::BigInt::from(0)))), _ => Err(format!( "comparator must return a number, got {}", self.static_type() @@ -1036,18 +992,18 @@ impl Value { impl PartialEq for Value { fn eq(&self, other: &Self) -> bool { + if self.is_number() && other.is_number() { + return match (vm_value_to_number(self), vm_value_to_number(other)) { + (Some(left), Some(right)) => left == right, + _ => false, + }; + } + match (self, other) { - (Self::Int(a), Self::Int(b)) => a == b, - (Self::Float(a), Self::Float(b)) => OrderedFloat(*a) == OrderedFloat(*b), (Self::Bool(a), Self::Bool(b)) => a == b, (Self::None, Self::None) => true, (Self::Object(a), Self::Object(b)) => a == b, - // Cross-type numeric equality: delegate to Number, consistent with PartialOrd. - // Covers Int vs Float, Int vs Rational, Int vs BigInt, Float vs Rational, etc. - (a, b) => match (vm_value_to_number(a), vm_value_to_number(b)) { - (Some(a), Some(b)) => a == b, - _ => false, - }, + _ => false, } } } @@ -1056,22 +1012,13 @@ impl Eq for Value {} impl Hash for Value { fn hash(&self, state: &mut H) { + if let Some(number) = vm_value_to_number(self) { + state.write_u8(1); + number.hash(state); + return; + } + match self { - Self::Int(n) => { - state.write_u8(1); - n.hash(state); - } - Self::Float(f) => { - // Normalise whole-number floats to the same hash as their integer equivalent, - // so that Int(1) and Float(1.0) hash identically (consistent with PartialEq). - if let Some(i) = Int::from_f64_if_int(*f) { - state.write_u8(1); - i.hash(state); - } else { - state.write_u8(2); - OrderedFloat(*f).hash(state); - } - } Self::Bool(true) => state.write_u8(3), Self::Bool(false) => state.write_u8(4), Self::None => state.write_u8(5), @@ -1079,6 +1026,7 @@ impl Hash for Value { state.write_u8(6); o.hash(state); } + Self::Int(_) | Self::Float(_) | Self::Number(_) => unreachable!("handled above"), } } } @@ -1139,12 +1087,7 @@ impl PartialEq for Object { // address is equivalent to comparing the outer Rc pointers. (Self::MinHeap(a), Self::MinHeap(b)) => std::ptr::eq(a, b), (Self::MaxHeap(a), Self::MaxHeap(b)) => std::ptr::eq(a, b), - // Numeric types: delegate to Number for cross-type equality - // (e.g. BigInt(5) == Rational(5/1), Rational(5/1) == Complex(5+0i)). - (a, b) => match (obj_to_number(a), obj_to_number(b)) { - (Some(a), Some(b)) => a == b, - _ => false, - }, + _ => false, } } } @@ -1158,19 +1101,6 @@ impl Hash for Object { state.write_u8(1); v.hash(state); } - Self::BigInt(n) => { - state.write_u8(2); - n.hash(state); - } - Self::Complex(c) => { - state.write_u8(3); - c.re.to_bits().hash(state); - c.im.to_bits().hash(state); - } - Self::Rational(r) => { - state.write_u8(4); - r.hash(state); - } Self::String(s) => { state.write_u8(5); s.borrow().hash(state); diff --git a/tests/functional/programs/001_math/004_integer_division.ndc b/tests/functional/programs/001_math/004_integer_division.ndc index ff45227b..84c0c1c4 100644 --- a/tests/functional/programs/001_math/004_integer_division.ndc +++ b/tests/functional/programs/001_math/004_integer_division.ndc @@ -1,6 +1,6 @@ assert_eq(10 / 2, 5); assert_eq(10 \ 2, 5); -assert_eq(10 / 3, 10 / 3); +assert_eq(10 / 3, 3); -assert_eq((10 / 3).denominator, 3); -assert_eq((10 / 3).numerator, 10); +assert_eq((10n / 3n).denominator, 3n); +assert_eq((10n / 3n).numerator, 10n); diff --git a/tests/functional/programs/001_math/010_rational_numbers.ndc b/tests/functional/programs/001_math/010_rational_numbers.ndc index ed9f6e72..328469bc 100644 --- a/tests/functional/programs/001_math/010_rational_numbers.ndc +++ b/tests/functional/programs/001_math/010_rational_numbers.ndc @@ -1 +1,2 @@ -assert_eq(123 ^ 1234, 87681408214164476970365205228763109219898888878881068771208558661744652944973794097995863371113304803648974825541467427939839526377710059863431476309143764813924927562640894119948501580418029986500720998388787813370525431166848874516198653313025239228397155448068660083441510113309297213377130812540882569036330888360587252320502710668238917924819256177085547151628452956575620209396410425802528274173423237198788850317119387183597639340271827009446479814212636428433508905393502570143050733004341365267464673083511407846749856768559082044756465557382458441298173247590732260532362837781261273044790732964572472906028786735769068002908334950629529238494229388773631509587346276421451934859511090185201687935820179348745078114760498326353080922545842429714078866368666174811436292368322013344004655977058760977542054124836643126063114763695548545776130407210820780894324241690914009874920841120259000224443381313232432773323198180041283907691642196083913253441176164973189724729319313191910270523504758501858336459076757618992839333332511497597480067748947646390153654975397979573562246663761509470719229703024274190910777440709851016954053496016760952179721806135963774666054115617271706189246258265115943067237016490399563635443442846739014850282195618521120535565188025906868874107944865094967772873343090181992187951403546897725021536947680972091031688467020738605076457274796500971999661728798303030972853189743552799579684844828491444308484526518267218257921241625499052436609254699010727463618576326919716488239497546381103193111733548334284388280545773071212732103370578217302060632234196305992004053401681566112227114812691817085587946583104659271921923562874223100900959335802212944702005164243039866416649440564428814269313203471855669910522511335546803534892285510883626302419238150232750213370699115557243071043712094583781097657284873633992611116149199287419640896041431121935521412322305706094971610821567279856521112109486451031119237844839149647788096234240268663046528172307744046671563379062495488515946010782381404515931631412299705016057392361243778777836765937047145439248470525821254187111275601658651848526621397680714909511070312403055710824426638837020346610435667546953236125866025757795263762436864502672294404692055020881153207578385898388026761498803446534437001436910282915105450240960086694231786588797080733683534366526961049764901226371507061043411117058668037169788357610458683641714305519588901316259335868882461093614111174112266777064725000599519534220824847284140314691213633043592112726822825400146132529759565779050658199333210486870286809); +assert_eq(2n ^ 100n, 1267650600228229401496703205376n); +assert_eq(10n / 4n, 5n / 2n); diff --git a/tests/functional/programs/001_math/013_negative_integer_exponents.ndc b/tests/functional/programs/001_math/013_negative_integer_exponents.ndc index 0ff1e885..73ef034e 100644 --- a/tests/functional/programs/001_math/013_negative_integer_exponents.ndc +++ b/tests/functional/programs/001_math/013_negative_integer_exponents.ndc @@ -1 +1 @@ -assert_eq(5 ^ -1, 1/5); +assert_eq(5n ^ -1n, 1n/5n); diff --git a/tests/functional/programs/001_math/014_exponentiation_right_associative.ndc b/tests/functional/programs/001_math/014_exponentiation_right_associative.ndc index d33bd3ef..4e379971 100644 --- a/tests/functional/programs/001_math/014_exponentiation_right_associative.ndc +++ b/tests/functional/programs/001_math/014_exponentiation_right_associative.ndc @@ -1,2 +1,2 @@ // Exponentiation is defined as being right associative -assert_eq(2 ^ 3 ^ 4, 2417851639229258349412352); +assert_eq(2n ^ 3n ^ 4n, 2417851639229258349412352n); diff --git a/tests/functional/programs/001_math/019_bit_operations.ndc b/tests/functional/programs/001_math/019_bit_operations.ndc index 1b29031c..1c6dabf0 100644 --- a/tests/functional/programs/001_math/019_bit_operations.ndc +++ b/tests/functional/programs/001_math/019_bit_operations.ndc @@ -26,6 +26,5 @@ assert(~0 == -1); assert(~1 == -2); assert(~1234 == ~~-1235); // LOL -// bigintegers -assert((2^65) | (2^65) == (2^65)); - +// Int bit operations stay within the primitive mode. +assert((2^62) | (2^62) == (2^62)); diff --git a/tests/functional/programs/001_math/022_spaceship.ndc b/tests/functional/programs/001_math/022_spaceship.ndc index 3ae5ae8c..3051e476 100644 --- a/tests/functional/programs/001_math/022_spaceship.ndc +++ b/tests/functional/programs/001_math/022_spaceship.ndc @@ -5,9 +5,9 @@ assert_eq(5 <=> 6, -1); assert_eq(6 <=> 5, 1); assert_eq(5 <=> 5, 0); assert_eq(9223372036854775807 <=> 9223372036854775806, 1); // i64 max -assert_eq(-9223372036854775808 <=> -9223372036854775807, -1); // i64 min -assert_eq(9223372036854775808 <=> -9223372036854775808, 1); // Outside i64 -assert_eq(-9223372036854775809 <=> 9223372036854775807, -1); +assert_eq((-9223372036854775807 - 1) <=> -9223372036854775807, -1); // i64 min +assert_eq(9223372036854775808n <=> -9223372036854775808n, 1); // Outside i64 +assert_eq(-9223372036854775809n <=> 9223372036854775807, -1); // Strings (single and multiple characters) assert_eq("a" <=> "b", -1); @@ -35,9 +35,9 @@ assert_eq(0 <=> 0.1, -1); // zero < small positive float assert_eq(0 <=> -0.1, 1); // zero > small negative float // Rational Numbers -assert_eq((5 / 3) <=> (7 / 4), -1); // 5/3 < 7/4 -assert_eq((7 / 4) <=> (5 / 3), 1); // 7/4 > 5/3 -assert_eq((5 / 3) <=> (5 / 3), 0); // Equal rational numbers +assert_eq((5n / 3n) <=> (7n / 4n), -1); // 5/3 < 7/4 +assert_eq((7n / 4n) <=> (5n / 3n), 1); // 7/4 > 5/3 +assert_eq((5n / 3n) <=> (5n / 3n), 0); // Equal rational numbers // Complex Numbers assert_eq((1 + 2i) <=> (2 + 3i), -1); // Compare based on magnitude, or however complex comparison is defined @@ -79,7 +79,7 @@ assert_eq("b" >=< "a", -1); assert_eq("a" >=< "a", 0); assert_eq([1, 2, 3] >=< [1, 2, 4], 1); assert_eq((1, 2, 3) >=< (1, 2, 4), 1); -assert_eq((5 / 3) >=< (7 / 4), 1); +assert_eq((5n / 3n) >=< (7n / 4n), 1); assert_eq((1 + 2i) >=< (2 + 3i), 1); assert_eq(true >=< false, -1); assert_eq(false >=< true, 1); diff --git a/tests/functional/programs/001_math/025_modulo_by_zero_bigint.ndc b/tests/functional/programs/001_math/025_modulo_by_zero_bigint.ndc index 2c81be23..d20cd953 100644 --- a/tests/functional/programs/001_math/025_modulo_by_zero_bigint.ndc +++ b/tests/functional/programs/001_math/025_modulo_by_zero_bigint.ndc @@ -1,2 +1,2 @@ -// expect-error: division by zero -print(170141183460469231731687303715884105727 % 0); +print(170141183460469231731687303715884105727n % 0n); +// expect-output: NaN diff --git a/tests/functional/programs/001_math/026_floor_div_by_zero.ndc b/tests/functional/programs/001_math/026_floor_div_by_zero.ndc index 0c926052..7dd1b72e 100644 --- a/tests/functional/programs/001_math/026_floor_div_by_zero.ndc +++ b/tests/functional/programs/001_math/026_floor_div_by_zero.ndc @@ -1,4 +1,4 @@ // Floor division by zero promotes to a float and yields infinity, the same // as `/` (it must not panic). -print(5 \ 0); +print(5n \ 0n); // expect-output: inf diff --git a/tests/functional/programs/001_math/028_rational_modulo_by_zero.ndc b/tests/functional/programs/001_math/028_rational_modulo_by_zero.ndc index 7365e79c..e6577e4d 100644 --- a/tests/functional/programs/001_math/028_rational_modulo_by_zero.ndc +++ b/tests/functional/programs/001_math/028_rational_modulo_by_zero.ndc @@ -1,3 +1,3 @@ -// expect-error: division by zero -let a = 1 / 3; -print(a % 0); +let a = 1n / 3n; +print(a % 0n); +// expect-output: NaN diff --git a/tests/functional/programs/001_math/029_floor_div_by_zero_rational.ndc b/tests/functional/programs/001_math/029_floor_div_by_zero_rational.ndc index 74797972..9dfcd817 100644 --- a/tests/functional/programs/001_math/029_floor_div_by_zero_rational.ndc +++ b/tests/functional/programs/001_math/029_floor_div_by_zero_rational.ndc @@ -1,6 +1,6 @@ // Regression: rational and BigInt floor division by zero yields infinity, // not an error (matches `/`). -print((1 / 3) \ 0); -print(170141183460469231731687303715884105727 \ 0); +print((1n / 3n) \ 0n); +print(170141183460469231731687303715884105727n \ 0n); // expect-output: inf // expect-output: inf diff --git a/tests/functional/programs/001_math/030_pow_zero_negative_exponent.ndc b/tests/functional/programs/001_math/030_pow_zero_negative_exponent.ndc index 5bd2d126..2220682b 100644 --- a/tests/functional/programs/001_math/030_pow_zero_negative_exponent.ndc +++ b/tests/functional/programs/001_math/030_pow_zero_negative_exponent.ndc @@ -2,4 +2,4 @@ // used to build a rational with a zero denominator, which panicked in // num-rational ("denominator == 0"). It now reports a recoverable error. // expect-error: division by zero -print(0 ^ -1); +print(0n ^ -1n); diff --git a/tests/functional/programs/001_math/031_pow_zero_negative_rational_exponent.ndc b/tests/functional/programs/001_math/031_pow_zero_negative_rational_exponent.ndc index ff912d6f..8210d029 100644 --- a/tests/functional/programs/001_math/031_pow_zero_negative_rational_exponent.ndc +++ b/tests/functional/programs/001_math/031_pow_zero_negative_rational_exponent.ndc @@ -2,4 +2,4 @@ // exponent path (`2/-1` is an integer-valued rational). Used to panic // with "right hand side must not be negative"; now a recoverable error. // expect-error: division by zero -print(0 ^ (2/-1)); +print(0n ^ (2n/-1n)); diff --git a/tests/functional/programs/001_math/032_neg_abs_i64_min.ndc b/tests/functional/programs/001_math/032_neg_abs_i64_min.ndc index b24e23a9..0f2074ac 100644 --- a/tests/functional/programs/001_math/032_neg_abs_i64_min.ndc +++ b/tests/functional/programs/001_math/032_neg_abs_i64_min.ndc @@ -2,7 +2,8 @@ // range (`-i64::MIN` does not fit in an i64). `Int::neg` and `Int::abs` used // to call `i.neg()` / `i.abs()` directly, which panics in debug builds and // silently wraps in release. They now promote to BigInt, yielding the correct -// positive magnitude. -// expect-output: 9223372036854775808 9223372036854775808 9223372036854775808 +// positive magnitude. Int arithmetic now reports the overflow instead of +// promoting implicitly; Number remains arbitrary precision. +// expect-error: integer negation overflowed let min = -9223372036854775807 - 1; -print(-min, abs(min), abs(-min)); +print(-min); diff --git a/tests/functional/programs/001_math/033_numeric_modes.ndc b/tests/functional/programs/001_math/033_numeric_modes.ndc new file mode 100644 index 00000000..6da25220 --- /dev/null +++ b/tests/functional/programs/001_math/033_numeric_modes.ndc @@ -0,0 +1,40 @@ +// The three public numeric types are siblings. Every mixed arithmetic pair +// has an explicit overload and the result mode follows Int < Float < Number. +let int_int: Int = 1 + 2; +let int_float: Float = 1 + 2.5; +let float_int: Float = 2.5 + 1; +let float_float: Float = 1.5 + 2.5; +let int_number: Number = 1 + 2n; +let number_int: Number = 2n + 1; +let float_number: Number = 1.5 + 2n; +let number_float: Number = 2n + 1.5; +let number_number: Number = 1n + 2n; + +assert_eq(int_int, 3); +assert_eq(int_float, 3.5); +assert_eq(float_int, 3.5); +assert_eq(float_float, 4.0); +assert_eq(int_number, 3n); +assert_eq(number_int, 3n); +assert_eq(float_number, 3.5n); +assert_eq(number_float, 3.5n); +assert_eq(number_number, 3n); + +// Int division truncates, floor division rounds toward negative infinity, +// and the two remainder operators pair with those choices. +assert_eq(-7 / 2, -3); +assert_eq(-7 \ 2, -4); +assert_eq(-7 % 2, -1); +assert_eq(-7 %% 2, 1); +assert_eq(2 ^ 10, 1024); + +// Number division remains exact and all Number results remain wrapped. +let ratio: Number = 7n / 2n; +let floored: Number = -7n \ 2n; +assert_eq(ratio, 7n / 2n); +assert_eq(floored, -4n); +assert_eq(7n \ -2n, -4n); + +assert_eq(Number(3), 3n); +assert_eq(Number(3.5), 3.5n); +assert_eq(Number(3n), 3n); diff --git a/tests/functional/programs/001_math/034_exact_numeric_equality.ndc b/tests/functional/programs/001_math/034_exact_numeric_equality.ndc new file mode 100644 index 00000000..d5965eba --- /dev/null +++ b/tests/functional/programs/001_math/034_exact_numeric_equality.ndc @@ -0,0 +1,24 @@ +// Equal values compare and hash alike across all three public numeric modes. +assert(1 == 1.0); +assert(1.0 == 1n); +assert(1n == 1 + 0i); +assert_eq(%{1, 1.0, 1n, 1 + 0i}.len(), 1); + +// A finite Float is compared as its exact binary value, not approximately. +assert(0.1 != 1n / 10n); +assert(-0.0 == 0.0); + +// NaNs form one equal, hash-compatible value and sort after positive infinity. +let nan = 0.0 / 0.0; +let number_nan = 0n / 0n; +assert(nan == number_nan); +assert_eq(%{nan, number_nan}.len(), 1); +let negative_infinity = -1n / 0n; +let positive_infinity = 1n / 0n; +assert(negative_infinity < 0n); +assert(0n < positive_infinity); +assert(positive_infinity < number_nan); + +// Complex ordering is lexicographic: real part first, imaginary part second. +assert((2 + 0i) > (1 + 100i)); +assert((1 + 2i) < (1 + 3i)); diff --git a/tests/functional/programs/001_math/035_number_literals.ndc b/tests/functional/programs/001_math/035_number_literals.ndc new file mode 100644 index 00000000..ff110732 --- /dev/null +++ b/tests/functional/programs/001_math/035_number_literals.ndc @@ -0,0 +1,18 @@ +let decimal_integer: Number = 123456789123456789123456789n; +let decimal_float: Number = 1.25n; +let binary: Number = 0b101010n; +let octal: Number = 0o52n; +let hexadecimal: Number = 0x2an; +let imaginary_i: Number = 2i; +let imaginary_j: Number = 2j; + +assert_eq(decimal_integer, 123456789123456789123456789n); +assert_eq(decimal_float, Number(1.25)); +assert_eq(binary, 42n); +assert_eq(octal, 42n); +assert_eq(hexadecimal, 42n); +assert_eq(imaginary_i, imaginary_j); + +// Arbitrary-radix literals intentionally remain Int literals without a suffix. +let arbitrary_radix: Int = 16r2a; +assert_eq(arbitrary_radix, 42); diff --git a/tests/functional/programs/001_math/036_number_stdlib.ndc b/tests/functional/programs/001_math/036_number_stdlib.ndc new file mode 100644 index 00000000..25014ca4 --- /dev/null +++ b/tests/functional/programs/001_math/036_number_stdlib.ndc @@ -0,0 +1,25 @@ +let primitive_factorial: Int = factorial(20); +let advanced_factorial: Number = factorial(25n); +assert_eq(primitive_factorial, 2432902008176640000); +assert_eq(advanced_factorial, 15511210043330985984000000n); + +assert_eq(gcd(54, 24), 6); +assert_eq(lcm(21, 6), 42); +assert_eq(gcd(540000000000000000000n, 240000000000000000000n), 60000000000000000000n); +assert_eq(lcm(21n, 6n), 42n); + +assert_eq(ceil(7n / 2n), 4n); +assert_eq(floor(-7n / 2n), -4n); +assert_eq(round(7n / 2n), 4n); +assert_eq(abs(-9223372036854775808n), 9223372036854775808n); +assert_eq(abs_diff(1n, 3.5), 2.5n); + +assert_eq(real(2 + 3i), 2n); +assert_eq(imag(2 + 3i), 3n); +assert_eq(numerator(7n / 3n), 7n); +assert_eq(denominator(7n / 3n), 3n); + +assert_eq(sqrt(-1n), 1i); +let negative_log: Number = ln(-1n); +assert_eq(real(negative_log), 0n); +assert(imag(negative_log) > 3.14n); diff --git a/tests/functional/programs/001_math/037_oversized_int_literal.ndc b/tests/functional/programs/001_math/037_oversized_int_literal.ndc new file mode 100644 index 00000000..5c1c2bf1 --- /dev/null +++ b/tests/functional/programs/001_math/037_oversized_int_literal.ndc @@ -0,0 +1,2 @@ +// expect-error: use the advanced literal `9223372036854775808n` +print(9223372036854775808); diff --git a/tests/functional/programs/001_math/038_arbitrary_radix_number_suffix.ndc b/tests/functional/programs/001_math/038_arbitrary_radix_number_suffix.ndc new file mode 100644 index 00000000..dbeb3152 --- /dev/null +++ b/tests/functional/programs/001_math/038_arbitrary_radix_number_suffix.ndc @@ -0,0 +1,2 @@ +// expect-error: suffix is not supported on arbitrary-radix literals +print(16rffn); diff --git a/tests/functional/programs/001_math/039_removed_rational_type.ndc b/tests/functional/programs/001_math/039_removed_rational_type.ndc new file mode 100644 index 00000000..75c1747e --- /dev/null +++ b/tests/functional/programs/001_math/039_removed_rational_type.ndc @@ -0,0 +1,2 @@ +// expect-error: unknown type `Rational` +let value: Rational = 1n / 2n; diff --git a/tests/functional/programs/001_math/040_removed_complex_type.ndc b/tests/functional/programs/001_math/040_removed_complex_type.ndc new file mode 100644 index 00000000..97633b7f --- /dev/null +++ b/tests/functional/programs/001_math/040_removed_complex_type.ndc @@ -0,0 +1,2 @@ +// expect-error: unknown type `Complex` +let value: Complex = 1i; diff --git a/tests/functional/programs/001_math/041_number_bitwise_error.ndc b/tests/functional/programs/001_math/041_number_bitwise_error.ndc new file mode 100644 index 00000000..63480c7a --- /dev/null +++ b/tests/functional/programs/001_math/041_number_bitwise_error.ndc @@ -0,0 +1,2 @@ +// expect-error: No function called +print(1n & 2n); diff --git a/tests/functional/programs/004_basic/039_binary_literal.ndc b/tests/functional/programs/004_basic/039_binary_literal.ndc index 932c59f9..387c1fac 100644 --- a/tests/functional/programs/004_basic/039_binary_literal.ndc +++ b/tests/functional/programs/004_basic/039_binary_literal.ndc @@ -1,7 +1,6 @@ // Simple binary number assert(0b1111 == 15); // Really big binary numbers are valid too! -assert(0b1111111111111111111111111111111111111111111111111111111111111111 == 18446744073709551615); +assert(0b1111111111111111111111111111111111111111111111111111111111111111n == 18446744073709551615n); // Underscores are allowed assert(0b10_10_10 == 42); - diff --git a/tests/functional/programs/004_basic/040_hex_literal.ndc b/tests/functional/programs/004_basic/040_hex_literal.ndc index f3a41ef1..205a7134 100644 --- a/tests/functional/programs/004_basic/040_hex_literal.ndc +++ b/tests/functional/programs/004_basic/040_hex_literal.ndc @@ -1,6 +1,5 @@ assert(0xffff == 65535); // Big numbers -assert(0xDEADDEADDEADDEADDEAD == 1051572691647698727460525); +assert(0xDEADDEADDEADDEADDEADn == 1051572691647698727460525n); // Underscores are allowed assert(0xf_Ff_dead == 268426925); - diff --git a/tests/functional/programs/004_basic/046_annotated_let_binding.ndc b/tests/functional/programs/004_basic/046_annotated_let_binding.ndc index b137a62f..9f2901d1 100644 --- a/tests/functional/programs/004_basic/046_annotated_let_binding.ndc +++ b/tests/functional/programs/004_basic/046_annotated_let_binding.ndc @@ -6,9 +6,9 @@ while false { let bool_value: Bool = true; let int_value: Int = 3; let float_value: Float = 3.0; -let rational_value: Number = 3 / 4; +let rational_value: Number = 3n / 4n; let complex_value: Number = 1 + 2i; -let number_value: Number = 3; +let number_value: Number = 3n; let string_value: String = "hello"; let option_value: Option = Some(3); diff --git a/tests/functional/programs/004_basic/051_annotated_let_subtype_accepted.ndc b/tests/functional/programs/004_basic/051_annotated_let_subtype_accepted.ndc index 7a231d3a..4468f962 100644 --- a/tests/functional/programs/004_basic/051_annotated_let_subtype_accepted.ndc +++ b/tests/functional/programs/004_basic/051_annotated_let_subtype_accepted.ndc @@ -1,3 +1,3 @@ // expect-output: 42 -let x: Number = 42; +let x: Number = 42n; print(x); diff --git a/tests/functional/programs/004_basic/055_annotated_let_op_assign_rejected.ndc b/tests/functional/programs/004_basic/055_annotated_let_op_assign_rejected.ndc index 88cbc43e..23234b5f 100644 --- a/tests/functional/programs/004_basic/055_annotated_let_op_assign_rejected.ndc +++ b/tests/functional/programs/004_basic/055_annotated_let_op_assign_rejected.ndc @@ -1,3 +1,3 @@ let x: Int = 3; x /= 4; -// expect-error: mismatched types +assert_eq(x, 0); diff --git a/tests/functional/programs/004_basic/058_annotated_list_index_op_assignment.ndc b/tests/functional/programs/004_basic/058_annotated_list_index_op_assignment.ndc index c0bf7d58..8d927b7b 100644 --- a/tests/functional/programs/004_basic/058_annotated_list_index_op_assignment.ndc +++ b/tests/functional/programs/004_basic/058_annotated_list_index_op_assignment.ndc @@ -1,3 +1,3 @@ -// expect-error: mismatched types: found Number but expected Int +// expect-error: mismatched types: found Float but expected Int let values: List = [1]; values[0] += 0.5; diff --git a/tests/functional/programs/004_basic/059_annotated_map_index_op_assignment.ndc b/tests/functional/programs/004_basic/059_annotated_map_index_op_assignment.ndc index 26554cd8..93710417 100644 --- a/tests/functional/programs/004_basic/059_annotated_map_index_op_assignment.ndc +++ b/tests/functional/programs/004_basic/059_annotated_map_index_op_assignment.ndc @@ -1,3 +1,3 @@ -// expect-error: mismatched types: found Number but expected Int +// expect-error: mismatched types: found Float but expected Int let values: Map = %{"one": 1}; values["one"] += 0.5; diff --git a/tests/functional/programs/004_basic/062_non_widenable_index_op_assignment.ndc b/tests/functional/programs/004_basic/062_non_widenable_index_op_assignment.ndc index df42dd15..891136d2 100644 --- a/tests/functional/programs/004_basic/062_non_widenable_index_op_assignment.ndc +++ b/tests/functional/programs/004_basic/062_non_widenable_index_op_assignment.ndc @@ -1,3 +1,3 @@ -// expect-error: mismatched types: found Number but expected Int +// expect-error: mismatched types: found Float but expected Int fn values() -> List => [1]; values()[0] += 0.5; diff --git a/tests/functional/programs/008_iterators/002_range_contains.ndc b/tests/functional/programs/008_iterators/002_range_contains.ndc index 26fd1972..74da3c79 100644 --- a/tests/functional/programs/008_iterators/002_range_contains.ndc +++ b/tests/functional/programs/008_iterators/002_range_contains.ndc @@ -39,4 +39,4 @@ assert_eq(0 in (-1..1), true); // 0 is within the range -1 to 1 assert_eq(1 in (-1..1), false); // 1 is outside the exclusive range // Edge case: infinite ranges -assert_eq(-9223372036854775808 in (5..), false); // Out of open range's bounds +assert_eq((-9223372036854775807 - 1) in (5..), false); // Out of open range's bounds diff --git a/tests/functional/programs/011_heap/011_numeric_types_ordering.ndc b/tests/functional/programs/011_heap/011_numeric_types_ordering.ndc index 1824cb71..c5c37904 100644 --- a/tests/functional/programs/011_heap/011_numeric_types_ordering.ndc +++ b/tests/functional/programs/011_heap/011_numeric_types_ordering.ndc @@ -3,30 +3,30 @@ // MinHeap with BigInts (values outside i64 range) let h = MinHeap(); -h.push(9223372036854775808); // i64::MAX + 1 -h.push(9223372036854775809); +h.push(9223372036854775808n); // i64::MAX + 1 +h.push(9223372036854775809n); h.push(9223372036854775807); // i64::MAX (fits in i64) assert_eq(h.pop, 9223372036854775807); -assert_eq(h.pop, 9223372036854775808); -assert_eq(h.pop, 9223372036854775809); +assert_eq(h.pop, 9223372036854775808n); +assert_eq(h.pop, 9223372036854775809n); // MaxHeap with Rationals let h = MaxHeap(); -h.push(5 / 3); -h.push(7 / 4); -h.push(1 / 2); -assert_eq(h.pop, 7 / 4); -assert_eq(h.pop, 5 / 3); -assert_eq(h.pop, 1 / 2); +h.push(5n / 3n); +h.push(7n / 4n); +h.push(1n / 2n); +assert_eq(h.pop, 7n / 4n); +assert_eq(h.pop, 5n / 3n); +assert_eq(h.pop, 1n / 2n); // MinHeap: mixed int and Rational let h = MinHeap(); h.push(2); -h.push(3 / 2); // 1.5 -h.push(5 / 2); // 2.5 -assert_eq(h.pop, 3 / 2); +h.push(3n / 2n); // 1.5 +h.push(5n / 2n); // 2.5 +assert_eq(h.pop, 3n / 2n); assert_eq(h.pop, 2); -assert_eq(h.pop, 5 / 2); +assert_eq(h.pop, 5n / 2n); // MinHeap with Complex (ordered by (re, im) tuple, same as interpreter) let h = MinHeap(); diff --git a/tests/functional/programs/013_vector_math/009_vector_exact_match_precision.ndc b/tests/functional/programs/013_vector_math/009_vector_exact_match_precision.ndc index d9b003cd..d0cdd9c1 100644 --- a/tests/functional/programs/013_vector_math/009_vector_exact_match_precision.ndc +++ b/tests/functional/programs/013_vector_math/009_vector_exact_match_precision.ndc @@ -15,11 +15,11 @@ assert_eq(f, (14, 26)); // Any args fall to LUB (no scalar `-(Any, Any)` exists, so vec dispatch // can't pin a single overload at compile time): `Tuple - -// Tuple` infers as `Tuple`, the LUB across -// every numeric overload's return type. +// Tuple` remains `Tuple` because Int, Float, and Number +// are siblings and their least upper bound is Any. let l: List = [1, 2]; let p: Tuple = (l.first, l.first); let q: Tuple = (l.last, l.last); -let r: Tuple = p - q; -let s: Tuple = r * r; +let r: Tuple = p - q; +let s: Tuple = r * r; assert_eq(s, (1, 1)); diff --git a/tests/functional/programs/015_struct/017_struct_in_container.ndc b/tests/functional/programs/015_struct/017_struct_in_container.ndc index e0ac45d4..7c0d0895 100644 --- a/tests/functional/programs/015_struct/017_struct_in_container.ndc +++ b/tests/functional/programs/015_struct/017_struct_in_container.ndc @@ -1,6 +1,12 @@ struct Point { x: Int, y: Int } -fn total(points: List) => points.map(x).sum() +fn total(points: List) { + let total = 0; + for point in points { + total += point.x; + } + total +} let points: List = [Point(1, 2), Point(3, 4)]; print(total(points)) diff --git a/tests/functional/programs/601_stdlib_list/007_list_index_maybe.ndc b/tests/functional/programs/601_stdlib_list/007_list_index_maybe.ndc index 1e385a53..22f5dffd 100644 --- a/tests/functional/programs/601_stdlib_list/007_list_index_maybe.ndc +++ b/tests/functional/programs/601_stdlib_list/007_list_index_maybe.ndc @@ -8,7 +8,3 @@ assert_eq(list.index?(-3), Some(10)); // out of bounds in either direction is None assert_eq(list.index?(3), None); assert_eq(list.index?(-4), None); - -// an index too large to address the list is also None (no error) -assert_eq(list.index?(10^40), None); -assert_eq(list.index?(-(10^40)), None); diff --git a/tests/functional/programs/601_stdlib_list/009_list_get_too_large_error.ndc b/tests/functional/programs/601_stdlib_list/009_list_get_too_large_error.ndc index d30867d8..87dc75ee 100644 --- a/tests/functional/programs/601_stdlib_list/009_list_get_too_large_error.ndc +++ b/tests/functional/programs/601_stdlib_list/009_list_get_too_large_error.ndc @@ -1,3 +1,3 @@ -// an index that does not fit a usize is a conversion error -// expect-error: out of range -[10, 20, 30].get?(10^40); +// Number is deliberately not an index type. +// expect-error: No function called +[10, 20, 30].get?(10n^40n); diff --git a/tests/functional/programs/604_stdlib_math/001_sum.ndc b/tests/functional/programs/604_stdlib_math/001_sum.ndc index 43e8cf18..d6ddd127 100644 --- a/tests/functional/programs/604_stdlib_math/001_sum.ndc +++ b/tests/functional/programs/604_stdlib_math/001_sum.ndc @@ -1,5 +1,5 @@ assert_eq((1..10).sum(), 45); -assert_eq(%{1,2,3}.keys().sum(), 6); -assert_eq(%{1: "foo", 2: "bar", 3: "baz"}.keys().sum(), 6); assert_eq((1,2,3,4).sum(), 10); assert_eq([1,2,3,4,5].sum(), 15); +assert_eq([1.0,2.0,3.0].sum(), 6.0); +assert_eq([1n,2n,3n].sum(), 6n); diff --git a/tests/functional/programs/604_stdlib_math/002_product.ndc b/tests/functional/programs/604_stdlib_math/002_product.ndc index 6ac1cbbc..093b73ed 100644 --- a/tests/functional/programs/604_stdlib_math/002_product.ndc +++ b/tests/functional/programs/604_stdlib_math/002_product.ndc @@ -1,5 +1,5 @@ assert_eq((1..10).product(), 362880); -assert_eq(%{1,2,3}.keys().product(), 6); -assert_eq(%{1: "foo", 2: "bar", 3: "baz"}.keys().product(), 6); assert_eq((1,2,3,4).product(), 24); assert_eq([1,2,3,4,5].product(), 120); +assert_eq([1.0,2.0,3.0].product(), 6.0); +assert_eq([1n,2n,3n].product(), 6n); diff --git a/tests/functional/programs/604_stdlib_math/003_mixed_sum_error.ndc b/tests/functional/programs/604_stdlib_math/003_mixed_sum_error.ndc new file mode 100644 index 00000000..797c2816 --- /dev/null +++ b/tests/functional/programs/604_stdlib_math/003_mixed_sum_error.ndc @@ -0,0 +1,3 @@ +// Int, Float, and Number are siblings, so a heterogeneous list is List. +// expect-error: No function called 'sum' +print([1, 2.0].sum()); diff --git a/tests/functional/programs/605_stdlib_serde/003_numbers.ndc b/tests/functional/programs/605_stdlib_serde/003_numbers.ndc index b00fb82b..f82330f0 100644 --- a/tests/functional/programs/605_stdlib_serde/003_numbers.ndc +++ b/tests/functional/programs/605_stdlib_serde/003_numbers.ndc @@ -1,7 +1,7 @@ // Big integers round-trip exactly instead of degrading to floats -let big = 2 ^ 100 + 1; +let big = 2n ^ 100n + 1n; assert_eq(json_decode(json_encode(big)), big); -assert_eq(json_decode("123456789123456789123456789"), 123456789123456789123456789); +assert_eq(json_decode("123456789123456789123456789"), 123456789123456789123456789n); // Numbers written with a decimal point or exponent decode to floats assert_eq(json_decode("1e2"), 100.0); diff --git a/tests/functional/programs/605_stdlib_serde/004_lossy.ndc b/tests/functional/programs/605_stdlib_serde/004_lossy.ndc index 7850aae1..e0f76a49 100644 --- a/tests/functional/programs/605_stdlib_serde/004_lossy.ndc +++ b/tests/functional/programs/605_stdlib_serde/004_lossy.ndc @@ -2,7 +2,7 @@ // and cyclic values) by degrading it to the nearest JSON representation // Rationals become floats -assert_eq(json_encode_lossy(10/3), "3.3333333333333335"); +assert_eq(json_encode_lossy(10n/3n), "3.3333333333333335"); // Complex numbers become strings assert_eq(json_encode_lossy(5.0 + 3.1j), "\"5+3.1i\""); diff --git a/tests/functional/programs/605_stdlib_serde/010_encode_error_rational.ndc b/tests/functional/programs/605_stdlib_serde/010_encode_error_rational.ndc index 73020fdf..4c9a1a13 100644 --- a/tests/functional/programs/605_stdlib_serde/010_encode_error_rational.ndc +++ b/tests/functional/programs/605_stdlib_serde/010_encode_error_rational.ndc @@ -1,2 +1,2 @@ -json_encode(10/3); +json_encode(10n/3n); // expect-error: cannot convert a rational number to JSON diff --git a/tests/functional/programs/900_bugs/bug0020_pow_huge_exponent.ndc b/tests/functional/programs/900_bugs/bug0020_pow_huge_exponent.ndc index 3974aa2e..59f1bedf 100644 --- a/tests/functional/programs/900_bugs/bug0020_pow_huge_exponent.ndc +++ b/tests/functional/programs/900_bugs/bug0020_pow_huge_exponent.ndc @@ -4,4 +4,4 @@ // instead of computing a result that wouldn't fit in any reasonable // amount of memory. // expect-error: exponent too large -print(-2 ^ 170141183460469231731687303715884105727) +print(-2n ^ 170141183460469231731687303715884105727n) diff --git a/tests/functional/programs/900_bugs/bug0025_pow_negative_rational_exponent.ndc b/tests/functional/programs/900_bugs/bug0025_pow_negative_rational_exponent.ndc index 1fbaa17a..9ecd0c23 100644 --- a/tests/functional/programs/900_bugs/bug0025_pow_negative_rational_exponent.ndc +++ b/tests/functional/programs/900_bugs/bug0025_pow_negative_rational_exponent.ndc @@ -4,4 +4,4 @@ // negative") and aborted the whole process. It now computes the // reciprocal, exactly like a plain negative integer exponent. // expect-output: 1/2 -print(2 ^ (1/-1)); +print(2n ^ (1n/-1n)); diff --git a/tests/functional/programs/900_bugs/bug0026_neg_abs_i64_min_overflow.ndc b/tests/functional/programs/900_bugs/bug0026_neg_abs_i64_min_overflow.ndc index 595baa86..1d97a0cc 100644 --- a/tests/functional/programs/900_bugs/bug0026_neg_abs_i64_min_overflow.ndc +++ b/tests/functional/programs/900_bugs/bug0026_neg_abs_i64_min_overflow.ndc @@ -1,6 +1,5 @@ // `abs(i64::MIN)` and `-(i64::MIN)` overflow the i64 range because the -// positive magnitude `2^63` does not fit in an i64. `Int::abs`/`Int::neg` -// used to evaluate `i.abs()`/`i.neg()` directly, panicking with "attempt to -// negate with overflow" in debug builds. They now promote to BigInt. -// expect-output: 9223372036854775808 +// positive magnitude `2^63` does not fit in an i64. Int arithmetic reports +// that checked overflow rather than promoting implicitly. +// expect-error: integer absolute value overflowed print(abs(-9223372036854775807 - 1)); diff --git a/tests/functional/programs/998_not_desired/big_int_ranges.ndc b/tests/functional/programs/998_not_desired/big_int_ranges.ndc index bca4ad05..75c9a00c 100644 --- a/tests/functional/programs/998_not_desired/big_int_ranges.ndc +++ b/tests/functional/programs/998_not_desired/big_int_ranges.ndc @@ -1,4 +1,4 @@ // expect-error: Integer too large for range bounds // Not sure what to think of this one, but right now you cannot construct ranges with ints outside of the i64 range // it does make type analysis a tiny bit easier though -(2^1024)..((2^1024)+1) +(2n^1024n)..((2n^1024n)+1n) diff --git a/tests/functional/programs/999_cursed/comical_factorial.ndc b/tests/functional/programs/999_cursed/comical_factorial.ndc index 790f4101..b27fc1bf 100644 --- a/tests/functional/programs/999_cursed/comical_factorial.ndc +++ b/tests/functional/programs/999_cursed/comical_factorial.ndc @@ -1,6 +1,6 @@ -let v, n = 100, 100; +let v, n = 100n, 100; while n > 1 { n -= 1; v *= n; } -assert_eq(v, 100.factorial); +assert_eq(v, 100n.factorial); From 100a554c7a70a4da24577b153559ee2809fcc172 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 12:51:43 +0200 Subject: [PATCH 02/31] =?UTF-8?q?fix:=20highlight=20advanced=20number=20li?= =?UTF-8?q?terals=20everywhere=20=F0=9F=8E=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../syntaxes/andy-cpp.tmLanguage.json | 24 +-- ext/tree-sitter-andy-cpp/grammar.js | 10 +- ext/tree-sitter-andy-cpp/src/grammar.json | 10 +- ext/tree-sitter-andy-cpp/src/parser.c | 165 ++++++++++-------- .../test/corpus/basics.txt | 29 +++ ndc_bin/src/highlighter.rs | 27 +++ 6 files changed, 168 insertions(+), 97 deletions(-) diff --git a/ext/andy-cpp/syntaxes/andy-cpp.tmLanguage.json b/ext/andy-cpp/syntaxes/andy-cpp.tmLanguage.json index f9160242..f5676383 100644 --- a/ext/andy-cpp/syntaxes/andy-cpp.tmLanguage.json +++ b/ext/andy-cpp/syntaxes/andy-cpp.tmLanguage.json @@ -58,7 +58,7 @@ { "comment": "built-in type names used in annotations like `let x: Int` or `-> List`", "name": "support.type.andy-cpp", - "match": "\\b(Any|Never|Bool|Number|Float|Int|Rational|Complex|String|Option|Sequence|List|Iterator|MinHeap|MaxHeap|Deque|Tuple|Map)\\b" + "match": "\\b(Any|Never|Bool|Number|Float|Int|String|Option|Sequence|List|Iterator|MinHeap|MaxHeap|Deque|Tuple|Map)\\b" } ] }, @@ -200,18 +200,18 @@ "name": "constant.numeric.complex.andy-cpp" }, { - "comment": "Floats", - "match": "\\b([0-9][0-9_]*\\.[0-9][0-9_]*)\\b", + "comment": "Primitive Float and advanced Number float literals", + "match": "\\b([0-9][0-9_]*\\.[0-9][0-9_]*n?)\\b", "name": "constant.numeric.float.andy-cpp" }, { - "comment": "Hexadecimal numbers", - "match": "\\b(0x[0-9A-Fa-f][0-9A-Fa-f_]*)\\b", + "comment": "Primitive Int and advanced Number hexadecimal literals", + "match": "\\b(0x[0-9A-Fa-f][0-9A-Fa-f_]*n?)\\b", "name": "constant.numeric.hex.andy-cpp" }, { - "comment": "Ocatal n umbers", - "match": "\\b(0o[0-7][0-7]*)\\b", + "comment": "Primitive Int and advanced Number octal literals", + "match": "\\b(0o[0-7][0-7_]*n?)\\b", "name": "constant.numeric.octal.andy-cpp" }, { @@ -220,13 +220,13 @@ "name": "constant.numeric.andy-cpp" }, { - "comment": "Binary numbers", - "match": "\\b(0b[01][01_]*)\\b", + "comment": "Primitive Int and advanced Number binary literals", + "match": "\\b(0b[01][01_]*n?)\\b", "name": "constant.numeric.binary.andy-cpp" }, { - "comment": "Integer numbers", - "match": "\\b([0-9][0-9_]*)\\b", + "comment": "Primitive Int and advanced Number decimal literals", + "match": "\\b([0-9][0-9_]*n?)\\b", "name": "constant.numeric.decimal.andy-cpp" }, { @@ -492,4 +492,4 @@ } }, "scopeName": "source.andy-cpp" -} \ No newline at end of file +} diff --git a/ext/tree-sitter-andy-cpp/grammar.js b/ext/tree-sitter-andy-cpp/grammar.js index e9bd539c..853c210a 100644 --- a/ext/tree-sitter-andy-cpp/grammar.js +++ b/ext/tree-sitter-andy-cpp/grammar.js @@ -467,14 +467,14 @@ module.exports = grammar({ ), integer: _ => token(choice( - /0b[01][01_]*/, - /0o[0-7][0-7_]*/, - /0x[0-9A-Fa-f][0-9A-Fa-f_]*/, + /0b[01][01_]*n?/, + /0o[0-7][0-7_]*n?/, + /0x[0-9A-Fa-f][0-9A-Fa-f_]*n?/, /[0-9]+r[0-9A-Za-z][0-9A-Za-z_]*/, // arbitrary radix, e.g. 16rFF - /[0-9][0-9_]*/, + /[0-9][0-9_]*n?/, )), - float: _ => token(/[0-9][0-9_]*\.[0-9][0-9_]*/), + float: _ => token(/[0-9][0-9_]*\.[0-9][0-9_]*n?/), // Imaginary part of a complex literal: `3i`, `2.5j` complex: _ => token(/[0-9][0-9_]*(\.[0-9][0-9_]*)?[ij]/), diff --git a/ext/tree-sitter-andy-cpp/src/grammar.json b/ext/tree-sitter-andy-cpp/src/grammar.json index c4bb69c1..a16d09c7 100644 --- a/ext/tree-sitter-andy-cpp/src/grammar.json +++ b/ext/tree-sitter-andy-cpp/src/grammar.json @@ -2705,15 +2705,15 @@ "members": [ { "type": "PATTERN", - "value": "0b[01][01_]*" + "value": "0b[01][01_]*n?" }, { "type": "PATTERN", - "value": "0o[0-7][0-7_]*" + "value": "0o[0-7][0-7_]*n?" }, { "type": "PATTERN", - "value": "0x[0-9A-Fa-f][0-9A-Fa-f_]*" + "value": "0x[0-9A-Fa-f][0-9A-Fa-f_]*n?" }, { "type": "PATTERN", @@ -2721,7 +2721,7 @@ }, { "type": "PATTERN", - "value": "[0-9][0-9_]*" + "value": "[0-9][0-9_]*n?" } ] } @@ -2730,7 +2730,7 @@ "type": "TOKEN", "content": { "type": "PATTERN", - "value": "[0-9][0-9_]*\\.[0-9][0-9_]*" + "value": "[0-9][0-9_]*\\.[0-9][0-9_]*n?" } }, "complex": { diff --git a/ext/tree-sitter-andy-cpp/src/parser.c b/ext/tree-sitter-andy-cpp/src/parser.c index 0c76f4ac..c1ff4d80 100644 --- a/ext/tree-sitter-andy-cpp/src/parser.c +++ b/ext/tree-sitter-andy-cpp/src/parser.c @@ -2138,7 +2138,7 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { if (eof) ADVANCE(17); ADVANCE_MAP( '!', 52, - '"', 93, + '"', 95, '#', 1, '%', 69, '&', 61, @@ -2150,7 +2150,7 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { '-', 54, '.', 82, '/', 66, - '0', 84, + '0', 85, ':', 19, ';', 18, '<', 32, @@ -2167,12 +2167,12 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { ); if (('\t' <= lookahead && lookahead <= '\r') || lookahead == ' ') SKIP(0); - if (('1' <= lookahead && lookahead <= '9')) ADVANCE(85); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(86); if (('A' <= lookahead && lookahead <= '_') || - ('a' <= lookahead && lookahead <= 'z')) ADVANCE(101); + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(103); END_STATE(); case 1: - if (lookahead == '!') ADVANCE(102); + if (lookahead == '!') ADVANCE(104); END_STATE(); case 2: ADVANCE_MAP( @@ -2204,7 +2204,7 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { if (('\t' <= lookahead && lookahead <= '\r') || lookahead == ' ') SKIP(2); if (('A' <= lookahead && lookahead <= '_') || - ('a' <= lookahead && lookahead <= 'z')) ADVANCE(101); + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(103); END_STATE(); case 3: ADVANCE_MAP( @@ -2221,16 +2221,16 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { lookahead == ' ') SKIP(3); if (('A' <= lookahead && lookahead <= 'Z') || lookahead == '_' || - ('a' <= lookahead && lookahead <= 'z')) ADVANCE(101); + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(103); END_STATE(); case 4: - if (lookahead == '"') ADVANCE(93); - if (lookahead == '#') ADVANCE(95); - if (lookahead == '/') ADVANCE(97); + if (lookahead == '"') ADVANCE(95); + if (lookahead == '#') ADVANCE(97); + if (lookahead == '/') ADVANCE(99); if (lookahead == '\\') ADVANCE(11); if (('\t' <= lookahead && lookahead <= '\r') || - lookahead == ' ') ADVANCE(96); - if (lookahead != 0) ADVANCE(98); + lookahead == ' ') ADVANCE(98); + if (lookahead != 0) ADVANCE(100); END_STATE(); case 5: ADVANCE_MAP( @@ -2252,7 +2252,7 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { ('a' <= lookahead && lookahead <= 'z')) ADVANCE(30); END_STATE(); case 6: - if (lookahead == '/') ADVANCE(102); + if (lookahead == '/') ADVANCE(104); END_STATE(); case 7: if (lookahead == '=') ADVANCE(72); @@ -2266,7 +2266,7 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { END_STATE(); case 10: if (lookahead == '0' || - lookahead == '1') ADVANCE(87); + lookahead == '1') ADVANCE(88); END_STATE(); case 11: if (lookahead == '"' || @@ -2274,29 +2274,29 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { lookahead == '\\' || lookahead == 'n' || lookahead == 'r' || - lookahead == 't') ADVANCE(99); + lookahead == 't') ADVANCE(101); END_STATE(); case 12: - if (('0' <= lookahead && lookahead <= '7')) ADVANCE(88); + if (('0' <= lookahead && lookahead <= '7')) ADVANCE(89); END_STATE(); case 13: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(91); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(93); END_STATE(); case 14: if (('0' <= lookahead && lookahead <= '9') || ('A' <= lookahead && lookahead <= 'F') || - ('a' <= lookahead && lookahead <= 'f')) ADVANCE(89); + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(90); END_STATE(); case 15: if (('0' <= lookahead && lookahead <= '9') || ('A' <= lookahead && lookahead <= 'Z') || - ('a' <= lookahead && lookahead <= 'z')) ADVANCE(90); + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(91); END_STATE(); case 16: if (eof) ADVANCE(17); ADVANCE_MAP( '!', 52, - '"', 93, + '"', 95, '#', 1, '%', 69, '&', 61, @@ -2308,7 +2308,7 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { '-', 53, '.', 82, '/', 66, - '0', 84, + '0', 85, ':', 19, ';', 18, '<', 32, @@ -2325,9 +2325,9 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { ); if (('\t' <= lookahead && lookahead <= '\r') || lookahead == ' ') SKIP(16); - if (('1' <= lookahead && lookahead <= '9')) ADVANCE(85); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(86); if (('A' <= lookahead && lookahead <= '_') || - ('a' <= lookahead && lookahead <= 'z')) ADVANCE(101); + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(103); END_STATE(); case 17: ACCEPT_TOKEN(ts_builtin_sym_end); @@ -2507,7 +2507,7 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { END_STATE(); case 66: ACCEPT_TOKEN(anon_sym_SLASH); - if (lookahead == '/') ADVANCE(102); + if (lookahead == '/') ADVANCE(104); if (lookahead == '=') ADVANCE(40); END_STATE(); case 67: @@ -2575,128 +2575,143 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { END_STATE(); case 84: ACCEPT_TOKEN(sym_integer); - if (lookahead == '.') ADVANCE(13); - if (lookahead == '_') ADVANCE(86); - if (lookahead == 'b') ADVANCE(10); - if (lookahead == 'o') ADVANCE(12); - if (lookahead == 'r') ADVANCE(15); - if (lookahead == 'x') ADVANCE(14); - if (lookahead == 'i' || - lookahead == 'j') ADVANCE(92); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(85); END_STATE(); case 85: + ACCEPT_TOKEN(sym_integer); + ADVANCE_MAP( + '.', 13, + '_', 87, + 'b', 10, + 'n', 84, + 'o', 12, + 'r', 15, + 'x', 14, + 'i', 94, + 'j', 94, + ); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(86); + END_STATE(); + case 86: ACCEPT_TOKEN(sym_integer); if (lookahead == '.') ADVANCE(13); - if (lookahead == '_') ADVANCE(86); + if (lookahead == '_') ADVANCE(87); + if (lookahead == 'n') ADVANCE(84); if (lookahead == 'r') ADVANCE(15); if (lookahead == 'i' || - lookahead == 'j') ADVANCE(92); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(85); + lookahead == 'j') ADVANCE(94); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(86); END_STATE(); - case 86: + case 87: ACCEPT_TOKEN(sym_integer); if (lookahead == '.') ADVANCE(13); + if (lookahead == 'n') ADVANCE(84); if (lookahead == 'i' || - lookahead == 'j') ADVANCE(92); + lookahead == 'j') ADVANCE(94); if (('0' <= lookahead && lookahead <= '9') || - lookahead == '_') ADVANCE(86); + lookahead == '_') ADVANCE(87); END_STATE(); - case 87: + case 88: ACCEPT_TOKEN(sym_integer); + if (lookahead == 'n') ADVANCE(84); if (lookahead == '0' || lookahead == '1' || - lookahead == '_') ADVANCE(87); + lookahead == '_') ADVANCE(88); END_STATE(); - case 88: + case 89: ACCEPT_TOKEN(sym_integer); + if (lookahead == 'n') ADVANCE(84); if (('0' <= lookahead && lookahead <= '7') || - lookahead == '_') ADVANCE(88); + lookahead == '_') ADVANCE(89); END_STATE(); - case 89: + case 90: ACCEPT_TOKEN(sym_integer); + if (lookahead == 'n') ADVANCE(84); if (('0' <= lookahead && lookahead <= '9') || ('A' <= lookahead && lookahead <= 'F') || lookahead == '_' || - ('a' <= lookahead && lookahead <= 'f')) ADVANCE(89); + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(90); END_STATE(); - case 90: + case 91: ACCEPT_TOKEN(sym_integer); if (('0' <= lookahead && lookahead <= '9') || ('A' <= lookahead && lookahead <= 'Z') || lookahead == '_' || - ('a' <= lookahead && lookahead <= 'z')) ADVANCE(90); + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(91); END_STATE(); - case 91: + case 92: ACCEPT_TOKEN(sym_float); + END_STATE(); + case 93: + ACCEPT_TOKEN(sym_float); + if (lookahead == 'n') ADVANCE(92); if (lookahead == 'i' || - lookahead == 'j') ADVANCE(92); + lookahead == 'j') ADVANCE(94); if (('0' <= lookahead && lookahead <= '9') || - lookahead == '_') ADVANCE(91); + lookahead == '_') ADVANCE(93); END_STATE(); - case 92: + case 94: ACCEPT_TOKEN(sym_complex); END_STATE(); - case 93: + case 95: ACCEPT_TOKEN(anon_sym_DQUOTE); END_STATE(); - case 94: + case 96: ACCEPT_TOKEN(aux_sym_string_token1); - if (lookahead == '\n') ADVANCE(98); + if (lookahead == '\n') ADVANCE(100); if (lookahead != 0 && lookahead != '"' && - lookahead != '\\') ADVANCE(94); + lookahead != '\\') ADVANCE(96); END_STATE(); - case 95: + case 97: ACCEPT_TOKEN(aux_sym_string_token1); - if (lookahead == '!') ADVANCE(94); + if (lookahead == '!') ADVANCE(96); if (lookahead != 0 && lookahead != '!' && lookahead != '"' && - lookahead != '\\') ADVANCE(98); + lookahead != '\\') ADVANCE(100); END_STATE(); - case 96: + case 98: ACCEPT_TOKEN(aux_sym_string_token1); - if (lookahead == '#') ADVANCE(95); - if (lookahead == '/') ADVANCE(97); + if (lookahead == '#') ADVANCE(97); + if (lookahead == '/') ADVANCE(99); if (('\t' <= lookahead && lookahead <= '\r') || - lookahead == ' ') ADVANCE(96); + lookahead == ' ') ADVANCE(98); if (lookahead != 0 && lookahead != '"' && lookahead != '#' && - lookahead != '\\') ADVANCE(98); + lookahead != '\\') ADVANCE(100); END_STATE(); - case 97: + case 99: ACCEPT_TOKEN(aux_sym_string_token1); - if (lookahead == '/') ADVANCE(94); + if (lookahead == '/') ADVANCE(96); if (lookahead != 0 && lookahead != '"' && - lookahead != '\\') ADVANCE(98); + lookahead != '\\') ADVANCE(100); END_STATE(); - case 98: + case 100: ACCEPT_TOKEN(aux_sym_string_token1); if (lookahead != 0 && lookahead != '"' && - lookahead != '\\') ADVANCE(98); + lookahead != '\\') ADVANCE(100); END_STATE(); - case 99: + case 101: ACCEPT_TOKEN(sym_escape_sequence); END_STATE(); - case 100: + case 102: ACCEPT_TOKEN(sym_identifier); END_STATE(); - case 101: + case 103: ACCEPT_TOKEN(sym_identifier); - if (lookahead == '?') ADVANCE(100); + if (lookahead == '?') ADVANCE(102); if (('0' <= lookahead && lookahead <= '9') || ('A' <= lookahead && lookahead <= 'Z') || lookahead == '_' || - ('a' <= lookahead && lookahead <= 'z')) ADVANCE(101); + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(103); END_STATE(); - case 102: + case 104: ACCEPT_TOKEN(sym_comment); if (lookahead != 0 && - lookahead != '\n') ADVANCE(102); + lookahead != '\n') ADVANCE(104); END_STATE(); default: return false; diff --git a/ext/tree-sitter-andy-cpp/test/corpus/basics.txt b/ext/tree-sitter-andy-cpp/test/corpus/basics.txt index 22d7b437..98a01bfa 100644 --- a/ext/tree-sitter-andy-cpp/test/corpus/basics.txt +++ b/ext/tree-sitter-andy-cpp/test/corpus/basics.txt @@ -207,3 +207,32 @@ shift right after a cast comparison right: (binary_expression left: (integer) right: (integer)))) + +================== +advanced number literals +================== + +let decimal = 42n; +let float = 1.25n; +let binary = 0b101n; +let octal = 0o77n; +let hexadecimal = 0xffn; + +--- + +(source_file + (variable_declaration + name: (identifier) + value: (integer)) + (variable_declaration + name: (identifier) + value: (float)) + (variable_declaration + name: (identifier) + value: (integer)) + (variable_declaration + name: (identifier) + value: (integer)) + (variable_declaration + name: (identifier) + value: (integer))) diff --git a/ndc_bin/src/highlighter.rs b/ndc_bin/src/highlighter.rs index 66c9d908..a499d4d3 100644 --- a/ndc_bin/src/highlighter.rs +++ b/ndc_bin/src/highlighter.rs @@ -59,6 +59,8 @@ impl AndycppHighlighter { Token::BigInt(_) | Token::Int64(_) | Token::Float64(_) + | Token::NumberInt(_) + | Token::NumberFloat(_) | Token::Complex(_) | Token::Infinity | Token::True @@ -255,3 +257,28 @@ fn collect_function_spans(expr: &ExpressionLocation, spans: &mut AHashSet | Expression::Continue => {} } } + +#[cfg(test)] +mod tests { + use super::*; + use yansi::Color; + + #[test] + fn number_literals_are_highlighted_as_numbers() { + for literal in ["42n", "1.25n", "0b101n", "0o77n", "0xffn"] { + let highlighted = AndycppHighlighter::highlight_parsed(literal); + + assert_eq!( + highlighted.len(), + 1, + "unexpected tokenization for {literal}" + ); + assert_eq!(highlighted[0].value, literal); + assert_eq!( + highlighted[0].style.foreground, + Some(Color::Rgb(209, 154, 102)), + "unexpected color for {literal}" + ); + } + } +} From 2126915ef270534fa6cf0e72afd8167ffac84265 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 13:17:14 +0200 Subject: [PATCH 03/31] =?UTF-8?q?refactor(lexer):=20reject=20bare=20bigint?= =?UTF-8?q?=20literals=20=F0=9F=9A=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndc_analyser/src/analyser.rs | 14 -------- ndc_bin/src/highlighter.rs | 4 +-- ndc_lexer/src/number.rs | 68 ++++++++++++++++++++++++++++------- ndc_lexer/src/token.rs | 4 --- ndc_lsp/src/scope_resolve.rs | 1 - ndc_lsp/src/visitor.rs | 2 -- ndc_parser/src/expression.rs | 1 - ndc_parser/src/parser.rs | 1 - ndc_vm/src/compiler.rs | 4 --- tests/proptest/tests/panic.rs | 7 ++-- 10 files changed, 60 insertions(+), 46 deletions(-) diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index 4442c55f..f32d96e3 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -256,10 +256,6 @@ impl Analyser { Expression::BoolLiteral(_) => Ok(StaticType::Bool), Expression::StringLiteral(_) => Ok(StaticType::String), Expression::Int64Literal(_) => Ok(StaticType::Int), - Expression::BigIntLiteral(value) => { - self.emit(AnalysisError::integer_literal_out_of_range(value, *span)); - Ok(StaticType::Int) - } Expression::Float64Literal(_) => Ok(StaticType::Float), Expression::NumberIntLiteral(_) | Expression::NumberFloatLiteral(_) => { Ok(StaticType::Number) @@ -1286,16 +1282,6 @@ impl AnalysisError { self.help_text.as_deref() } - fn integer_literal_out_of_range(value: &impl std::fmt::Display, span: Span) -> Self { - Self { - text: format!( - "integer literal does not fit in Int; use the advanced literal `{value}n`" - ), - span, - help_text: None, - } - } - fn invalid_type_annotation(err: &StaticTypeConstructionError, span: Span) -> Self { Self { text: err.to_string(), diff --git a/ndc_bin/src/highlighter.rs b/ndc_bin/src/highlighter.rs index a499d4d3..72135c93 100644 --- a/ndc_bin/src/highlighter.rs +++ b/ndc_bin/src/highlighter.rs @@ -56,8 +56,7 @@ impl AndycppHighlighter { // Strings — green Token::String(_) => substring.rgb(152, 195, 121), // Numeric literals and booleans — orange - Token::BigInt(_) - | Token::Int64(_) + Token::Int64(_) | Token::Float64(_) | Token::NumberInt(_) | Token::NumberFloat(_) @@ -247,7 +246,6 @@ fn collect_function_spans(expr: &ExpressionLocation, spans: &mut AHashSet | Expression::StringLiteral(_) | Expression::Int64Literal(_) | Expression::Float64Literal(_) - | Expression::BigIntLiteral(_) | Expression::NumberIntLiteral(_) | Expression::NumberFloatLiteral(_) | Expression::ComplexLiteral(_) diff --git a/ndc_lexer/src/number.rs b/ndc_lexer/src/number.rs index 7f465bc0..db65d7bc 100644 --- a/ndc_lexer/src/number.rs +++ b/ndc_lexer/src/number.rs @@ -73,7 +73,7 @@ impl NumberLexer for Lexer<'_> { let token = if is_number { buf_to_number_token_with_radix(&buf, 2) } else { - buf_to_token_with_radix(&buf, 2) + buf_to_primitive_token_with_radix(&buf, 2, self.source.create_span(start_offset))? }; return match token { Some(token) => Ok(TokenLocation { @@ -102,7 +102,7 @@ impl NumberLexer for Lexer<'_> { let token = if is_number { buf_to_number_token_with_radix(&buf, 16) } else { - buf_to_token_with_radix(&buf, 16) + buf_to_primitive_token_with_radix(&buf, 16, self.source.create_span(start_offset))? }; return match token { Some(token) => Ok(TokenLocation { @@ -131,7 +131,7 @@ impl NumberLexer for Lexer<'_> { let token = if is_number { buf_to_number_token_with_radix(&buf, 8) } else { - buf_to_token_with_radix(&buf, 8) + buf_to_primitive_token_with_radix(&buf, 8, self.source.create_span(start_offset))? }; return match token { Some(token) => Ok(TokenLocation { @@ -202,7 +202,11 @@ impl NumberLexer for Lexer<'_> { )); } - return match buf_to_token_with_radix(&buf, u32::from(radix)) { + return match buf_to_primitive_token_with_radix( + &buf, + u32::from(radix), + self.source.create_span(start_offset), + )? { Some(token) => Ok(TokenLocation { token, span: self.source.create_span(start_offset), @@ -265,8 +269,9 @@ impl NumberLexer for Lexer<'_> { } } - let Some(token) = buf_to_token_with_radix(&buf, 10) - .or_else(|| buf.parse::().map(Token::Float64).ok()) + let Some(token) = + buf_to_primitive_token_with_radix(&buf, 10, self.source.create_span(start_offset))? + .or_else(|| buf.parse::().map(Token::Float64).ok()) else { // If we've lexed the int/float correctly this error should never happen, that's why it's probably safe to panic panic!("unable to convert buffer into Token"); @@ -279,14 +284,23 @@ impl NumberLexer for Lexer<'_> { } } -fn buf_to_token_with_radix(buf: &str, radix: u32) -> Option { - match i64::from_str_radix(buf, radix) { - Ok(num) => Some(Token::Int64(num)), - Err(_err) => match BigInt::from_str_radix(buf, radix) { - Ok(num) => Some(Token::BigInt(num)), - Err(_err) => None, - }, +fn buf_to_primitive_token_with_radix( + buf: &str, + radix: u32, + span: crate::Span, +) -> Result, Error> { + if let Ok(num) = i64::from_str_radix(buf, radix) { + return Ok(Some(Token::Int64(num))); } + + let Ok(value) = BigInt::from_str_radix(buf, radix) else { + return Ok(None); + }; + + Err(Error::text( + format!("integer literal does not fit in Int; use the advanced literal `{value}n`"), + span, + )) } fn buf_to_number_token_with_radix(buf: &str, radix: u32) -> Option { @@ -298,3 +312,31 @@ fn buf_to_number_token_with_radix(buf: &str, radix: u32) -> Option { fn validator_for_radix(radix: usize) -> impl Fn(char) -> bool { move |c| "0123456789abcdefghijlkmnopqrstuvwxyz"[0..radix].contains(c.to_ascii_lowercase()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::SourceId; + + #[test] + fn oversized_bare_integer_literals_are_rejected_by_the_lexer() { + for literal in [ + "9223372036854775808", + "0b1000000000000000000000000000000000000000000000000000000000000000", + "0o1000000000000000000000", + "0x8000000000000000", + "16r8000000000000000", + ] { + let error = Lexer::new(literal, SourceId::SYNTHETIC) + .next() + .expect("literal should produce a lexer result") + .expect_err("oversized bare literal should fail lexing"); + + assert_eq!( + error.to_string(), + "integer literal does not fit in Int; use the advanced literal `9223372036854775808n`", + "unexpected diagnostic for {literal}" + ); + } + } +} diff --git a/ndc_lexer/src/token.rs b/ndc_lexer/src/token.rs index cd433e7e..723d9da6 100644 --- a/ndc_lexer/src/token.rs +++ b/ndc_lexer/src/token.rs @@ -9,7 +9,6 @@ pub enum Token { String(String), Int64(i64), Float64(f64), - BigInt(BigInt), NumberInt(BigInt), NumberFloat(f64), Complex(Complex64), @@ -107,9 +106,6 @@ impl fmt::Display for Token { let mut buffer = ryu::Buffer::new(); return write!(f, "{}", buffer.format(*n)); } - Self::BigInt(n) => { - return write!(f, "{n}"); - } Self::NumberInt(n) => { return write!(f, "{n}n"); } diff --git a/ndc_lsp/src/scope_resolve.rs b/ndc_lsp/src/scope_resolve.rs index dcad6b08..1d622d9e 100644 --- a/ndc_lsp/src/scope_resolve.rs +++ b/ndc_lsp/src/scope_resolve.rs @@ -205,7 +205,6 @@ fn collect(expr: &ExpressionLocation, scope: Span, out: &mut Vec) { | Expression::StringLiteral(_) | Expression::Int64Literal(_) | Expression::Float64Literal(_) - | Expression::BigIntLiteral(_) | Expression::NumberIntLiteral(_) | Expression::NumberFloatLiteral(_) | Expression::ComplexLiteral(_) diff --git a/ndc_lsp/src/visitor.rs b/ndc_lsp/src/visitor.rs index 0ba0eea1..57a260c8 100644 --- a/ndc_lsp/src/visitor.rs +++ b/ndc_lsp/src/visitor.rs @@ -190,7 +190,6 @@ fn child_expressions(expr: &ExpressionLocation) -> Vec<&ExpressionLocation> { | Expression::StringLiteral(_) | Expression::Int64Literal(_) | Expression::Float64Literal(_) - | Expression::BigIntLiteral(_) | Expression::NumberIntLiteral(_) | Expression::NumberFloatLiteral(_) | Expression::ComplexLiteral(_) @@ -362,7 +361,6 @@ fn walk_expression(visitor: &mut impl AstVisitor, expr: &ExpressionLocation) { | Expression::StringLiteral(_) | Expression::Int64Literal(_) | Expression::Float64Literal(_) - | Expression::BigIntLiteral(_) | Expression::NumberIntLiteral(_) | Expression::NumberFloatLiteral(_) | Expression::ComplexLiteral(_) diff --git a/ndc_parser/src/expression.rs b/ndc_parser/src/expression.rs index 5d2e138f..0fd26c0d 100644 --- a/ndc_parser/src/expression.rs +++ b/ndc_parser/src/expression.rs @@ -97,7 +97,6 @@ pub enum Expression { StringLiteral(String), Int64Literal(i64), Float64Literal(f64), - BigIntLiteral(BigInt), NumberIntLiteral(BigInt), NumberFloatLiteral(f64), ComplexLiteral(Complex64), diff --git a/ndc_parser/src/parser.rs b/ndc_parser/src/parser.rs index f9f5ae72..0e3ca175 100644 --- a/ndc_parser/src/parser.rs +++ b/ndc_parser/src/parser.rs @@ -1069,7 +1069,6 @@ impl Parser { Token::True => Expression::BoolLiteral(true), Token::Int64(num) => Expression::Int64Literal(num), Token::Float64(num) => Expression::Float64Literal(num), - Token::BigInt(num) => Expression::BigIntLiteral(num), Token::NumberInt(num) => Expression::NumberIntLiteral(num), Token::NumberFloat(num) => Expression::NumberFloatLiteral(num), Token::Complex(num) => Expression::ComplexLiteral(num), diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index f6dd1961..889474bc 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -220,10 +220,6 @@ impl Compiler { let idx = self.ir.add_constant(Value::float(f)); self.ir.write(OpCode::Constant(idx), span); } - Expression::BigIntLiteral(i) => { - let idx = self.ir.add_constant(Value::bigint(i)); - self.ir.write(OpCode::Constant(idx), span); - } Expression::NumberIntLiteral(i) => { let idx = self .ir diff --git a/tests/proptest/tests/panic.rs b/tests/proptest/tests/panic.rs index a6e50337..5221801f 100644 --- a/tests/proptest/tests/panic.rs +++ b/tests/proptest/tests/panic.rs @@ -94,7 +94,7 @@ fn install_quiet_panic_hook() { } /// All unit-variant tokens, identifiers/strings/numbers from small pools, -/// and a couple of `BigInt`/`Complex` literals. Combined uniformly with a +/// and a couple of advanced-number/complex literals. Combined uniformly with a /// low-weight `OpAssign` wrapping an augmentable inner token. fn arb_token() -> impl Strategy { let mut atoms: Vec = vec![ @@ -173,8 +173,9 @@ fn arb_token() -> impl Strategy { for f in [0.0_f64, 1.0, -1.0, 0.5, f64::INFINITY, f64::NAN] { atoms.push(Token::Float64(f)); } - atoms.push(Token::BigInt(num::BigInt::from(0))); - atoms.push(Token::BigInt(num::BigInt::from(i128::MAX))); + atoms.push(Token::NumberInt(num::BigInt::from(0))); + atoms.push(Token::NumberInt(num::BigInt::from(i128::MAX))); + atoms.push(Token::NumberFloat(0.5)); atoms.push(Token::Complex(num::complex::Complex64::new(0.0, 1.0))); atoms.push(Token::Complex(num::complex::Complex64::new(2.0, -3.0))); From 15122bea5558c72879975e1f85c259d6d999eecb Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 13:24:41 +0200 Subject: [PATCH 04/31] =?UTF-8?q?fix(stdlib):=20restore=20transcendental?= =?UTF-8?q?=20function=20docs=20=F0=9F=93=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndc_stdlib/src/math.rs | 143 ++++++++++++++++++++++++++++++++++------- 1 file changed, 118 insertions(+), 25 deletions(-) diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 5074a375..a23abc12 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -87,12 +87,12 @@ fn declare( name: &str, parameters: Vec, return_type: StaticType, - documentation: &str, + documentation: impl Into, func: impl Fn(&[Value]) -> Result + 'static, ) { env.declare_global_fn(Rc::new(NativeFunction { name: name.to_string(), - documentation: Some(documentation.to_string()), + documentation: Some(documentation.into()), static_type: StaticType::Function { parameters: Some(parameters), return_type: Box::new(return_type), @@ -1041,6 +1041,25 @@ enum Transcendental { } impl Transcendental { + const ALL: [Self; 16] = [ + Self::Acos, + Self::Acosh, + Self::Asin, + Self::Asinh, + Self::Atan, + Self::Atanh, + Self::Cbrt, + Self::Cos, + Self::Exp, + Self::Ln, + Self::Log2, + Self::Log10, + Self::Sin, + Self::Sqrt, + Self::Tan, + Self::Tanh, + ]; + fn name(self) -> &'static str { match self { Self::Acos => "acos", @@ -1062,6 +1081,63 @@ impl Transcendental { } } + fn description(self) -> &'static str { + match self { + Self::Acos => "Computes the inverse cosine in radians.", + Self::Acosh => "Computes the inverse hyperbolic cosine.", + Self::Asin => "Computes the inverse sine in radians.", + Self::Asinh => "Computes the inverse hyperbolic sine.", + Self::Atan => "Computes the inverse tangent in radians.", + Self::Atanh => "Computes the inverse hyperbolic tangent.", + Self::Cbrt => "Returns the cube root of the input.", + Self::Cos => "Computes the cosine of an angle in radians.", + Self::Exp => "Raises e to the power of the input.", + Self::Ln => "Returns the natural logarithm of the input.", + Self::Log2 => "Returns the base-2 logarithm of the input.", + Self::Log10 => "Returns the base-10 logarithm of the input.", + Self::Sin => "Computes the sine of an angle in radians.", + Self::Sqrt => "Returns the square root of the input.", + Self::Tan => "Computes the tangent of an angle in radians.", + Self::Tanh => "Computes the hyperbolic tangent.", + } + } + + fn real_domain_description(self) -> &'static str { + match self { + Self::Acos | Self::Asin => " Real inputs outside [-1, 1] produce NaN.", + Self::Acosh => " Real inputs below 1 produce NaN.", + Self::Atanh => " Real inputs with an absolute value greater than 1 produce NaN.", + Self::Ln | Self::Log2 | Self::Log10 | Self::Sqrt => { + " Negative real inputs produce NaN." + } + Self::Asinh + | Self::Atan + | Self::Cbrt + | Self::Cos + | Self::Exp + | Self::Sin + | Self::Tan + | Self::Tanh => "", + } + } + + fn documentation(self, kind: NumericKind) -> String { + let mode = match kind { + NumericKind::Int => " Converts Int input to Float before evaluation and returns Float.", + NumericKind::Float => " Returns Float for Float input.", + NumericKind::Number => { + " Returns Number for real or complex Number input. Values outside the real domain continue into the complex plane." + } + }; + let domain = if kind == NumericKind::Number { + "" + } else { + self.real_domain_description() + }; + + format!("{}{domain}{mode}", self.description()) + } + fn apply_float(self, value: f64) -> f64 { match self { Self::Acos => value.acos(), @@ -1106,32 +1182,13 @@ impl Transcendental { } fn register_transcendentals(env: &mut FunctionRegistry>) { - const FUNCTIONS: [Transcendental; 16] = [ - Transcendental::Acos, - Transcendental::Acosh, - Transcendental::Asin, - Transcendental::Asinh, - Transcendental::Atan, - Transcendental::Atanh, - Transcendental::Cbrt, - Transcendental::Cos, - Transcendental::Exp, - Transcendental::Ln, - Transcendental::Log2, - Transcendental::Log10, - Transcendental::Sin, - Transcendental::Sqrt, - Transcendental::Tan, - Transcendental::Tanh, - ]; - - for function in FUNCTIONS { + for function in Transcendental::ALL { declare( env, function.name(), vec![StaticType::Int], StaticType::Float, - "Applies a transcendental function and returns a Float.", + function.documentation(NumericKind::Int), move |args| { let [Value::Int(value)] = args else { return Err(VmError::native("expected one Int argument".to_string())); @@ -1144,7 +1201,7 @@ fn register_transcendentals(env: &mut FunctionRegistry>) { function.name(), vec![StaticType::Float], StaticType::Float, - "Applies a transcendental function to a Float.", + function.documentation(NumericKind::Float), move |args| { let [Value::Float(value)] = args else { return Err(VmError::native("expected one Float argument".to_string())); @@ -1157,7 +1214,7 @@ fn register_transcendentals(env: &mut FunctionRegistry>) { function.name(), vec![StaticType::Number], StaticType::Number, - "Applies a transcendental function with complex continuation.", + function.documentation(NumericKind::Number), move |args| { let [Value::Number(value)] = args else { return Err(VmError::native("expected one Number argument".to_string())); @@ -1221,4 +1278,40 @@ mod tests { } } } + + #[test] + fn transcendental_overloads_have_function_specific_documentation() { + let mut registry = FunctionRegistry::default(); + register(&mut registry); + + for transcendental in Transcendental::ALL { + for kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + let expected_type = StaticType::Function { + parameters: Some(vec![kind.static_type()]), + return_type: Box::new(match kind { + NumericKind::Int | NumericKind::Float => StaticType::Float, + NumericKind::Number => StaticType::Number, + }), + }; + let function = registry + .iter() + .find(|function| { + function.name == transcendental.name() + && function.static_type == expected_type + }) + .unwrap_or_else(|| { + panic!( + "missing {}({}) transcendental overload", + transcendental.name(), + kind.static_type() + ) + }); + + assert_eq!( + function.documentation.as_deref(), + Some(transcendental.documentation(kind).as_str()) + ); + } + } + } } From 33a4f21f33bc8b6987d45da26b35c473de93b78c Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 14:15:05 +0200 Subject: [PATCH 05/31] =?UTF-8?q?refactor(core):=20store=20Number=20intege?= =?UTF-8?q?rs=20as=20BigInt=20=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndc_core/src/int.rs | 461 ----------------------------------- ndc_core/src/lib.rs | 1 - ndc_core/src/num.rs | 83 ++++--- ndc_macros/src/vm_convert.rs | 6 +- ndc_stdlib/src/math.rs | 21 +- ndc_stdlib/src/serde.rs | 4 +- ndc_vm/src/compiler.rs | 4 +- ndc_vm/src/value/mod.rs | 24 +- 8 files changed, 60 insertions(+), 544 deletions(-) delete mode 100644 ndc_core/src/int.rs diff --git a/ndc_core/src/int.rs b/ndc_core/src/int.rs deleted file mode 100644 index 1b583013..00000000 --- a/ndc_core/src/int.rs +++ /dev/null @@ -1,461 +0,0 @@ -use num::FromPrimitive; -use num::bigint::{Sign, ToBigInt}; -use num::complex::Complex64; -use num::traits::CheckedEuclid; -use num::{BigInt, BigRational, Signed, ToPrimitive, Zero, pow::Pow}; -use std::cmp::Ordering; -use std::fmt; -use std::hash::{Hash, Hasher}; -use std::ops::{BitAnd, BitOr, BitXor, Neg, Not, Shl, Shr}; - -#[derive(Debug, Clone)] -pub enum Int { - Int64(i64), - BigInt(BigInt), -} - -impl Int { - /// Truncates the float and returns Some if the float was not NaN or Inf - #[must_use] - pub fn from_f64_trunc(value: f64) -> Option { - if value.is_nan() || value.is_infinite() { - return None; - } - - BigInt::from_f64(value).map(Int::BigInt) - } - - /// Converts to float to an int only if it's fractal part is zero, otherwise it returns None - #[must_use] - pub fn from_f64_if_int(value: f64) -> Option { - if value.fract() == 0.0 { - Self::from_f64_trunc(value) - } else { - None - } - } - - #[must_use] - pub fn to_bigint(&self) -> BigInt { - match self { - Self::Int64(i) => BigInt::from(*i), - Self::BigInt(b) => b.clone(), - } - } - - #[must_use] - pub fn simplified(self) -> Self { - match self { - i @ Self::Int64(_) => i, - Self::BigInt(bi) => { - if let Some(i) = bi.to_i64() { - Self::Int64(i) - } else { - Self::BigInt(bi) - } - } - } - } - - pub fn checked_rem_euclid(self, rhs: &Self) -> Option { - if let (Self::Int64(p1), Self::Int64(p2)) = (&self, &rhs) - && let Some(a) = (*p1).checked_rem_euclid(*p2) - { - return Some(Self::Int64(a)); - } - - self.to_bigint() - .checked_rem_euclid(&rhs.to_bigint()) - .map(Int::BigInt) - } - - #[must_use] - pub fn checked_shl(self, rhs: Self) -> Option { - let rhs: u32 = match rhs { - Self::Int64(rhs) => rhs.try_into().ok()?, - Self::BigInt(rhs) => rhs.try_into().ok()?, - }; - - match self { - Self::Int64(lhs) => { - if let Some(result) = lhs.checked_shl(rhs) { - Some(Self::Int64(result)) - } else { - Some(Self::BigInt(lhs.to_bigint().expect("cannot fail").shl(rhs))) - } - } - Self::BigInt(big_int) => Some(Self::BigInt(big_int.shl(rhs))), - } - } - - #[must_use] - pub fn checked_shr(self, rhs: Self) -> Option { - let rhs: u32 = match rhs { - Self::Int64(rhs) => rhs.try_into().ok()?, - Self::BigInt(rhs) => rhs.try_into().ok()?, - }; - - match self { - Self::Int64(lhs) => { - if let Some(result) = lhs.checked_shr(rhs) { - Some(Self::Int64(result)) - } else { - Some(Self::BigInt(lhs.to_bigint().expect("cannot fail").shr(rhs))) - } - } - Self::BigInt(big_int) => Some(Self::BigInt(big_int.shr(rhs))), - } - } - - /// # Panics - /// This method panics if the `rhs` argument is negative - #[must_use] - pub fn pow(&self, rhs: &Self) -> Self { - let lhs = self.to_bigint(); - let rhs = rhs.to_bigint(); - match rhs.sign() { - Sign::Minus => panic!("right hand side must not be negative"), - Sign::NoSign => Self::Int64(1), - // This is kinda dumb because if the `rhs` doesn't fit in a u64 we sure as hell aren't - // going to be able to compute it in finite time - Sign::Plus => Self::BigInt(Pow::pow(lhs, rhs.magnitude())), - } - } - - #[must_use] - pub fn is_negative(&self) -> bool { - match self { - Self::Int64(i) => i.is_negative(), - Self::BigInt(i) => i.is_negative(), - } - } - - #[must_use] - pub fn is_zero(&self) -> bool { - match self { - Self::Int64(i) => i.is_zero(), - Self::BigInt(i) => i.is_zero(), - } - } - - #[must_use] - pub fn is_positive(&self) -> bool { - match self { - Self::Int64(i) => i.is_positive(), - Self::BigInt(i) => i.is_positive(), - } - } - - #[must_use] - pub fn abs(&self) -> Self { - match self { - // `i64::MIN.abs()` overflows, so promote to BigInt in that case. - Self::Int64(i) => i - .checked_abs() - .map_or_else(|| Self::from(BigInt::from(*i).abs()), Self::Int64), - Self::BigInt(b) => Self::from(b.abs()), - } - } - - #[must_use] - pub fn signum(&self) -> Self { - match self { - Self::Int64(i) => Self::Int64(i.signum()), - Self::BigInt(i) => match i.sign() { - Sign::Minus => Self::Int64(-1), - Sign::NoSign => Self::Int64(0), - Sign::Plus => Self::Int64(1), - }, - } - } -} - -impl PartialEq for Int { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::BigInt(left), Self::BigInt(right)) => left == right, - (Self::BigInt(left), Self::Int64(right)) => left == &BigInt::from(*right), - (Self::Int64(left), Self::BigInt(right)) => &BigInt::from(*left) == right, - (Self::Int64(left), Self::Int64(right)) => left == right, - } - } -} - -impl Eq for Int {} - -impl Ord for Int { - fn cmp(&self, other: &Self) -> Ordering { - match (self, other) { - (Self::Int64(l), Self::BigInt(r)) => BigInt::from(*l).cmp(r), - (Self::BigInt(l), Self::Int64(r)) => l.cmp(&BigInt::from(*r)), - (Self::Int64(l), Self::Int64(r)) => l.cmp(r), - (Self::BigInt(l), Self::BigInt(r)) => l.cmp(r), - } - } -} - -impl Hash for Int { - // This hash implementation ensures that a BigInt that fits in an i64 is hashed the same way an i64 would - fn hash(&self, state: &mut H) { - match self { - Self::Int64(i) => state.write_i64(*i), - Self::BigInt(b) => { - if let Some(i) = b.to_i64() { - state.write_i64(i); - } else { - b.hash(state); - } - } - } - } -} - -impl PartialOrd for Int { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Neg for Int { - type Output = Self; - - fn neg(self) -> Self::Output { - match self { - // `-i64::MIN` overflows, so promote to BigInt in that case. - Self::Int64(i) => i - .checked_neg() - .map_or_else(|| Self::BigInt(BigInt::from(i).neg()), Self::Int64), - Self::BigInt(i) => Self::BigInt(i.neg()), - } - } -} - -impl Not for Int { - type Output = Self; - - fn not(self) -> Self::Output { - match self { - Self::Int64(i) => i.not().into(), - Self::BigInt(big_int) => big_int.not().into(), - } - } -} - -macro_rules! impl_binary_operator { - ($trait:ident, $method:ident, $safe_method:ident) => { - impl std::ops::$trait for Int { - type Output = Int; - - fn $method(self, rhs: Self) -> Self::Output { - match (&self, &rhs) { - (Int::Int64(p1), Int::Int64(p2)) => { - if let Some(s) = p1.$safe_method(*p2) { - return Int::Int64(s); - } - } - _ => (), - } - Int::BigInt(self.to_bigint().$method(rhs.to_bigint())) - } - } - impl std::ops::$trait<&Int> for Int { - type Output = Int; - - fn $method(self, rhs: &Self) -> Self::Output { - match (&self, rhs) { - (Int::Int64(p1), Int::Int64(p2)) => { - if let Some(s) = p1.$safe_method(*p2) { - return Int::Int64(s); - } - } - _ => (), - } - Int::BigInt(self.to_bigint().$method(rhs.to_bigint())) - } - } - impl std::ops::$trait for &Int { - type Output = Int; - - fn $method(self, rhs: Int) -> Self::Output { - match (&self, &rhs) { - (Int::Int64(p1), Int::Int64(p2)) => { - if let Some(s) = p1.$safe_method(*p2) { - Int::Int64(s); - } - } - _ => {} - } - - Int::BigInt(self.to_bigint().$method(rhs.to_bigint())) - } - } - impl std::ops::$trait<&Int> for &Int { - type Output = Int; - - fn $method(self, rhs: &Int) -> Self::Output { - match (self, rhs) { - (Int::Int64(p1), Int::Int64(p2)) => { - if let Some(s) = p1.$safe_method(*p2) { - return Int::Int64(s); - } - } - _ => (), - } - Int::BigInt(self.to_bigint().$method(rhs.to_bigint())) - } - } - }; -} -impl_binary_operator!(Add, add, checked_add); -impl_binary_operator!(Sub, sub, checked_sub); -impl_binary_operator!(Mul, mul, checked_mul); -impl_binary_operator!(Div, div, checked_div); - -impl_binary_operator!(Rem, rem, checked_rem); - -impl BitAnd for &Int { - type Output = Int; - - fn bitand(self, rhs: Self) -> Self::Output { - if let (Int::Int64(p1), Int::Int64(p2)) = (self, rhs) { - return Int::Int64(p1 & p2); - } - - Int::BigInt(self.to_bigint().bitand(rhs.to_bigint())).simplified() - } -} - -impl BitAnd for Int { - type Output = Self; - - fn bitand(self, rhs: Self) -> Self::Output { - &self & &rhs - } -} - -impl BitOr for &Int { - type Output = Int; - - fn bitor(self, rhs: Self) -> Self::Output { - if let (Int::Int64(p1), Int::Int64(p2)) = (self, rhs) { - return Int::Int64(p1 | p2); - } - - Int::BigInt(self.to_bigint().bitor(rhs.to_bigint())) - } -} - -impl BitOr for Int { - type Output = Self; - - fn bitor(self, rhs: Self) -> Self::Output { - &self | &rhs - } -} - -impl BitXor for &Int { - type Output = Int; - - fn bitxor(self, rhs: Self) -> Self::Output { - if let (Int::Int64(p1), Int::Int64(p2)) = (self, rhs) { - return Int::Int64(p1 ^ p2); - } - - Int::BigInt(self.to_bigint().bitxor(rhs.to_bigint())).simplified() - } -} - -impl BitXor for Int { - type Output = Self; - - fn bitxor(self, rhs: Self) -> Self::Output { - &self ^ &rhs - } -} - -impl From for Int { - fn from(value: i32) -> Self { - Self::Int64(i64::from(value)) - } -} - -impl From for Int { - fn from(value: i64) -> Self { - Self::Int64(value) - } -} - -impl From for Int { - fn from(value: BigInt) -> Self { - Self::BigInt(value) - } -} - -impl From for f64 { - fn from(value: Int) -> Self { - Self::from(&value) - } -} - -impl From<&Int> for f64 { - fn from(value: &Int) -> Self { - match value { - Int::Int64(i) => i.to_f64().unwrap_or(Self::INFINITY), - Int::BigInt(i) => i.to_f64().unwrap_or(Self::INFINITY), - } - } -} - -impl From for BigInt { - fn from(value: Int) -> Self { - match value { - Int::Int64(i) => Self::from(i), - Int::BigInt(b) => b, - } - } -} - -impl From<&Int> for BigInt { - fn from(value: &Int) -> Self { - match value { - Int::Int64(i) => Self::from(*i), - Int::BigInt(b) => b.clone(), - } - } -} - -impl From for BigRational { - fn from(value: Int) -> Self { - Self::from(value.to_bigint()) - } -} - -impl From<&Int> for BigRational { - fn from(value: &Int) -> Self { - Self::from(value.to_bigint()) - } -} - -impl From for Complex64 { - fn from(value: Int) -> Self { - Self::from(&value) - } -} - -impl From<&Int> for Complex64 { - fn from(value: &Int) -> Self { - match value { - Int::Int64(i) => Self::from(i.to_f64().unwrap_or(f64::INFINITY)), - Int::BigInt(i) => Self::from(i.to_f64().unwrap_or(f64::INFINITY)), - } - } -} - -impl fmt::Display for Int { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Int64(i) => write!(f, "{i}"), - Self::BigInt(b) => write!(f, "{b}"), - } - } -} diff --git a/ndc_core/src/lib.rs b/ndc_core/src/lib.rs index cf869a43..42ed6460 100644 --- a/ndc_core/src/lib.rs +++ b/ndc_core/src/lib.rs @@ -1,7 +1,6 @@ pub mod compare; pub mod duration; pub mod hash_map; -pub mod int; pub mod num; pub mod static_type; pub mod r#struct; diff --git a/ndc_core/src/num.rs b/ndc_core/src/num.rs index ba04e14a..1491704f 100644 --- a/ndc_core/src/num.rs +++ b/ndc_core/src/num.rs @@ -1,18 +1,16 @@ use std::cmp::Ordering; use std::fmt; use std::hash::{Hash, Hasher}; -use std::num::TryFromIntError; use std::ops::{Add, Div, Mul, Neg, Not, Rem, Sub}; use crate::StaticType; -use crate::int::Int; use num::bigint::TryFromBigIntError; use num::complex::{Complex64, ComplexFloat}; use num::{BigInt, BigRational, Complex, FromPrimitive, Signed, ToPrimitive, Zero}; #[derive(Debug, Clone)] pub enum AdvancedNumber { - Int(Int), + Int(BigInt), Float(f64), Rational(Box), Complex(Complex64), @@ -53,15 +51,15 @@ impl CanonicalScalar { } } -impl From for AdvancedNumber { - fn from(value: Int) -> Self { +impl From for AdvancedNumber { + fn from(value: BigInt) -> Self { Self::Int(value) } } impl From for AdvancedNumber { fn from(value: i32) -> Self { - Self::Int(Int::from(value)) + Self::Int(BigInt::from(value)) } } @@ -97,7 +95,7 @@ impl PartialEq for AdvancedNumber { impl Default for AdvancedNumber { fn default() -> Self { - Self::Int(Int::Int64(0)) + Self::Int(BigInt::zero()) } } @@ -375,7 +373,7 @@ impl AdvancedNumber { fn canonical(&self) -> CanonicalNumber { match self { Self::Int(value) => CanonicalNumber { - real: CanonicalScalar::Finite(BigRational::from(value)), + real: CanonicalScalar::Finite(BigRational::from_integer(value.clone())), imaginary: CanonicalScalar::zero(), }, Self::Float(value) => CanonicalNumber { @@ -459,19 +457,21 @@ impl AdvancedNumber { /// A negative exponent yields the reciprocal as a rational, except /// `0 ^ negative`, which is division by zero and returns an error /// instead of panicking with a zero denominator. - fn int_pow(base: &Int, exponent: &Int) -> Result { + fn int_pow(base: &BigInt, exponent: &BigInt) -> Result { if exponent.is_negative() { if base.is_zero() { return Err(BinaryOperatorError::new("division by zero".to_string())); } - let exponent = exponent.to_bigint(); - let denominator = num::pow::Pow::pow(base.to_bigint(), exponent.magnitude()); + let denominator = num::pow::Pow::pow(base.clone(), exponent.magnitude()); Ok(Self::Rational(Box::new(BigRational::new( BigInt::from(1), denominator, )))) } else { - Ok(Self::Int(base.pow(exponent))) + Ok(Self::Int(num::pow::Pow::pow( + base.clone(), + exponent.magnitude(), + ))) } } @@ -481,7 +481,7 @@ impl AdvancedNumber { // in finite time. Without this guard, `2 ^ i64::MAX` hangs the VM. const MAX_EXPONENT_BITS: u64 = 32; let too_large = match &rhs { - Self::Int(Int::BigInt(b)) => b.magnitude().bits() > MAX_EXPONENT_BITS, + Self::Int(b) => b.magnitude().bits() > MAX_EXPONENT_BITS, Self::Rational(p) if p.is_integer() => p.numer().magnitude().bits() > MAX_EXPONENT_BITS, _ => false, }; @@ -495,7 +495,7 @@ impl AdvancedNumber { // Int vs others (Self::Int(p1), Self::Int(p2)) => return Self::int_pow(&p1, &p2), (Self::Int(p1), Self::Float(p2)) => { - let p1 = f64::from(p1); + let p1 = bigint_to_float(&p1); if p1 < 0.0 && p2.fract() != 0.0 { Self::Complex(Complex64::from(p1).powf(p2)) } else { @@ -503,14 +503,14 @@ impl AdvancedNumber { } } (Self::Int(p1), Self::Complex(p2)) => { - Self::Complex(Complex::from(f64::from(p1)).powc(p2)) + Self::Complex(Complex::from(bigint_to_float(&p1)).powc(p2)) } (Self::Int(p1), Self::Rational(p2)) => { if p2.is_integer() { - return Self::int_pow(&p1, &Int::BigInt(p2.to_integer())); + return Self::int_pow(&p1, &p2.to_integer()); } - let p1 = f64::from(p1); + let p1 = bigint_to_float(&p1); let p2 = rational_to_float(&p2); if p1 < 0.0 { Self::Complex(Complex64::from(p1).powf(p2)) @@ -521,7 +521,7 @@ impl AdvancedNumber { // Rational vs Others (Self::Rational(p1), Self::Int(p2)) => { - Self::Rational(Box::new(num::pow::Pow::pow(&*p1, p2.to_bigint()))) + Self::Rational(Box::new(num::pow::Pow::pow(&*p1, p2))) } (Self::Rational(p1), Self::Rational(p2)) => { if p2.is_integer() @@ -559,7 +559,7 @@ impl AdvancedNumber { } } (Self::Float(p1), Self::Complex(p2)) => Self::Complex(Complex::from(p1).powc(p2)), - (Self::Float(p1), Self::Int(p2)) => Self::Float(p1.powf(f64::from(p2))), + (Self::Float(p1), Self::Int(p2)) => Self::Float(p1.powf(bigint_to_float(&p2))), (Self::Float(p1), Self::Rational(p2)) => { let p2 = rational_to_float(&p2); if p1 < 0.0 && p2.fract() != 0.0 { @@ -573,7 +573,7 @@ impl AdvancedNumber { (Self::Complex(p1), Self::Complex(p2)) => Self::Complex(p1.powc(p2)), (Self::Complex(p1), Self::Float(p2)) => Self::Complex(p1.powc(Complex::from(p2))), (Self::Complex(p1), Self::Int(p2)) => { - Self::Complex(p1.powc(Complex::from(f64::from(p2)))) + Self::Complex(p1.powc(Complex::from(bigint_to_float(&p2)))) } (Self::Complex(p1), Self::Rational(p2)) => { Self::Complex(p1.powc(rational_to_complex(&p2))) @@ -588,12 +588,12 @@ impl AdvancedNumber { Self::Int(i) => Self::Int(i.clone()), Self::Float(f) => { if let Some(bi) = BigInt::from_f64(*f) { - Self::Int(Int::BigInt(bi).simplified()) + Self::Int(bi) } else { return Err(NumberConversionError(format!("cannot convert {f} to int"))); } } - Self::Rational(r) => Self::Int(Int::BigInt(r.to_integer()).simplified()), + Self::Rational(r) => Self::Int(r.to_integer()), Self::Complex(c) => { return Err(NumberConversionError(format!( "cannot convert complex number {c} to int" @@ -606,7 +606,7 @@ impl AdvancedNumber { #[must_use] pub fn to_complex(&self) -> Complex64 { match self { - Self::Int(i) => Complex64::from(i), + Self::Int(i) => Complex64::from(bigint_to_float(i)), Self::Float(f) => Complex64::from(f), Self::Rational(r) => rational_to_complex(r), Self::Complex(c) => *c, @@ -616,7 +616,7 @@ impl AdvancedNumber { #[must_use] pub fn to_f64(&self) -> Option { match self { - Self::Int(i) => Some(f64::from(i)), + Self::Int(i) => Some(bigint_to_float(i)), Self::Float(f) => Some(*f), Self::Rational(r) => Some(rational_to_float(r)), Self::Complex(_) => None, @@ -626,7 +626,7 @@ impl AdvancedNumber { #[must_use] pub fn to_rational(&self) -> Option { match self { - Self::Int(i) => Some(BigRational::from(i)), + Self::Int(i) => Some(BigRational::from_integer(i.clone())), Self::Rational(r) => Some(BigRational::clone(&**r)), Self::Float(_) | Self::Complex(_) => None, } @@ -669,15 +669,13 @@ macro_rules! implement_rounding { AdvancedNumber::Int(i) => AdvancedNumber::Int(i.clone()), AdvancedNumber::Float(f) => { let f = f.$method(); - if let Some(i) = Int::from_f64_trunc(f) { + if let Some(i) = BigInt::from_f64(f) { AdvancedNumber::Int(i) } else { AdvancedNumber::Float(f) } } - AdvancedNumber::Rational(r) => { - AdvancedNumber::Int(Int::BigInt(r.$method().to_integer())) - } + AdvancedNumber::Rational(r) => AdvancedNumber::Int(r.$method().to_integer()), AdvancedNumber::Complex(c) => { Complex::new(c.re.$method(), c.im.$method()).into() } @@ -695,8 +693,6 @@ implement_rounding!(round); pub enum NumberToUsizeError { #[error("expected a non-negative integer, got {0}")] UnsupportedVariant(StaticType), - #[error("expected a non-negative integer, but the value was negative")] - FromIntError(#[from] TryFromIntError), #[error("this integer is out of range (must be non-negative and small enough to be an index)")] FromBigIntError(#[from] TryFromBigIntError), } @@ -706,8 +702,7 @@ impl TryFrom for usize { fn try_from(value: AdvancedNumber) -> Result { match value { - AdvancedNumber::Int(Int::Int64(i)) => Ok(Self::try_from(i)?), - AdvancedNumber::Int(Int::BigInt(b)) => Ok(Self::try_from(b)?), + AdvancedNumber::Int(value) => Ok(Self::try_from(value)?), n => Err(NumberToUsizeError::UnsupportedVariant(n.static_type())), } } @@ -726,11 +721,12 @@ impl TryFrom<&AdvancedNumber> for f64 { fn try_from(value: &AdvancedNumber) -> Result { match value { - AdvancedNumber::Int(Int::BigInt(bi)) => bi.to_f64(), - AdvancedNumber::Int(Int::Int64(i)) => i.to_f64(), + AdvancedNumber::Int(value) => value.to_f64(), AdvancedNumber::Float(f) => Some(*f), AdvancedNumber::Rational(r) => r.to_f64(), - _ => return Err(Self::Error::UnsupportedType(value.static_type())), + AdvancedNumber::Complex(_) => { + return Err(Self::Error::UnsupportedType(value.static_type())); + } } .ok_or_else(|| Self::Error::UnsupportedValue(value.clone())) } @@ -749,10 +745,9 @@ impl TryFrom<&AdvancedNumber> for i64 { fn try_from(value: &AdvancedNumber) -> Result { match value { - AdvancedNumber::Int(Int::BigInt(bi)) => bi + AdvancedNumber::Int(integer) => integer .try_into() .map_err(|_err| NumberToIntError::UnsupportedValue(value.clone())), - AdvancedNumber::Int(Int::Int64(i)) => Ok(*i), _ => Err(Self::Error::UnsupportedType(value.static_type())), } } @@ -776,6 +771,16 @@ fn rational_to_float(r: &BigRational) -> f64 { r.to_f64().unwrap_or(f64::NAN) } +fn bigint_to_float(value: &BigInt) -> f64 { + value.to_f64().unwrap_or_else(|| { + if value.is_negative() { + f64::NEG_INFINITY + } else { + f64::INFINITY + } + }) +} + fn rational_to_complex(r: &BigRational) -> Complex { Complex::from(r.to_f64().unwrap_or(f64::NAN)) } @@ -798,7 +803,7 @@ mod tests { #[test] fn equality_and_hash_use_exact_numeric_values() { - let integer = AdvancedNumber::Int(Int::Int64(1)); + let integer = AdvancedNumber::Int(BigInt::from(1)); let float = AdvancedNumber::Float(1.0); let complex = AdvancedNumber::complex(1.0, -0.0); diff --git a/ndc_macros/src/vm_convert.rs b/ndc_macros/src/vm_convert.rs index f2e034b3..e9682ade 100644 --- a/ndc_macros/src/vm_convert.rs +++ b/ndc_macros/src/vm_convert.rs @@ -523,12 +523,10 @@ fn vm_return_for_classified(ty: &syn::Type) -> Option<(TokenStream, TokenStream) NdcType::BigInt => Some(( quote! { Ok(ndc_vm::value::Value::from_number( - ndc_core::num::AdvancedNumber::Int( - ndc_core::int::Int::BigInt(result).simplified() - ) + ndc_core::num::AdvancedNumber::Int(result) )) }, - quote! { ndc_core::StaticType::Int }, + quote! { ndc_core::StaticType::Number }, )), NdcType::BigRational => Some(( quote! { diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index a23abc12..426c27bd 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -1,5 +1,4 @@ use factorial::Factorial; -use ndc_core::int::Int; use ndc_core::num::{AdvancedNumber, BinaryOperatorError}; use ndc_core::{FunctionRegistry, StaticType}; use ndc_vm::error::VmError; @@ -210,7 +209,7 @@ fn primitive_float(kind: NumericKind, value: &Value) -> Result { fn promoted_number(kind: NumericKind, value: &Value) -> Result { match (kind, value) { - (NumericKind::Int, Value::Int(value)) => Ok(AdvancedNumber::Int(Int::Int64(*value))), + (NumericKind::Int, Value::Int(value)) => Ok(AdvancedNumber::Int(BigInt::from(*value))), (NumericKind::Float, Value::Float(value)) => Ok(AdvancedNumber::Float(*value)), (NumericKind::Number, Value::Number(value)) => Ok(value.as_ref().clone()), _ => Err(VmError::native(format!( @@ -633,7 +632,7 @@ fn aggregate(args: &[Value], kind: NumericKind, product: bool) -> Result { - let initial = AdvancedNumber::Int(Int::Int64(if product { 1 } else { 0 })); + let initial = AdvancedNumber::Int(BigInt::from(if product { 1 } else { 0 })); let value = values.try_fold(initial, |accumulator, value| { let Value::Number(value) = value else { return Err(VmError::native("expected a sequence of Number".to_string())); @@ -726,7 +725,7 @@ fn register_number_helpers(env: &mut FunctionRegistry>) { (AdvancedNumber::Complex(value), false) => AdvancedNumber::Float(value.re), (AdvancedNumber::Complex(value), true) => AdvancedNumber::Float(value.im), (_, false) => value.as_ref().clone(), - (_, true) => AdvancedNumber::Int(Int::Int64(0)), + (_, true) => AdvancedNumber::Int(BigInt::from(0)), }; Ok(Value::from_number(component)) }, @@ -746,13 +745,11 @@ fn register_number_helpers(env: &mut FunctionRegistry>) { }; let value = match value.as_ref() { AdvancedNumber::Int(value) if numerator => AdvancedNumber::Int(value.clone()), - AdvancedNumber::Int(_) => AdvancedNumber::Int(Int::Int64(1)), + AdvancedNumber::Int(_) => AdvancedNumber::Int(BigInt::from(1)), AdvancedNumber::Rational(value) if numerator => { - AdvancedNumber::Int(Int::BigInt(value.numer().clone()).simplified()) - } - AdvancedNumber::Rational(value) => { - AdvancedNumber::Int(Int::BigInt(value.denom().clone()).simplified()) + AdvancedNumber::Int(value.numer().clone()) } + AdvancedNumber::Rational(value) => AdvancedNumber::Int(value.denom().clone()), _ => { return Err(VmError::native( "expected an exact integer or rational Number".to_string(), @@ -902,7 +899,7 @@ fn exact_integer(value: &Value) -> Result { )); }; match value.as_ref() { - AdvancedNumber::Int(value) => Ok(value.to_bigint()), + AdvancedNumber::Int(value) => Ok(value.clone()), AdvancedNumber::Rational(value) if value.is_integer() => Ok(value.to_integer()), _ => Err(VmError::native( "expected an exact integer Number".to_string(), @@ -911,7 +908,7 @@ fn exact_integer(value: &Value) -> Result { } fn bigint_number(value: BigInt) -> Value { - Value::from_number(AdvancedNumber::Int(Int::BigInt(value).simplified())) + Value::from_number(AdvancedNumber::Int(value)) } fn register_conversions(env: &mut FunctionRegistry>) { @@ -981,7 +978,7 @@ fn convert_to_int(value: &Value) -> Result { Value::Int(value) => return Ok(*value), Value::Float(value) => float_to_i64(*value), Value::Number(value) => match value.as_ref() { - AdvancedNumber::Int(value) => value.to_bigint().to_i64(), + AdvancedNumber::Int(value) => value.to_i64(), AdvancedNumber::Float(value) => float_to_i64(*value), AdvancedNumber::Rational(value) => value.to_integer().to_i64(), AdvancedNumber::Complex(_) => None, diff --git a/ndc_stdlib/src/serde.rs b/ndc_stdlib/src/serde.rs index 2da22c73..0c896366 100644 --- a/ndc_stdlib/src/serde.rs +++ b/ndc_stdlib/src/serde.rs @@ -1,6 +1,5 @@ use anyhow::{Context, bail}; use ndc_core::hash_map::HashMap; -use ndc_core::int::Int; use ndc_core::num::AdvancedNumber; use ndc_macros::export_module; use ndc_vm::value::{Object, Value}; @@ -66,8 +65,7 @@ fn advanced_number_to_json( lossy: bool, ) -> Result { match number { - AdvancedNumber::Int(Int::Int64(i)) => Ok(json!(i)), - AdvancedNumber::Int(Int::BigInt(big_int)) => Number::from_str(&big_int.to_string()) + AdvancedNumber::Int(big_int) => Number::from_str(&big_int.to_string()) .map(JsonValue::Number) .context("cannot convert bigint to JSON number"), AdvancedNumber::Float(f) if f.is_finite() => Ok(json!(f)), diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index 889474bc..b920e323 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -223,9 +223,7 @@ impl Compiler { Expression::NumberIntLiteral(i) => { let idx = self .ir - .add_constant(Value::number(ndc_core::num::AdvancedNumber::Int( - ndc_core::int::Int::BigInt(i).simplified(), - ))); + .add_constant(Value::number(ndc_core::num::AdvancedNumber::Int(i))); self.ir.write(OpCode::Constant(idx), span); } Expression::NumberFloatLiteral(f) => { diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index ed093a2a..4821b61c 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -6,7 +6,6 @@ use crate::iterator::SharedIterator; use ndc_core::StaticType; use ndc_core::compare::FallibleOrd; use ndc_core::hash_map::{DefaultHasher, HashMap}; -use ndc_core::int::Int; use ndc_core::num::AdvancedNumber; use ndc_core::r#struct::StructInfo; use ndc_parser::ResolvedVar; @@ -197,7 +196,7 @@ impl Value { } pub fn bigint(i: num::BigInt) -> Self { - Self::number(AdvancedNumber::Int(Int::BigInt(i))) + Self::number(AdvancedNumber::Int(i)) } pub fn complex(c: num::complex::Complex64) -> Self { @@ -575,23 +574,6 @@ impl Value { Self::number(n) } - /// Extract an integer VM value as a `ndc_core::Int`. - /// Returns `None` for non-integer values. - pub fn to_int(&self) -> Option { - match self { - Self::Int(i) => Some(Int::Int64(*i)), - _ => None, - } - } - - /// Convert a `ndc_core::Int` to a VM value. - pub fn from_int(i: Int) -> Self { - match i { - Int::Int64(n) => Self::Int(n), - Int::BigInt(b) => Self::number(AdvancedNumber::Int(Int::BigInt(b))), - } - } - /// Convert a numeric VM value to `f64`, coercing integers and rationals. /// Returns `None` for non-numeric values (Bool, None, String, …). pub fn to_f64(&self) -> Option { @@ -931,7 +913,7 @@ impl PartialOrd for Value { /// Returns `None` for non-numeric values (Bool, None, String, List, …). fn vm_value_to_number(v: &Value) -> Option { match v { - Value::Int(i) => Some(AdvancedNumber::Int(Int::Int64(*i))), + Value::Int(i) => Some(AdvancedNumber::Int(num::BigInt::from(*i))), Value::Float(f) => Some(AdvancedNumber::Float(*f)), Value::Number(number) => Some(number.as_ref().clone()), _ => None, @@ -973,7 +955,7 @@ impl Value { .partial_cmp(&0.0) .ok_or_else(|| "NaN in comparator result".to_string()), Self::Number(number) => match number.as_ref() { - AdvancedNumber::Int(i) => Ok(i.cmp(&Int::Int64(0))), + AdvancedNumber::Int(i) => Ok(i.cmp(&num::BigInt::from(0))), AdvancedNumber::Rational(r) => Ok(r .as_ref() .cmp(&num::BigRational::from(num::BigInt::from(0)))), From 662385dbcec70039b5b1dde3edae4d591922bd8c Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 14:37:18 +0200 Subject: [PATCH 06/31] =?UTF-8?q?perf(vm):=20compare=20primitive=20numeric?= =?UTF-8?q?s=20directly=20=E2=9A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndc_vm/src/value/mod.rs | 115 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 4821b61c..ac8501b0 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -897,6 +897,15 @@ impl fmt::Debug for Object { impl PartialOrd for Value { fn partial_cmp(&self, other: &Self) -> Option { + match (self, other) { + (Self::Int(a), Self::Int(b)) => return Some(a.cmp(b)), + (Self::Float(a), Self::Float(b)) => return Some(compare_floats(*a, *b)), + (Self::Int(a), Self::Float(b)) => return Some(compare_int_float(*a, *b)), + (Self::Float(a), Self::Int(b)) => return Some(compare_int_float(*b, *a).reverse()), + (Self::Number(a), Self::Number(b)) => return a.partial_cmp(b), + _ => {} + } + if self.is_number() && other.is_number() { return vm_value_to_number(self)?.partial_cmp(&vm_value_to_number(other)?); } @@ -909,6 +918,41 @@ impl PartialOrd for Value { } } +/// Match `AdvancedNumber`'s total ordering without constructing exact rational +/// representations for two values that are already stored as floats. +fn compare_floats(left: f64, right: f64) -> Ordering { + match (left.is_nan(), right.is_nan()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => left + .partial_cmp(&right) + .expect("non-NaN floats are totally ordered"), + } +} + +/// Compare an `i64` to an `f64` exactly, without first allocating a `BigInt` +/// and exact `BigRational` for their `AdvancedNumber` representations. +fn compare_int_float(integer: i64, float: f64) -> Ordering { + const I64_MIN_AS_F64: f64 = i64::MIN as f64; + const I64_UPPER_BOUND_AS_F64: f64 = i64::MAX as f64; + + if float.is_nan() || float >= I64_UPPER_BOUND_AS_F64 { + return Ordering::Less; + } + if float < I64_MIN_AS_F64 { + return Ordering::Greater; + } + + let truncated = float.trunc() as i64; + match integer.cmp(&truncated) { + Ordering::Equal if float.fract() == 0.0 => Ordering::Equal, + Ordering::Equal if float.is_sign_positive() => Ordering::Less, + Ordering::Equal => Ordering::Greater, + ordering => ordering, + } +} + /// Convert a VM numeric value to an `AdvancedNumber` for cross-type comparison. /// Returns `None` for non-numeric values (Bool, None, String, List, …). fn vm_value_to_number(v: &Value) -> Option { @@ -974,6 +1018,17 @@ impl Value { impl PartialEq for Value { fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Int(a), Self::Int(b)) => return a == b, + (Self::Float(a), Self::Float(b)) => { + return compare_floats(*a, *b) == Ordering::Equal; + } + (Self::Int(a), Self::Float(b)) => return compare_int_float(*a, *b) == Ordering::Equal, + (Self::Float(a), Self::Int(b)) => return compare_int_float(*b, *a) == Ordering::Equal, + (Self::Number(a), Self::Number(b)) => return a == b, + _ => {} + } + if self.is_number() && other.is_number() { return match (vm_value_to_number(self), vm_value_to_number(other)) { (Some(left), Some(right)) => left == right, @@ -1199,4 +1254,64 @@ mod tests { // Break the reference cycle so the test does not leak its allocations. elements.borrow_mut().clear(); } + + #[test] + fn same_representation_numbers_compare_without_changing_semantics() { + assert!(Value::int(-1) < Value::int(1)); + assert_eq!(Value::float(-0.0), Value::float(0.0)); + assert_eq!(Value::float(f64::NAN), Value::float(f64::NAN)); + assert!(Value::float(f64::NAN) > Value::float(f64::INFINITY)); + + let one = Value::bigint(num::BigInt::from(1)); + let two = Value::bigint(num::BigInt::from(2)); + assert!(one < two); + } + + #[test] + fn cross_representation_numbers_keep_exact_comparison_semantics() { + let integers = [ + i64::MIN, + i64::MIN + 1, + -9_007_199_254_740_993, + -1, + 0, + 1, + 9_007_199_254_740_993, + i64::MAX, + ]; + let floats = [ + f64::NEG_INFINITY, + i64::MIN as f64, + -9_007_199_254_740_992.0, + -1.5, + -0.0, + 0.5, + 1.0, + 9_007_199_254_740_992.0, + i64::MAX as f64, + f64::INFINITY, + f64::NAN, + ]; + + for integer in integers { + for float in floats { + let fast_integer = Value::int(integer); + let fast_float = Value::float(float); + let canonical_integer = AdvancedNumber::Int(num::BigInt::from(integer)); + let canonical_float = AdvancedNumber::Float(float); + + assert_eq!( + fast_integer.partial_cmp(&fast_float), + canonical_integer.partial_cmp(&canonical_float), + "ordering differs for {integer} and {float}" + ); + assert_eq!( + fast_integer == fast_float, + canonical_integer == canonical_float, + "equality differs for {integer} and {float}" + ); + } + } + } + } From 62d982f8f760c259d5e02448001eba750f28902d Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 14:58:19 +0200 Subject: [PATCH 07/31] =?UTF-8?q?perf(stdlib):=20prioritize=20homogeneous?= =?UTF-8?q?=20numeric=20overloads=20=F0=9F=8F=8E=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndc_stdlib/src/math.rs | 87 +++++++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 426c27bd..70c99e40 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -125,8 +125,22 @@ fn result_kind(left: NumericKind, right: NumericKind) -> NumericKind { } } +/// Runtime overload candidates are inspected in reverse registration order. +/// Keep homogeneous primitive pairs first in that runtime order: dynamic code +/// usually preserves one numeric representation across repeated operations. +const NUMERIC_PAIRS_BY_DYNAMIC_PRIORITY: [(NumericKind, NumericKind); 9] = [ + (NumericKind::Int, NumericKind::Int), + (NumericKind::Float, NumericKind::Float), + (NumericKind::Number, NumericKind::Number), + (NumericKind::Int, NumericKind::Float), + (NumericKind::Float, NumericKind::Int), + (NumericKind::Int, NumericKind::Number), + (NumericKind::Number, NumericKind::Int), + (NumericKind::Float, NumericKind::Number), + (NumericKind::Number, NumericKind::Float), +]; + fn register_binary_arithmetic(env: &mut FunctionRegistry>) { - const KINDS: [NumericKind; 3] = [NumericKind::Int, NumericKind::Float, NumericKind::Number]; const OPERATIONS: [BinaryOperation; 8] = [ BinaryOperation::Add, BinaryOperation::Sub, @@ -139,28 +153,26 @@ fn register_binary_arithmetic(env: &mut FunctionRegistry>) { ]; for operation in OPERATIONS { - for left_kind in KINDS { - for right_kind in KINDS { - let output_kind = result_kind(left_kind, right_kind); - declare( - env, - operation.name(), - vec![left_kind.static_type(), right_kind.static_type()], - output_kind.static_type(), - operation.documentation(), - move |args| { - arity(args, 2)?; - eval_binary( - operation, - left_kind, - right_kind, - output_kind, - &args[0], - &args[1], - ) - }, - ); - } + for (left_kind, right_kind) in NUMERIC_PAIRS_BY_DYNAMIC_PRIORITY.into_iter().rev() { + let output_kind = result_kind(left_kind, right_kind); + declare( + env, + operation.name(), + vec![left_kind.static_type(), right_kind.static_type()], + output_kind.static_type(), + operation.documentation(), + move |args| { + arity(args, 2)?; + eval_binary( + operation, + left_kind, + right_kind, + output_kind, + &args[0], + &args[1], + ) + }, + ); } } } @@ -1276,6 +1288,35 @@ mod tests { } } + #[test] + fn arithmetic_registration_prefers_homogeneous_dynamic_operands() { + let mut registry = FunctionRegistry::default(); + register(&mut registry); + + for operator in ["+", "-", "*", "/", "\\", "%", "%%", "^"] { + let dynamic_order = registry + .iter() + .filter(|function| function.name == operator) + .filter_map(|function| match &function.static_type { + StaticType::Function { + parameters: Some(parameters), + .. + } if parameters.len() == 2 && parameters.iter().all(StaticType::is_number) => { + Some((parameters[0].clone(), parameters[1].clone())) + } + _ => None, + }) + .rev() + .collect::>(); + let expected = NUMERIC_PAIRS_BY_DYNAMIC_PRIORITY + .iter() + .map(|(left, right)| (left.static_type(), right.static_type())) + .collect::>(); + + assert_eq!(dynamic_order, expected, "unexpected {operator} priority"); + } + } + #[test] fn transcendental_overloads_have_function_specific_documentation() { let mut registry = FunctionRegistry::default(); From c99f5127462cb8e0e308d72a6feac4489d57e6a6 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 15:02:53 +0200 Subject: [PATCH 08/31] =?UTF-8?q?perf(stdlib):=20fold=20borrowed=20numeric?= =?UTF-8?q?=20collections=20=F0=9F=A7=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndc_stdlib/src/math.rs | 37 ++++++++++++++++--- .../programs/604_stdlib_math/001_sum.ndc | 6 +++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 70c99e40..40435281 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -609,21 +609,46 @@ fn register_aggregates(env: &mut FunctionRegistry>) { fn aggregate(args: &[Value], kind: NumericKind, product: bool) -> Result { arity(args, 1)?; - let mut values = args[0] + + if let Value::Object(object) = &args[0] { + match object.as_ref() { + Object::List(values) => { + let values = values.borrow(); + return aggregate_values(values.iter(), kind, product); + } + Object::Tuple(values) => return aggregate_values(values.iter(), kind, product), + Object::Deque(values) => { + let values = values.borrow(); + return aggregate_values(values.iter(), kind, product); + } + _ => {} + } + } + + let values = args[0] .clone() .try_into_iter() .ok_or_else(|| VmError::native("expected a sequence".to_string()))?; + aggregate_values(values, kind, product) +} + +fn aggregate_values(mut values: I, kind: NumericKind, product: bool) -> Result +where + I: Iterator, + I::Item: std::borrow::Borrow, +{ match kind { NumericKind::Int => { let initial: i64 = if product { 1 } else { 0 }; let value = values.try_fold(initial, |accumulator, value| { + let value = std::borrow::Borrow::borrow(&value); let Value::Int(value) = value else { return Err(VmError::native("expected a sequence of Int".to_string())); }; if product { - accumulator.checked_mul(value) + accumulator.checked_mul(*value) } else { - accumulator.checked_add(value) + accumulator.checked_add(*value) } .ok_or_else(|| VmError::native("integer aggregate overflowed".to_string())) })?; @@ -632,13 +657,14 @@ fn aggregate(args: &[Value], kind: NumericKind, product: bool) -> Result { let initial = if product { 1.0 } else { 0.0 }; let value = values.try_fold(initial, |accumulator, value| { + let value = std::borrow::Borrow::borrow(&value); let Value::Float(value) = value else { return Err(VmError::native("expected a sequence of Float".to_string())); }; Ok::<_, VmError>(if product { - accumulator * value + accumulator * *value } else { - accumulator + value + accumulator + *value }) })?; Ok(Value::Float(value)) @@ -646,6 +672,7 @@ fn aggregate(args: &[Value], kind: NumericKind, product: bool) -> Result { let initial = AdvancedNumber::Int(BigInt::from(if product { 1 } else { 0 })); let value = values.try_fold(initial, |accumulator, value| { + let value = std::borrow::Borrow::borrow(&value); let Value::Number(value) = value else { return Err(VmError::native("expected a sequence of Number".to_string())); }; diff --git a/tests/functional/programs/604_stdlib_math/001_sum.ndc b/tests/functional/programs/604_stdlib_math/001_sum.ndc index d6ddd127..0f1b33a2 100644 --- a/tests/functional/programs/604_stdlib_math/001_sum.ndc +++ b/tests/functional/programs/604_stdlib_math/001_sum.ndc @@ -3,3 +3,9 @@ assert_eq((1,2,3,4).sum(), 10); assert_eq([1,2,3,4,5].sum(), 15); assert_eq([1.0,2.0,3.0].sum(), 6.0); assert_eq([1n,2n,3n].sum(), 6n); + +let deque = Deque(); +deque.push_back(1); +deque.push_back(2); +deque.push_back(3); +assert_eq(deque.sum(), 6); From fc59d757f5901fbdee4a8f208bdb9b4b7d9e64e5 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 15:18:17 +0200 Subject: [PATCH 09/31] =?UTF-8?q?fix(bench):=20use=20Number=20for=20bigint?= =?UTF-8?q?=20benchmark=20=F0=9F=94=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- benches/programs/bigint.ndc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benches/programs/bigint.ndc b/benches/programs/bigint.ndc index 1fa12ab6..21703a0a 100644 --- a/benches/programs/bigint.ndc +++ b/benches/programs/bigint.ndc @@ -1,4 +1,4 @@ -let result = 1; +let result = 1n; for i in 2..=5000 { result *= i; } From 43a40c0db4538158f3ce230a43b7f4d8684a8c43 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 21:09:31 +0200 Subject: [PATCH 10/31] =?UTF-8?q?fix(stdlib):=20add=20explicit=20numeric?= =?UTF-8?q?=20overloads=20for=20randf=20and=20randi=20=F0=9F=8E=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ndc_stdlib/src/rand.rs | 110 ++++++++++++++++-- .../606_stdlib_rand/001_randf_int_bounds.ndc | 10 ++ .../002_randf_float_bounds.ndc | 15 +++ .../003_randf_mixed_bounds.ndc | 20 ++++ .../004_randf_number_bounds.ndc | 15 +++ .../606_stdlib_rand/005_randi_bounds.ndc | 16 +++ .../006_randi_number_bounds.ndc | 13 +++ .../007_randf_invalid_bounds_error.ndc | 2 + .../008_randi_float_bounds_error.ndc | 3 + 9 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 tests/functional/programs/606_stdlib_rand/001_randf_int_bounds.ndc create mode 100644 tests/functional/programs/606_stdlib_rand/002_randf_float_bounds.ndc create mode 100644 tests/functional/programs/606_stdlib_rand/003_randf_mixed_bounds.ndc create mode 100644 tests/functional/programs/606_stdlib_rand/004_randf_number_bounds.ndc create mode 100644 tests/functional/programs/606_stdlib_rand/005_randi_bounds.ndc create mode 100644 tests/functional/programs/606_stdlib_rand/006_randi_number_bounds.ndc create mode 100644 tests/functional/programs/606_stdlib_rand/007_randf_invalid_bounds_error.ndc create mode 100644 tests/functional/programs/606_stdlib_rand/008_randi_float_bounds_error.ndc diff --git a/ndc_stdlib/src/rand.rs b/ndc_stdlib/src/rand.rs index 5eac6147..6d24324d 100644 --- a/ndc_stdlib/src/rand.rs +++ b/ndc_stdlib/src/rand.rs @@ -43,38 +43,128 @@ mod inner { } #[function(name = "randf")] - /// Generate a random number between 0 (inclusive) and 1 (exclusive) + /// Generate a random Float between 0 (inclusive) and 1 (exclusive) pub fn randf_0() -> anyhow::Result { random_n(0.0, 1.0) } #[function(name = "randf")] - /// Generate a random number between 0 (inclusive) and `upper` (exclusive) - pub fn randf_1(upper: &AdvancedNumber) -> anyhow::Result { + /// Generate a random Float between 0 (inclusive) and `upper` (exclusive) + pub fn randf_upper_int(upper: i64) -> anyhow::Result { + random_n(0.0, upper as f64) + } + + #[function(name = "randf")] + /// Generate a random Float between 0 (inclusive) and `upper` (exclusive) + pub fn randf_upper_float(upper: f64) -> anyhow::Result { + random_n(0.0, upper) + } + + #[function(name = "randf")] + /// Generate a random Float between 0 (inclusive) and `upper` (exclusive) + pub fn randf_upper_number(upper: &AdvancedNumber) -> anyhow::Result { random_n(0.0, upper.try_into()?) } #[function(name = "randf")] - /// Generate a random number between `lower` (inclusive) and `upper` (exclusive) - pub fn randf_2(lower: &AdvancedNumber, upper: &AdvancedNumber) -> anyhow::Result { + /// Generate a random Float between `lower` (inclusive) and `upper` (exclusive) + pub fn randf_int_int(lower: i64, upper: i64) -> anyhow::Result { + random_n(lower as f64, upper as f64) + } + + #[function(name = "randf")] + /// Generate a random Float between `lower` (inclusive) and `upper` (exclusive) + pub fn randf_int_float(lower: i64, upper: f64) -> anyhow::Result { + random_n(lower as f64, upper) + } + + #[function(name = "randf")] + /// Generate a random Float between `lower` (inclusive) and `upper` (exclusive) + pub fn randf_float_int(lower: f64, upper: i64) -> anyhow::Result { + random_n(lower, upper as f64) + } + + #[function(name = "randf")] + /// Generate a random Float between `lower` (inclusive) and `upper` (exclusive) + pub fn randf_float_float(lower: f64, upper: f64) -> anyhow::Result { + random_n(lower, upper) + } + + #[function(name = "randf")] + /// Generate a random Float between `lower` (inclusive) and `upper` (exclusive) + pub fn randf_int_number(lower: i64, upper: &AdvancedNumber) -> anyhow::Result { + random_n(lower as f64, upper.try_into()?) + } + + #[function(name = "randf")] + /// Generate a random Float between `lower` (inclusive) and `upper` (exclusive) + pub fn randf_number_int(lower: &AdvancedNumber, upper: i64) -> anyhow::Result { + random_n(lower.try_into()?, upper as f64) + } + + #[function(name = "randf")] + /// Generate a random Float between `lower` (inclusive) and `upper` (exclusive) + pub fn randf_float_number(lower: f64, upper: &AdvancedNumber) -> anyhow::Result { + random_n(lower, upper.try_into()?) + } + + #[function(name = "randf")] + /// Generate a random Float between `lower` (inclusive) and `upper` (exclusive) + pub fn randf_number_float(lower: &AdvancedNumber, upper: f64) -> anyhow::Result { + random_n(lower.try_into()?, upper) + } + + #[function(name = "randf")] + /// Generate a random Float between `lower` (inclusive) and `upper` (exclusive) + pub fn randf_number_number( + lower: &AdvancedNumber, + upper: &AdvancedNumber, + ) -> anyhow::Result { random_n(lower.try_into()?, upper.try_into()?) } #[function(name = "randi")] - /// Generate a random number between 0 (inclusive) and 1 (exclusive) + /// Generate a random Int between 0 (inclusive) and the maximum Int value (exclusive) pub fn randi_0() -> anyhow::Result { random_n(0, i64::MAX) } #[function(name = "randi")] - /// Generate a random number between 0 (inclusive) and `upper` (exclusive) - pub fn randi_1(upper: &AdvancedNumber) -> anyhow::Result { + /// Generate a random Int between 0 (inclusive) and `upper` (exclusive) + pub fn randi_upper_int(upper: i64) -> anyhow::Result { + random_n(0, upper) + } + + #[function(name = "randi")] + /// Generate a random Int between 0 (inclusive) and `upper` (exclusive) + pub fn randi_upper_number(upper: &AdvancedNumber) -> anyhow::Result { random_n(0, upper.try_into()?) } #[function(name = "randi")] - /// Generate a random number between `lower` (inclusive) and `upper` (exclusive) - pub fn randi_2(lower: &AdvancedNumber, upper: &AdvancedNumber) -> anyhow::Result { + /// Generate a random Int between `lower` (inclusive) and `upper` (exclusive) + pub fn randi_int_int(lower: i64, upper: i64) -> anyhow::Result { + random_n(lower, upper) + } + + #[function(name = "randi")] + /// Generate a random Int between `lower` (inclusive) and `upper` (exclusive) + pub fn randi_int_number(lower: i64, upper: &AdvancedNumber) -> anyhow::Result { + random_n(lower, upper.try_into()?) + } + + #[function(name = "randi")] + /// Generate a random Int between `lower` (inclusive) and `upper` (exclusive) + pub fn randi_number_int(lower: &AdvancedNumber, upper: i64) -> anyhow::Result { + random_n(lower.try_into()?, upper) + } + + #[function(name = "randi")] + /// Generate a random Int between `lower` (inclusive) and `upper` (exclusive) + pub fn randi_number_number( + lower: &AdvancedNumber, + upper: &AdvancedNumber, + ) -> anyhow::Result { random_n(lower.try_into()?, upper.try_into()?) } } diff --git a/tests/functional/programs/606_stdlib_rand/001_randf_int_bounds.ndc b/tests/functional/programs/606_stdlib_rand/001_randf_int_bounds.ndc new file mode 100644 index 00000000..0142fc15 --- /dev/null +++ b/tests/functional/programs/606_stdlib_rand/001_randf_int_bounds.ndc @@ -0,0 +1,10 @@ +// randf accepts Int bounds and always returns a Float +for _ in 0..100 { + let x: Float = randf(-1, 1); + assert(x >= -1.0 and x < 1.0); +} + +for _ in 0..100 { + let x: Float = randf(5); + assert(x >= 0.0 and x < 5.0); +} diff --git a/tests/functional/programs/606_stdlib_rand/002_randf_float_bounds.ndc b/tests/functional/programs/606_stdlib_rand/002_randf_float_bounds.ndc new file mode 100644 index 00000000..4f3110b4 --- /dev/null +++ b/tests/functional/programs/606_stdlib_rand/002_randf_float_bounds.ndc @@ -0,0 +1,15 @@ +// randf accepts Float bounds +for _ in 0..100 { + let x: Float = randf(); + assert(x >= 0.0 and x < 1.0); +} + +for _ in 0..100 { + let x: Float = randf(0.5); + assert(x >= 0.0 and x < 0.5); +} + +for _ in 0..100 { + let x: Float = randf(-2.5, 2.5); + assert(x >= -2.5 and x < 2.5); +} diff --git a/tests/functional/programs/606_stdlib_rand/003_randf_mixed_bounds.ndc b/tests/functional/programs/606_stdlib_rand/003_randf_mixed_bounds.ndc new file mode 100644 index 00000000..6995b3d4 --- /dev/null +++ b/tests/functional/programs/606_stdlib_rand/003_randf_mixed_bounds.ndc @@ -0,0 +1,20 @@ +// randf accepts any mix of Int, Float, and Number bounds +for _ in 0..100 { + let x: Float = randf(-1, 1.0); + assert(x >= -1.0 and x < 1.0); +} + +for _ in 0..100 { + let x: Float = randf(-1.0, 1); + assert(x >= -1.0 and x < 1.0); +} + +for _ in 0..100 { + let x: Float = randf(-1n, 1); + assert(x >= -1.0 and x < 1.0); +} + +for _ in 0..100 { + let x: Float = randf(-1.0, 1n); + assert(x >= -1.0 and x < 1.0); +} diff --git a/tests/functional/programs/606_stdlib_rand/004_randf_number_bounds.ndc b/tests/functional/programs/606_stdlib_rand/004_randf_number_bounds.ndc new file mode 100644 index 00000000..90653f72 --- /dev/null +++ b/tests/functional/programs/606_stdlib_rand/004_randf_number_bounds.ndc @@ -0,0 +1,15 @@ +// randf accepts Number bounds, including rationals +for _ in 0..100 { + let x: Float = randf(2n); + assert(x >= 0.0 and x < 2.0); +} + +for _ in 0..100 { + let x: Float = randf(-1n, 1n); + assert(x >= -1.0 and x < 1.0); +} + +for _ in 0..100 { + let x: Float = randf(1n / 4n, 1n / 2n); + assert(x >= 0.25 and x < 0.5); +} diff --git a/tests/functional/programs/606_stdlib_rand/005_randi_bounds.ndc b/tests/functional/programs/606_stdlib_rand/005_randi_bounds.ndc new file mode 100644 index 00000000..2533b974 --- /dev/null +++ b/tests/functional/programs/606_stdlib_rand/005_randi_bounds.ndc @@ -0,0 +1,16 @@ +// randi accepts Int bounds and always returns an Int +let a: Int = randi(); +assert(a >= 0); + +for _ in 0..100 { + let x: Int = randi(10); + assert(x >= 0 and x < 10); +} + +for _ in 0..100 { + let x: Int = randi(-5, 5); + assert(x >= -5 and x < 5); +} + +// a single-value range is deterministic +assert_eq(randi(5, 6), 5); diff --git a/tests/functional/programs/606_stdlib_rand/006_randi_number_bounds.ndc b/tests/functional/programs/606_stdlib_rand/006_randi_number_bounds.ndc new file mode 100644 index 00000000..8a6aade1 --- /dev/null +++ b/tests/functional/programs/606_stdlib_rand/006_randi_number_bounds.ndc @@ -0,0 +1,13 @@ +// randi accepts integer Number bounds in any combination with Int +for _ in 0..100 { + let x: Int = randi(10n); + assert(x >= 0 and x < 10); +} + +for _ in 0..100 { + let x: Int = randi(-5n, 5n); + assert(x >= -5 and x < 5); +} + +assert_eq(randi(5n, 6), 5); +assert_eq(randi(5, 6n), 5); diff --git a/tests/functional/programs/606_stdlib_rand/007_randf_invalid_bounds_error.ndc b/tests/functional/programs/606_stdlib_rand/007_randf_invalid_bounds_error.ndc new file mode 100644 index 00000000..e5a9acac --- /dev/null +++ b/tests/functional/programs/606_stdlib_rand/007_randf_invalid_bounds_error.ndc @@ -0,0 +1,2 @@ +// expect-error: cannot be greater than +randf(1, -1); diff --git a/tests/functional/programs/606_stdlib_rand/008_randi_float_bounds_error.ndc b/tests/functional/programs/606_stdlib_rand/008_randi_float_bounds_error.ndc new file mode 100644 index 00000000..dd5c956c --- /dev/null +++ b/tests/functional/programs/606_stdlib_rand/008_randi_float_bounds_error.ndc @@ -0,0 +1,3 @@ +// randi deliberately has no Float overloads: rounding direction would be ambiguous +// expect-error: No function called 'randi' +randi(0.5, 1.5); From d7e4713b8abf354d41a088d594a28763abfc9c26 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Tue, 1 Sep 2026 11:05:30 +0200 Subject: [PATCH 11/31] =?UTF-8?q?test(lsp):=20adapt=20completion=20groupin?= =?UTF-8?q?g=20to=20sibling=20numeric=20types=20=F0=9F=A7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ndc_lsp/src/features/completion.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ndc_lsp/src/features/completion.rs b/ndc_lsp/src/features/completion.rs index b3777ebf..5e3a8d37 100644 --- a/ndc_lsp/src/features/completion.rs +++ b/ndc_lsp/src/features/completion.rs @@ -497,11 +497,15 @@ mod tests { panic!("expected Array response"); }; - // One item per arity; type permutations within an arity fold into the - // common supertype (`Number` subsumes `Int` and `Float`). + // One item per arity; type permutations within an arity fold into a + // union display (`Int`, `Float` and `Number` are sibling types). assert_eq!( parameter_details(&items, "randf"), - vec!["()", "(Number)", "(Number, Number)"] + vec![ + "()", + "(Int | Float | Number)", + "(Int | Number, Int | Number)" + ] ); } @@ -528,7 +532,10 @@ mod tests { panic!("expected Array response"); }; - assert_eq!(parameter_details(&items, "randf"), vec!["()", "(Number)"]); + assert_eq!( + parameter_details(&items, "randf"), + vec!["()", "(Number | Int)"] + ); } #[test] From 322b50e85b7d5c2847ba6651c0631cab53551a05 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 2 Sep 2026 15:59:21 +0200 Subject: [PATCH 12/31] =?UTF-8?q?test:=20cast=20into=20typed=20collections?= =?UTF-8?q?=20for=20numeric=20dispatch=20=F0=9F=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch keeps master's no-scan policy, so a value the analyser only knows as `List` no longer reaches the typed `sum` overloads. An `as` cast is the supported spelling: it scans once, at a site the author wrote. `016_casts/024` pins that a cast selects a *typed* container overload — `002_list_downcast_from_any` only reaches `sorted(Sequence)`, so nothing covered that. It includes the empty case, since `[].sum()` cannot be written without a cast. `007_numeric_hierarchy` was written to change here — with Int, Float, and Number siblings, `1 as Number` can never hold, so it moves to an error test and the file keeps only the casts that recover a numeric type from Any. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_vm/src/value/mod.rs | 1 - .../016_casts/007_numeric_hierarchy.ndc | 18 +++++++++++------- .../023_numeric_sibling_cast_error.ndc | 4 ++++ .../024_cast_selects_typed_overload.ndc | 18 ++++++++++++++++++ .../programs/604_stdlib_math/001_sum.ndc | 4 +++- 5 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 tests/functional/programs/016_casts/023_numeric_sibling_cast_error.ndc create mode 100644 tests/functional/programs/016_casts/024_cast_selects_typed_overload.ndc diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index ac8501b0..1ba7d6cd 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -1313,5 +1313,4 @@ mod tests { } } } - } diff --git a/tests/functional/programs/016_casts/007_numeric_hierarchy.ndc b/tests/functional/programs/016_casts/007_numeric_hierarchy.ndc index fb5d54a1..8f0a91a3 100644 --- a/tests/functional/programs/016_casts/007_numeric_hierarchy.ndc +++ b/tests/functional/programs/016_casts/007_numeric_hierarchy.ndc @@ -1,7 +1,11 @@ -// Casts that depend on the numeric subtype hierarchy. Isolated here on -// purpose: the explicit-numeric-modes work makes Int, Float, and Number -// siblings, so this file is expected to change when that lands. -assert_eq(1 as Number, 1); -assert_eq(1.5 as Number, 1.5); -let value: Any = 1; -assert_eq(value as Int, 1); +// Int, Float, and Number are sibling types, so a cast between them can never +// succeed — `Number()`, `int()`, and `float()` are the conversions. A cast +// only recovers a numeric type that is hidden behind Any. +let integer: Any = 1; +assert_eq(integer as Int, 1); + +let float: Any = 1.5; +assert_eq(float as Float, 1.5); + +let advanced: Any = 1n; +assert_eq(advanced as Number, 1n); diff --git a/tests/functional/programs/016_casts/023_numeric_sibling_cast_error.ndc b/tests/functional/programs/016_casts/023_numeric_sibling_cast_error.ndc new file mode 100644 index 00000000..da4deb7a --- /dev/null +++ b/tests/functional/programs/016_casts/023_numeric_sibling_cast_error.ndc @@ -0,0 +1,4 @@ +// A cast asserts, it does not convert, and Int is not a Number under the +// sibling numeric model — `Number(1)` is the conversion. +// expect-error: invalid cast: Int can never be Number +1 as Number diff --git a/tests/functional/programs/016_casts/024_cast_selects_typed_overload.ndc b/tests/functional/programs/016_casts/024_cast_selects_typed_overload.ndc new file mode 100644 index 00000000..48b36fb8 --- /dev/null +++ b/tests/functional/programs/016_casts/024_cast_selects_typed_overload.ndc @@ -0,0 +1,18 @@ +// A cast is what lets a typed container overload be selected. `sum` has +// separate Int, Float, and Number overloads, and a value the analyser only +// knows as List matches none of them, so each cast below picks a +// different overload at compile time. +let values = %{}; +values.insert(1); +values.insert(2); +assert_eq((values.keys as List).sum(), 3); + +let floats: List = [1.0, 2.0, 3.0]; +assert_eq((floats as List).sum(), 6.0); + +let numbers: List = [1n, 2n, 3n]; +assert_eq((numbers as List).sum(), 6n); + +// An empty container conforms vacuously, so the overload still resolves. +let empty: List = []; +assert_eq((empty as List).sum(), 0); diff --git a/tests/functional/programs/604_stdlib_math/001_sum.ndc b/tests/functional/programs/604_stdlib_math/001_sum.ndc index 0f1b33a2..c5b6af0f 100644 --- a/tests/functional/programs/604_stdlib_math/001_sum.ndc +++ b/tests/functional/programs/604_stdlib_math/001_sum.ndc @@ -4,8 +4,10 @@ assert_eq([1,2,3,4,5].sum(), 15); assert_eq([1.0,2.0,3.0].sum(), 6.0); assert_eq([1n,2n,3n].sum(), 6n); +// `Deque()` starts out as Deque and pushing does not narrow it, so the +// cast is what lets `sum` resolve against its Int overload. let deque = Deque(); deque.push_back(1); deque.push_back(2); deque.push_back(3); -assert_eq(deque.sum(), 6); +assert_eq((deque as Deque).sum(), 6); From d553248795e7735ac8a04184af774b5ec963e347 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 2 Sep 2026 15:59:21 +0200 Subject: [PATCH 13/31] =?UTF-8?q?docs(manual):=20explain=20typed-collectio?= =?UTF-8?q?n=20dispatch=20and=20its=20cast=20=F0=9F=93=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The troubleshooting page claimed every standard library container overload uses `` elements, so the element-type limitation was invisible. The typed `sum` overloads make it visible, and a cast is the way through it. Co-Authored-By: Claude Opus 5 (1M context) --- .../overload-dispatch-collections.md | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/manual/src/troubleshooting/overload-dispatch-collections.md b/manual/src/troubleshooting/overload-dispatch-collections.md index 78e45616..d68c04d6 100644 --- a/manual/src/troubleshooting/overload-dispatch-collections.md +++ b/manual/src/troubleshooting/overload-dispatch-collections.md @@ -41,20 +41,48 @@ fn process(List) would both fail to match under dynamic dispatch if the list type cannot be resolved at compile time, because verifying element types would require scanning the entire container. -In practice this limitation is not currently visible: +Standard library overloads do differ by element type. Numeric sequence functions preserve the +concrete numeric type, so `sum` has three typed overloads: -- User-defined functions cannot yet declare typed container parameters (the syntax is not - implemented), so user overloads always use `Any` and dispatch works correctly. -- All standard library overloads on container parameters use `` element types - (e.g. `List`, `Sequence`, `Map`), so they also hit the fast path. - Overloads that differ by container *kind* (e.g. `pop(List)` vs `pop(MinHeap)`) - are distinguished by the container kind check alone. +``` +fn sum(Sequence) -> Int +fn sum(Sequence) -> Float +fn sum(Sequence) -> Number +``` + +A value the analyser only knows as `List` matches none of them. Rather than dispatch on +it and scan, the call is rejected at compile time: + +```ndc +let values: List = [1, 2, 3]; +values.sum() +// error[resolver]: No function called 'sum' found that matches the arguments 'List' +// = An overload would accept a narrower argument type. Cast to say what the value holds, +// as in `value as List`. +``` + +Overloads that differ only by container *kind* (e.g. `pop(List)` vs `pop(MinHeap)`) +are still distinguished by the kind check alone. User-defined functions cannot yet declare +typed container parameters (the syntax is not implemented), so user overloads always use +`Any`. ## Workaround -If you notice that a function call unexpectedly fails to match an overload, move the call to a -location where Andy C++ can infer the argument types statically — for example, directly at the -call site rather than through an intermediate untyped function parameter: +State the element type with a [cast](../features/casts.md). A cast scans the value, but the +cost lands at a site you wrote rather than inside every dispatch: + +```ndc +let values = %{}; +values.insert(1); +values.insert(2); + +// keys is typed List; the cast recovers Int so sum can resolve. +assert_eq((values.keys as List).sum(), 3); +``` + +Otherwise, move the call to a location where Andy C++ can infer the argument types +statically — for example, directly at the call site rather than through an intermediate +untyped function parameter: ```ndc // The type of `data` is Any here — dynamic dispatch used From 924b3f5c642a30b359e3145c23eab709559ed07a Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 2 Sep 2026 16:32:13 +0200 Subject: [PATCH 14/31] =?UTF-8?q?refactor:=20centralize=20numeric=20runtim?= =?UTF-8?q?e=20handling=20=F0=9F=94=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a borrowed NumericRef and operational NumericMode without changing the public sibling types or Value representation. Reuse them for promotion, comparison, hashing, constructors, and conversions, while keeping primitive fast paths and exact overload validation. Borrow Number payloads in generated native adapters so reference parameters no longer clone AdvancedNumber values. --- ndc_macros/src/vm_convert.rs | 30 ++-- ndc_stdlib/src/math.rs | 288 ++++++++++++++++------------------- ndc_vm/src/value/mod.rs | 151 ++++++++---------- ndc_vm/src/value/numeric.rs | 233 ++++++++++++++++++++++++++++ 4 files changed, 445 insertions(+), 257 deletions(-) create mode 100644 ndc_vm/src/value/numeric.rs diff --git a/ndc_macros/src/vm_convert.rs b/ndc_macros/src/vm_convert.rs index e9682ade..c83eb087 100644 --- a/ndc_macros/src/vm_convert.rs +++ b/ndc_macros/src/vm_convert.rs @@ -79,18 +79,24 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { let temp = format_ident!("vm_temp{position}"); let result = match ndc_type { - NdcType::Number | NdcType::NumberRef => { + NdcType::Number => { let err = arg_error(position, "number"); - let pass = if ndc_type == NdcType::NumberRef { - quote! { &#temp } - } else { - quote! { #temp } - }; VmInputArg { extract: quote! { let #temp = #raw.to_number().ok_or_else(|| #err)?; }, - pass, + pass: quote! { #temp }, + static_type: quote! { ndc_core::StaticType::Number }, + } + } + + NdcType::NumberRef => { + let err = arg_error(position, "number"); + VmInputArg { + extract: quote! { + let #temp = #raw.as_number().ok_or_else(|| #err)?; + }, + pass: quote! { #temp }, static_type: quote! { ndc_core::StaticType::Number }, } } @@ -197,14 +203,14 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { VmInputArg { extract: quote! { let #temp = { - let num = #raw.to_number().ok_or_else(|| #err)?; + let num = #raw.as_number().ok_or_else(|| #err)?; match num { - ndc_core::num::AdvancedNumber::Rational(r) => *r, + ndc_core::num::AdvancedNumber::Rational(r) => r.as_ref(), _ => return Err(#err), } }; }, - pass: quote! { &#temp }, + pass: quote! { #temp }, static_type: quote! { ndc_core::StaticType::Number }, } } @@ -214,9 +220,9 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { VmInputArg { extract: quote! { let #temp = { - let num = #raw.to_number().ok_or_else(|| #err)?; + let num = #raw.as_number().ok_or_else(|| #err)?; match num { - ndc_core::num::AdvancedNumber::Complex(c) => c, + ndc_core::num::AdvancedNumber::Complex(c) => *c, _ => return Err(#err), } }; diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 40435281..8dc4ca6e 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -2,30 +2,13 @@ use factorial::Factorial; use ndc_core::num::{AdvancedNumber, BinaryOperatorError}; use ndc_core::{FunctionRegistry, StaticType}; use ndc_vm::error::VmError; -use ndc_vm::value::{NativeFunc, NativeFunction, Object, Value}; +use ndc_vm::value::{NativeFunc, NativeFunction, NumericMode, NumericRef, Object, Value}; use num::complex::Complex64; -use num::{BigInt, BigUint, FromPrimitive, Integer, ToPrimitive}; +use num::{BigInt, BigUint, Integer, ToPrimitive}; use std::cmp::Ordering; use std::ops::{Add, Div, Mul, Neg, Not, Rem, Sub}; use std::rc::Rc; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum NumericKind { - Int, - Float, - Number, -} - -impl NumericKind { - fn static_type(self) -> StaticType { - match self { - Self::Int => StaticType::Int, - Self::Float => StaticType::Float, - Self::Number => StaticType::Number, - } - } -} - #[derive(Clone, Copy)] enum BinaryOperation { Add, @@ -115,29 +98,19 @@ fn native_error(error: impl std::fmt::Display) -> VmError { VmError::native(error.to_string()) } -fn result_kind(left: NumericKind, right: NumericKind) -> NumericKind { - if left == NumericKind::Number || right == NumericKind::Number { - NumericKind::Number - } else if left == NumericKind::Float || right == NumericKind::Float { - NumericKind::Float - } else { - NumericKind::Int - } -} - /// Runtime overload candidates are inspected in reverse registration order. /// Keep homogeneous primitive pairs first in that runtime order: dynamic code /// usually preserves one numeric representation across repeated operations. -const NUMERIC_PAIRS_BY_DYNAMIC_PRIORITY: [(NumericKind, NumericKind); 9] = [ - (NumericKind::Int, NumericKind::Int), - (NumericKind::Float, NumericKind::Float), - (NumericKind::Number, NumericKind::Number), - (NumericKind::Int, NumericKind::Float), - (NumericKind::Float, NumericKind::Int), - (NumericKind::Int, NumericKind::Number), - (NumericKind::Number, NumericKind::Int), - (NumericKind::Float, NumericKind::Number), - (NumericKind::Number, NumericKind::Float), +const NUMERIC_PAIRS_BY_DYNAMIC_PRIORITY: [(NumericMode, NumericMode); 9] = [ + (NumericMode::Int, NumericMode::Int), + (NumericMode::Float, NumericMode::Float), + (NumericMode::Number, NumericMode::Number), + (NumericMode::Int, NumericMode::Float), + (NumericMode::Float, NumericMode::Int), + (NumericMode::Int, NumericMode::Number), + (NumericMode::Number, NumericMode::Int), + (NumericMode::Float, NumericMode::Number), + (NumericMode::Number, NumericMode::Float), ]; fn register_binary_arithmetic(env: &mut FunctionRegistry>) { @@ -153,21 +126,21 @@ fn register_binary_arithmetic(env: &mut FunctionRegistry>) { ]; for operation in OPERATIONS { - for (left_kind, right_kind) in NUMERIC_PAIRS_BY_DYNAMIC_PRIORITY.into_iter().rev() { - let output_kind = result_kind(left_kind, right_kind); + for (left_mode, right_mode) in NUMERIC_PAIRS_BY_DYNAMIC_PRIORITY.into_iter().rev() { + let output_mode = left_mode.promote(right_mode); declare( env, operation.name(), - vec![left_kind.static_type(), right_kind.static_type()], - output_kind.static_type(), + vec![left_mode.static_type(), right_mode.static_type()], + output_mode.static_type(), operation.documentation(), move |args| { arity(args, 2)?; eval_binary( operation, - left_kind, - right_kind, - output_kind, + left_mode, + right_mode, + output_mode, &args[0], &args[1], ) @@ -179,27 +152,37 @@ fn register_binary_arithmetic(env: &mut FunctionRegistry>) { fn eval_binary( operation: BinaryOperation, - left_kind: NumericKind, - right_kind: NumericKind, - output_kind: NumericKind, + left_mode: NumericMode, + right_mode: NumericMode, + output_mode: NumericMode, left: &Value, right: &Value, ) -> Result { - match output_kind { - NumericKind::Int => { - let (Value::Int(left), Value::Int(right)) = (left, right) else { - return Err(VmError::native("expected two Int operands".to_string())); - }; - eval_int_binary(operation, *left, *right).map(Value::Int) + let left = numeric_ref(left_mode, left)?; + let right = numeric_ref(right_mode, right)?; + + match output_mode { + NumericMode::Int => { + let left = left + .as_int() + .expect("Int output requires an Int left operand"); + let right = right + .as_int() + .expect("Int output requires an Int right operand"); + eval_int_binary(operation, left, right).map(Value::Int) } - NumericKind::Float => { - let left = primitive_float(left_kind, left)?; - let right = primitive_float(right_kind, right)?; + NumericMode::Float => { + let left = left + .to_primitive_float() + .expect("Float output only combines primitive operands"); + let right = right + .to_primitive_float() + .expect("Float output only combines primitive operands"); Ok(Value::Float(eval_float_binary(operation, left, right))) } - NumericKind::Number => { - let left = promoted_number(left_kind, left)?; - let right = promoted_number(right_kind, right)?; + NumericMode::Number => { + let left = left.to_advanced_number(); + let right = right.to_advanced_number(); eval_number_binary(operation, left, right) .map(Value::from_number) .map_err(native_error) @@ -207,26 +190,12 @@ fn eval_binary( } } -fn primitive_float(kind: NumericKind, value: &Value) -> Result { - match (kind, value) { - (NumericKind::Int, Value::Int(value)) => Ok(*value as f64), - (NumericKind::Float, Value::Float(value)) => Ok(*value), - _ => Err(VmError::native(format!( - "expected {}, got {}", - kind.static_type(), - value.static_type() - ))), - } -} - -fn promoted_number(kind: NumericKind, value: &Value) -> Result { - match (kind, value) { - (NumericKind::Int, Value::Int(value)) => Ok(AdvancedNumber::Int(BigInt::from(*value))), - (NumericKind::Float, Value::Float(value)) => Ok(AdvancedNumber::Float(*value)), - (NumericKind::Number, Value::Number(value)) => Ok(value.as_ref().clone()), +fn numeric_ref(mode: NumericMode, value: &Value) -> Result, VmError> { + match value.numeric_ref() { + Some(number) if number.mode() == mode => Ok(number), _ => Err(VmError::native(format!( "expected {}, got {}", - kind.static_type(), + mode.static_type(), value.static_type() ))), } @@ -573,53 +542,55 @@ fn register_bitwise(env: &mut FunctionRegistry>) { } fn register_constructors(env: &mut FunctionRegistry>) { - for kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + for mode in NumericMode::ALL { declare( env, "Number", - vec![kind.static_type()], + vec![mode.static_type()], StaticType::Number, "Wraps a primitive numeric value as a Number.", move |args| { arity(args, 1)?; - promoted_number(kind, &args[0]).map(Value::from_number) + Ok(Value::from_number( + numeric_ref(mode, &args[0])?.to_advanced_number(), + )) }, ); } } fn register_aggregates(env: &mut FunctionRegistry>) { - for kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + for mode in NumericMode::ALL { for (name, product) in [("sum", false), ("product", true)] { declare( env, name, - vec![StaticType::Sequence(Box::new(kind.static_type()))], - kind.static_type(), + vec![StaticType::Sequence(Box::new(mode.static_type()))], + mode.static_type(), if product { "Returns the product of a numeric sequence." } else { "Returns the sum of a numeric sequence." }, - move |args| aggregate(args, kind, product), + move |args| aggregate(args, mode, product), ); } } } -fn aggregate(args: &[Value], kind: NumericKind, product: bool) -> Result { +fn aggregate(args: &[Value], mode: NumericMode, product: bool) -> Result { arity(args, 1)?; if let Value::Object(object) = &args[0] { match object.as_ref() { Object::List(values) => { let values = values.borrow(); - return aggregate_values(values.iter(), kind, product); + return aggregate_values(values.iter(), mode, product); } - Object::Tuple(values) => return aggregate_values(values.iter(), kind, product), + Object::Tuple(values) => return aggregate_values(values.iter(), mode, product), Object::Deque(values) => { let values = values.borrow(); - return aggregate_values(values.iter(), kind, product); + return aggregate_values(values.iter(), mode, product); } _ => {} } @@ -629,16 +600,16 @@ fn aggregate(args: &[Value], kind: NumericKind, product: bool) -> Result(mut values: I, kind: NumericKind, product: bool) -> Result +fn aggregate_values(mut values: I, mode: NumericMode, product: bool) -> Result where I: Iterator, I::Item: std::borrow::Borrow, { - match kind { - NumericKind::Int => { + match mode { + NumericMode::Int => { let initial: i64 = if product { 1 } else { 0 }; let value = values.try_fold(initial, |accumulator, value| { let value = std::borrow::Borrow::borrow(&value); @@ -654,7 +625,7 @@ where })?; Ok(Value::Int(value)) } - NumericKind::Float => { + NumericMode::Float => { let initial = if product { 1.0 } else { 0.0 }; let value = values.try_fold(initial, |accumulator, value| { let value = std::borrow::Borrow::borrow(&value); @@ -669,7 +640,7 @@ where })?; Ok(Value::Float(value)) } - NumericKind::Number => { + NumericMode::Number => { let initial = AdvancedNumber::Int(BigInt::from(if product { 1 } else { 0 })); let value = values.try_fold(initial, |accumulator, value| { let value = std::borrow::Borrow::borrow(&value); @@ -698,14 +669,14 @@ enum PreservingUnary { } fn register_number_helpers(env: &mut FunctionRegistry>) { - for kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + for mode in NumericMode::ALL { declare( env, "signum", - vec![kind.static_type()], - kind.static_type(), + vec![mode.static_type()], + mode.static_type(), "Returns the sign of a number.", - move |args| unary_preserving(args, kind, PreservingUnary::Signum), + move |args| unary_preserving(args, mode, PreservingUnary::Signum), ); for (name, operation) in [ ("ceil", PreservingUnary::Ceil), @@ -716,17 +687,17 @@ fn register_number_helpers(env: &mut FunctionRegistry>) { declare( env, name, - vec![kind.static_type()], - kind.static_type(), + vec![mode.static_type()], + mode.static_type(), "Applies a numeric operation while preserving the numeric mode.", - move |args| unary_preserving(args, kind, operation), + move |args| unary_preserving(args, mode, operation), ); } } - for left in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { - for right in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { - let output = result_kind(left, right); + for left in NumericMode::ALL { + for right in NumericMode::ALL { + let output = left.promote(right); declare( env, "abs_diff", @@ -803,12 +774,12 @@ fn register_number_helpers(env: &mut FunctionRegistry>) { fn unary_preserving( args: &[Value], - kind: NumericKind, + mode: NumericMode, operation: PreservingUnary, ) -> Result { arity(args, 1)?; - match (kind, &args[0]) { - (NumericKind::Int, Value::Int(value)) => { + match (mode, &args[0]) { + (NumericMode::Int, Value::Int(value)) => { let result = match operation { PreservingUnary::Signum => value.signum(), PreservingUnary::Ceil | PreservingUnary::Floor | PreservingUnary::Round => *value, @@ -818,7 +789,7 @@ fn unary_preserving( }; Ok(Value::Int(result)) } - (NumericKind::Float, Value::Float(value)) => { + (NumericMode::Float, Value::Float(value)) => { let result = match operation { PreservingUnary::Signum => value.signum(), PreservingUnary::Ceil => value.ceil(), @@ -828,7 +799,7 @@ fn unary_preserving( }; Ok(Value::Float(result)) } - (NumericKind::Number, Value::Number(value)) => { + (NumericMode::Number, Value::Number(value)) => { let result = match operation { PreservingUnary::Signum => value.signum(), PreservingUnary::Ceil => value.ceil(), @@ -840,7 +811,7 @@ fn unary_preserving( } _ => Err(VmError::native(format!( "expected {}, got {}", - kind.static_type(), + mode.static_type(), args[0].static_type() ))), } @@ -974,25 +945,25 @@ fn register_conversions(env: &mut FunctionRegistry>) { }, ); - for left_kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { - for right_kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { - let output = result_kind(left_kind, right_kind); - let output = if output == NumericKind::Int { - NumericKind::Float + for left_mode in NumericMode::ALL { + for right_mode in NumericMode::ALL { + let output = left_mode.promote(right_mode); + let output = if output == NumericMode::Int { + NumericMode::Float } else { output }; declare( env, "atan2", - vec![left_kind.static_type(), right_kind.static_type()], + vec![left_mode.static_type(), right_mode.static_type()], output.static_type(), "Computes the four-quadrant arctangent of y and x.", move |args| { arity(args, 2)?; - if output == NumericKind::Number { - let left = promoted_number(left_kind, &args[0])?; - let right = promoted_number(right_kind, &args[1])?; + let left = numeric_ref(left_mode, &args[0])?; + let right = numeric_ref(right_mode, &args[1])?; + if output == NumericMode::Number { let left = left.to_f64().ok_or_else(|| { VmError::native("atan2 requires real Number operands".to_string()) })?; @@ -1001,8 +972,12 @@ fn register_conversions(env: &mut FunctionRegistry>) { })?; Ok(Value::from_number(AdvancedNumber::Float(left.atan2(right)))) } else { - let left = primitive_float(left_kind, &args[0])?; - let right = primitive_float(right_kind, &args[1])?; + let left = left + .to_primitive_float() + .expect("Float atan2 only combines primitive operands"); + let right = right + .to_primitive_float() + .expect("Float atan2 only combines primitive operands"); Ok(Value::Float(left.atan2(right))) } }, @@ -1013,46 +988,39 @@ fn register_conversions(env: &mut FunctionRegistry>) { fn convert_to_int(value: &Value) -> Result { let static_type = value.static_type(); + if let Some(number) = value.numeric_ref() { + return number + .to_i64_truncating() + .ok_or_else(|| VmError::native(format!("cannot convert {static_type} to Int"))); + } + let converted = match value { - Value::Int(value) => return Ok(*value), - Value::Float(value) => float_to_i64(*value), - Value::Number(value) => match value.as_ref() { - AdvancedNumber::Int(value) => value.to_i64(), - AdvancedNumber::Float(value) => float_to_i64(*value), - AdvancedNumber::Rational(value) => value.to_integer().to_i64(), - AdvancedNumber::Complex(_) => None, - }, Value::Bool(value) => return Ok(if *value { 1 } else { 0 }), Value::Object(value) => match value.as_ref() { Object::String(value) => return value.borrow().parse::().map_err(native_error), _ => None, }, Value::None => None, + Value::Int(_) | Value::Float(_) | Value::Number(_) => unreachable!("handled above"), }; converted.ok_or_else(|| VmError::native(format!("cannot convert {static_type} to Int"))) } -fn float_to_i64(value: f64) -> Option { - if value.is_finite() { - BigInt::from_f64(value.trunc())?.to_i64() - } else { - None +fn convert_to_float(value: &Value) -> Result { + if let Some(number) = value.numeric_ref() { + return number.to_f64().ok_or_else(|| { + VmError::native("cannot convert a complex Number to Float".to_string()) + }); } -} -fn convert_to_float(value: &Value) -> Result { match value { - Value::Int(value) => Ok(*value as f64), - Value::Float(value) => Ok(*value), - Value::Number(value) => value - .to_f64() - .ok_or_else(|| VmError::native("cannot convert a complex Number to Float".to_string())), Value::Bool(value) => Ok(if *value { 1.0 } else { 0.0 }), Value::Object(value) => match value.as_ref() { Object::String(value) => value.borrow().parse::().map_err(native_error), _ => Err(VmError::native("cannot convert value to Float".to_string())), }, Value::None => Err(VmError::native("cannot convert None to Float".to_string())), + Value::Int(_) | Value::Float(_) | Value::Number(_) => unreachable!("handled above"), } } @@ -1157,21 +1125,21 @@ impl Transcendental { } } - fn documentation(self, kind: NumericKind) -> String { - let mode = match kind { - NumericKind::Int => " Converts Int input to Float before evaluation and returns Float.", - NumericKind::Float => " Returns Float for Float input.", - NumericKind::Number => { + fn documentation(self, mode: NumericMode) -> String { + let mode_description = match mode { + NumericMode::Int => " Converts Int input to Float before evaluation and returns Float.", + NumericMode::Float => " Returns Float for Float input.", + NumericMode::Number => { " Returns Number for real or complex Number input. Values outside the real domain continue into the complex plane." } }; - let domain = if kind == NumericKind::Number { + let domain = if mode == NumericMode::Number { "" } else { self.real_domain_description() }; - format!("{}{domain}{mode}", self.description()) + format!("{}{domain}{mode_description}", self.description()) } fn apply_float(self, value: f64) -> f64 { @@ -1224,7 +1192,7 @@ fn register_transcendentals(env: &mut FunctionRegistry>) { function.name(), vec![StaticType::Int], StaticType::Float, - function.documentation(NumericKind::Int), + function.documentation(NumericMode::Int), move |args| { let [Value::Int(value)] = args else { return Err(VmError::native("expected one Int argument".to_string())); @@ -1237,7 +1205,7 @@ fn register_transcendentals(env: &mut FunctionRegistry>) { function.name(), vec![StaticType::Float], StaticType::Float, - function.documentation(NumericKind::Float), + function.documentation(NumericMode::Float), move |args| { let [Value::Float(value)] = args else { return Err(VmError::native("expected one Float argument".to_string())); @@ -1250,7 +1218,7 @@ fn register_transcendentals(env: &mut FunctionRegistry>) { function.name(), vec![StaticType::Number], StaticType::Number, - function.documentation(NumericKind::Number), + function.documentation(NumericMode::Number), move |args| { let [Value::Number(value)] = args else { return Err(VmError::native("expected one Number argument".to_string())); @@ -1350,12 +1318,12 @@ mod tests { register(&mut registry); for transcendental in Transcendental::ALL { - for kind in [NumericKind::Int, NumericKind::Float, NumericKind::Number] { + for mode in NumericMode::ALL { let expected_type = StaticType::Function { - parameters: Some(vec![kind.static_type()]), - return_type: Box::new(match kind { - NumericKind::Int | NumericKind::Float => StaticType::Float, - NumericKind::Number => StaticType::Number, + parameters: Some(vec![mode.static_type()]), + return_type: Box::new(match mode { + NumericMode::Int | NumericMode::Float => StaticType::Float, + NumericMode::Number => StaticType::Number, }), }; let function = registry @@ -1368,13 +1336,13 @@ mod tests { panic!( "missing {}({}) transcendental overload", transcendental.name(), - kind.static_type() + mode.static_type() ) }); assert_eq!( function.documentation.as_deref(), - Some(transcendental.documentation(kind).as_str()) + Some(transcendental.documentation(mode).as_str()) ); } } diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 1ba7d6cd..6fff53a3 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -1,6 +1,8 @@ mod function; +mod numeric; pub use function::*; +pub use numeric::{NumericMode, NumericRef}; use crate::iterator::SharedIterator; use ndc_core::StaticType; @@ -296,7 +298,7 @@ impl Value { /// Prefer this over `self.static_type().is_number()` in hot paths — this is O(1) /// and never allocates, whereas `static_type()` on containers is O(n). pub fn is_number(&self) -> bool { - matches!(self, Self::Int(_) | Self::Float(_) | Self::Number(_)) + self.numeric_ref().is_some() } /// Check whether this value satisfies a function parameter type at runtime, @@ -559,14 +561,20 @@ impl Value { } pub fn as_number(&self) -> Option<&AdvancedNumber> { + self.numeric_ref().and_then(NumericRef::as_number) + } + + pub fn numeric_ref(&self) -> Option> { match self { - Self::Number(number) => Some(number.as_ref()), - _ => None, + Self::Int(value) => Some(NumericRef::Int(*value)), + Self::Float(value) => Some(NumericRef::Float(*value)), + Self::Number(value) => Some(NumericRef::Number(value.as_ref())), + Self::Bool(_) | Self::None | Self::Object(_) => None, } } pub fn to_advanced_number(&self) -> Option { - vm_value_to_number(self) + self.numeric_ref().map(NumericRef::to_advanced_number) } /// Wrap an advanced numeric payload as a Number value. @@ -577,13 +585,7 @@ impl Value { /// Convert a numeric VM value to `f64`, coercing integers and rationals. /// Returns `None` for non-numeric values (Bool, None, String, …). pub fn to_f64(&self) -> Option { - use num::ToPrimitive; - match self { - Self::Float(f) => Some(*f), - Self::Int(i) => i.to_f64(), - Self::Number(number) => number.to_f64(), - _ => None, - } + self.numeric_ref().and_then(NumericRef::to_f64) } } @@ -897,17 +899,8 @@ impl fmt::Debug for Object { impl PartialOrd for Value { fn partial_cmp(&self, other: &Self) -> Option { - match (self, other) { - (Self::Int(a), Self::Int(b)) => return Some(a.cmp(b)), - (Self::Float(a), Self::Float(b)) => return Some(compare_floats(*a, *b)), - (Self::Int(a), Self::Float(b)) => return Some(compare_int_float(*a, *b)), - (Self::Float(a), Self::Int(b)) => return Some(compare_int_float(*b, *a).reverse()), - (Self::Number(a), Self::Number(b)) => return a.partial_cmp(b), - _ => {} - } - - if self.is_number() && other.is_number() { - return vm_value_to_number(self)?.partial_cmp(&vm_value_to_number(other)?); + if let (Some(left), Some(right)) = (self.numeric_ref(), other.numeric_ref()) { + return Some(left.compare(right)); } match (self, other) { @@ -918,52 +911,6 @@ impl PartialOrd for Value { } } -/// Match `AdvancedNumber`'s total ordering without constructing exact rational -/// representations for two values that are already stored as floats. -fn compare_floats(left: f64, right: f64) -> Ordering { - match (left.is_nan(), right.is_nan()) { - (true, true) => Ordering::Equal, - (true, false) => Ordering::Greater, - (false, true) => Ordering::Less, - (false, false) => left - .partial_cmp(&right) - .expect("non-NaN floats are totally ordered"), - } -} - -/// Compare an `i64` to an `f64` exactly, without first allocating a `BigInt` -/// and exact `BigRational` for their `AdvancedNumber` representations. -fn compare_int_float(integer: i64, float: f64) -> Ordering { - const I64_MIN_AS_F64: f64 = i64::MIN as f64; - const I64_UPPER_BOUND_AS_F64: f64 = i64::MAX as f64; - - if float.is_nan() || float >= I64_UPPER_BOUND_AS_F64 { - return Ordering::Less; - } - if float < I64_MIN_AS_F64 { - return Ordering::Greater; - } - - let truncated = float.trunc() as i64; - match integer.cmp(&truncated) { - Ordering::Equal if float.fract() == 0.0 => Ordering::Equal, - Ordering::Equal if float.is_sign_positive() => Ordering::Less, - Ordering::Equal => Ordering::Greater, - ordering => ordering, - } -} - -/// Convert a VM numeric value to an `AdvancedNumber` for cross-type comparison. -/// Returns `None` for non-numeric values (Bool, None, String, List, …). -fn vm_value_to_number(v: &Value) -> Option { - match v { - Value::Int(i) => Some(AdvancedNumber::Int(num::BigInt::from(*i))), - Value::Float(f) => Some(AdvancedNumber::Float(*f)), - Value::Number(number) => Some(number.as_ref().clone()), - _ => None, - } -} - impl PartialOrd for Object { fn partial_cmp(&self, other: &Self) -> Option { match (self, other) { @@ -1018,22 +965,8 @@ impl Value { impl PartialEq for Value { fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::Int(a), Self::Int(b)) => return a == b, - (Self::Float(a), Self::Float(b)) => { - return compare_floats(*a, *b) == Ordering::Equal; - } - (Self::Int(a), Self::Float(b)) => return compare_int_float(*a, *b) == Ordering::Equal, - (Self::Float(a), Self::Int(b)) => return compare_int_float(*b, *a) == Ordering::Equal, - (Self::Number(a), Self::Number(b)) => return a == b, - _ => {} - } - - if self.is_number() && other.is_number() { - return match (vm_value_to_number(self), vm_value_to_number(other)) { - (Some(left), Some(right)) => left == right, - _ => false, - }; + if let (Some(left), Some(right)) = (self.numeric_ref(), other.numeric_ref()) { + return left == right; } match (self, other) { @@ -1049,7 +982,7 @@ impl Eq for Value {} impl Hash for Value { fn hash(&self, state: &mut H) { - if let Some(number) = vm_value_to_number(self) { + if let Some(number) = self.numeric_ref() { state.write_u8(1); number.hash(state); return; @@ -1232,6 +1165,54 @@ impl Hash for Object { mod tests { use super::*; + fn value_hash(value: &Value) -> u64 { + let mut hasher = DefaultHasher::default(); + value.hash(&mut hasher); + hasher.finish() + } + + #[test] + fn numeric_ref_owns_checked_numeric_conversions() { + let integer = Value::Int(42); + let float = Value::Float(42.75); + let rational = Value::number(AdvancedNumber::rational(num::BigRational::new( + 7.into(), + 2.into(), + ))); + let complex = Value::complex(num::Complex::new(1.0, 2.0)); + + assert_eq!(integer.numeric_ref().unwrap().mode(), NumericMode::Int); + assert_eq!(float.numeric_ref().unwrap().mode(), NumericMode::Float); + assert_eq!(rational.numeric_ref().unwrap().mode(), NumericMode::Number); + assert_eq!(float.numeric_ref().unwrap().to_i64_truncating(), Some(42)); + assert_eq!(rational.numeric_ref().unwrap().to_i64_truncating(), Some(3)); + assert_eq!(rational.numeric_ref().unwrap().to_f64(), Some(3.5)); + assert_eq!(complex.numeric_ref().unwrap().to_f64(), None); + assert!(Value::Bool(true).numeric_ref().is_none()); + } + + #[test] + fn equal_numeric_modes_keep_equal_hashes() { + let values = [ + Value::Int(1), + Value::Float(1.0), + Value::number(AdvancedNumber::Int(1.into())), + Value::number(AdvancedNumber::Float(1.0)), + Value::number(AdvancedNumber::rational(num::BigRational::from_integer( + 1.into(), + ))), + Value::complex(num::Complex::new(1.0, 0.0)), + ]; + + for left in &values { + for right in &values { + assert_eq!(left, right); + assert_eq!(left.partial_cmp(right), Some(Ordering::Equal)); + assert_eq!(value_hash(left), value_hash(right)); + } + } + } + #[test] fn conformance_memoizes_aliased_containers() { let list = Rc::new(Object::List(RefCell::new(Vec::new()))); diff --git a/ndc_vm/src/value/numeric.rs b/ndc_vm/src/value/numeric.rs new file mode 100644 index 00000000..0c21956e --- /dev/null +++ b/ndc_vm/src/value/numeric.rs @@ -0,0 +1,233 @@ +use ndc_core::StaticType; +use ndc_core::num::AdvancedNumber; +use num::{FromPrimitive, ToPrimitive}; +use std::cmp::Ordering; +use std::hash::{Hash, Hasher}; + +/// The runtime representation mode of a numeric [`super::Value`]. +/// +/// This is an operational distinction used for overload selection and result +/// promotion. It does not define a subtype relationship between Andy types. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NumericMode { + Int, + Float, + Number, +} + +impl NumericMode { + pub const ALL: [Self; 3] = [Self::Int, Self::Float, Self::Number]; + + pub fn static_type(self) -> StaticType { + match self { + Self::Int => StaticType::Int, + Self::Float => StaticType::Float, + Self::Number => StaticType::Number, + } + } + + /// Return the numeric mode used by an operation combining both modes. + pub fn promote(self, other: Self) -> Self { + if self == Self::Number || other == Self::Number { + Self::Number + } else if self == Self::Float || other == Self::Float { + Self::Float + } else { + Self::Int + } + } +} + +/// A borrowed, non-subtyping view over the three numeric [`super::Value`] variants. +#[derive(Clone, Copy, Debug)] +pub enum NumericRef<'a> { + Int(i64), + Float(f64), + Number(&'a AdvancedNumber), +} + +impl<'a> NumericRef<'a> { + pub fn mode(self) -> NumericMode { + match self { + Self::Int(_) => NumericMode::Int, + Self::Float(_) => NumericMode::Float, + Self::Number(_) => NumericMode::Number, + } + } + + pub fn as_int(self) -> Option { + match self { + Self::Int(value) => Some(value), + Self::Float(_) | Self::Number(_) => None, + } + } + + pub fn as_number(self) -> Option<&'a AdvancedNumber> { + match self { + Self::Number(value) => Some(value), + Self::Int(_) | Self::Float(_) => None, + } + } + + /// Promote a primitive numeric value to `f64` without accepting `Number`. + pub fn to_primitive_float(self) -> Option { + match self { + Self::Int(value) => Some(value as f64), + Self::Float(value) => Some(value), + Self::Number(_) => None, + } + } + + /// Convert a real numeric value to `f64`. + /// + /// Complex `Number` values cannot be represented and return `None`. + pub fn to_f64(self) -> Option { + match self { + Self::Int(value) => Some(value as f64), + Self::Float(value) => Some(value), + Self::Number(value) => value.to_f64(), + } + } + + /// Convert to `i64` using the language's checked, truncating `int()` policy. + pub fn to_i64_truncating(self) -> Option { + match self { + Self::Int(value) => Some(value), + Self::Float(value) => float_to_i64(value), + Self::Number(value) => match value { + AdvancedNumber::Int(value) => value.to_i64(), + AdvancedNumber::Float(value) => float_to_i64(*value), + AdvancedNumber::Rational(value) => value.to_integer().to_i64(), + AdvancedNumber::Complex(_) => None, + }, + } + } + + /// Promote this value to the owned representation used by `Number`. + pub fn to_advanced_number(self) -> AdvancedNumber { + match self { + Self::Int(value) => AdvancedNumber::Int(num::BigInt::from(value)), + Self::Float(value) => AdvancedNumber::Float(value), + Self::Number(value) => value.clone(), + } + } + + pub fn compare(self, other: Self) -> Ordering { + match (self, other) { + (Self::Int(left), Self::Int(right)) => left.cmp(&right), + (Self::Float(left), Self::Float(right)) => compare_floats(left, right), + (Self::Int(left), Self::Float(right)) => compare_int_float(left, right), + (Self::Float(left), Self::Int(right)) => compare_int_float(right, left).reverse(), + (Self::Number(left), Self::Number(right)) => left + .partial_cmp(right) + .expect("AdvancedNumber values are totally ordered"), + (Self::Int(left), Self::Number(right)) => AdvancedNumber::Int(left.into()) + .partial_cmp(right) + .expect("AdvancedNumber values are totally ordered"), + (Self::Float(left), Self::Number(right)) => AdvancedNumber::Float(left) + .partial_cmp(right) + .expect("AdvancedNumber values are totally ordered"), + (Self::Number(left), Self::Int(right)) => left + .partial_cmp(&AdvancedNumber::Int(right.into())) + .expect("AdvancedNumber values are totally ordered"), + (Self::Number(left), Self::Float(right)) => left + .partial_cmp(&AdvancedNumber::Float(right)) + .expect("AdvancedNumber values are totally ordered"), + } + } +} + +impl PartialEq for NumericRef<'_> { + fn eq(&self, other: &Self) -> bool { + self.compare(*other) == Ordering::Equal + } +} + +impl Eq for NumericRef<'_> {} + +impl PartialOrd for NumericRef<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for NumericRef<'_> { + fn cmp(&self, other: &Self) -> Ordering { + self.compare(*other) + } +} + +impl Hash for NumericRef<'_> { + fn hash(&self, state: &mut H) { + match self { + Self::Int(value) => AdvancedNumber::Int((*value).into()).hash(state), + Self::Float(value) => AdvancedNumber::Float(*value).hash(state), + Self::Number(value) => value.hash(state), + } + } +} + +fn float_to_i64(value: f64) -> Option { + if value.is_finite() { + num::BigInt::from_f64(value.trunc())?.to_i64() + } else { + None + } +} + +/// Match `AdvancedNumber`'s total ordering without constructing exact rational +/// representations for two values that are already stored as floats. +fn compare_floats(left: f64, right: f64) -> Ordering { + match (left.is_nan(), right.is_nan()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => left + .partial_cmp(&right) + .expect("non-NaN floats are totally ordered"), + } +} + +/// Compare an `i64` to an `f64` exactly, without first allocating a `BigInt` +/// and exact `BigRational` for their `AdvancedNumber` representations. +fn compare_int_float(integer: i64, float: f64) -> Ordering { + const I64_MIN_AS_F64: f64 = i64::MIN as f64; + const I64_UPPER_BOUND_AS_F64: f64 = i64::MAX as f64; + + if float.is_nan() || float >= I64_UPPER_BOUND_AS_F64 { + return Ordering::Less; + } + if float < I64_MIN_AS_F64 { + return Ordering::Greater; + } + + let truncated = float.trunc() as i64; + match integer.cmp(&truncated) { + Ordering::Equal if float.fract() == 0.0 => Ordering::Equal, + Ordering::Equal if float.is_sign_positive() => Ordering::Less, + Ordering::Equal => Ordering::Greater, + ordering => ordering, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numeric_modes_promote_by_runtime_representation() { + assert_eq!(NumericMode::Int.promote(NumericMode::Int), NumericMode::Int); + assert_eq!( + NumericMode::Int.promote(NumericMode::Float), + NumericMode::Float + ); + assert_eq!( + NumericMode::Float.promote(NumericMode::Int), + NumericMode::Float + ); + for mode in NumericMode::ALL { + assert_eq!(mode.promote(NumericMode::Number), NumericMode::Number); + assert_eq!(NumericMode::Number.promote(mode), NumericMode::Number); + } + } +} From 29e5650a2f294fe647eb49074c8ac86653fbffa4 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 3 Sep 2026 10:11:32 +0200 Subject: [PATCH 15/31] =?UTF-8?q?refactor(lexer):=20unify=20numeric=20lite?= =?UTF-8?q?ral=20handling=20=F0=9F=94=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 - ndc_analyser/src/analyser.rs | 15 +- ndc_bin/src/highlighter.rs | 17 +- ndc_lexer/src/lib.rs | 2 +- ndc_lexer/src/number.rs | 321 +++++++++++++++++----------------- ndc_lexer/src/token.rs | 51 +++--- ndc_lsp/src/scope_resolve.rs | 6 +- ndc_lsp/src/visitor.rs | 12 +- ndc_parser/Cargo.toml | 3 +- ndc_parser/src/expression.rs | 10 +- ndc_parser/src/parser.rs | 11 +- ndc_vm/src/compiler.rs | 37 ++-- tests/proptest/tests/panic.rs | 25 ++- 13 files changed, 245 insertions(+), 266 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5cfd3afb..b3c5b5da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,6 @@ dependencies = [ "derive_more", "ndc_core", "ndc_lexer", - "num", "thiserror", ] diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index f32d96e3..043fb1a6 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -3,7 +3,7 @@ use itertools::{Itertools, izip}; use ndc_core::static_type::StaticTypeConstructionError; use ndc_core::r#struct::StructRegistry; use ndc_core::{StaticType, TypeSignature}; -use ndc_lexer::Span; +use ndc_lexer::{NumericLiteral, Span}; use ndc_parser::{ AugmentedAssignmentPlan, Binding, Candidate, Expression, ExpressionLocation, ForBody, ForIteration, FunctionParameter, Lvalue, NodeId, TypeExpr, @@ -255,12 +255,13 @@ impl Analyser { match expression { Expression::BoolLiteral(_) => Ok(StaticType::Bool), Expression::StringLiteral(_) => Ok(StaticType::String), - Expression::Int64Literal(_) => Ok(StaticType::Int), - Expression::Float64Literal(_) => Ok(StaticType::Float), - Expression::NumberIntLiteral(_) | Expression::NumberFloatLiteral(_) => { - Ok(StaticType::Number) - } - Expression::ComplexLiteral(_) => Ok(StaticType::Number), + Expression::NumericLiteral(NumericLiteral::Int64(_)) => Ok(StaticType::Int), + Expression::NumericLiteral(NumericLiteral::Float64(_)) => Ok(StaticType::Float), + Expression::NumericLiteral( + NumericLiteral::NumberInt(_) + | NumericLiteral::NumberFloat(_) + | NumericLiteral::Complex(_), + ) => Ok(StaticType::Number), Expression::Continue | Expression::Break => Ok(StaticType::Never), Expression::Identifier { name: ident, diff --git a/ndc_bin/src/highlighter.rs b/ndc_bin/src/highlighter.rs index 72135c93..21834fc1 100644 --- a/ndc_bin/src/highlighter.rs +++ b/ndc_bin/src/highlighter.rs @@ -56,14 +56,9 @@ impl AndycppHighlighter { // Strings — green Token::String(_) => substring.rgb(152, 195, 121), // Numeric literals and booleans — orange - Token::Int64(_) - | Token::Float64(_) - | Token::NumberInt(_) - | Token::NumberFloat(_) - | Token::Complex(_) - | Token::Infinity - | Token::True - | Token::False => substring.rgb(209, 154, 102), + Token::NumericLiteral(_) | Token::True | Token::False => { + substring.rgb(209, 154, 102) + } // Keywords — coral red Token::Let | Token::Fn @@ -244,11 +239,7 @@ fn collect_function_spans(expr: &ExpressionLocation, spans: &mut AHashSet // Leaves — no sub-expressions to recurse into Expression::BoolLiteral(_) | Expression::StringLiteral(_) - | Expression::Int64Literal(_) - | Expression::Float64Literal(_) - | Expression::NumberIntLiteral(_) - | Expression::NumberFloatLiteral(_) - | Expression::ComplexLiteral(_) + | Expression::NumericLiteral(_) | Expression::Identifier { .. } | Expression::StructDeclaration { .. } | Expression::Break diff --git a/ndc_lexer/src/lib.rs b/ndc_lexer/src/lib.rs index 207d983d..1ba01226 100644 --- a/ndc_lexer/src/lib.rs +++ b/ndc_lexer/src/lib.rs @@ -11,7 +11,7 @@ use string::StringLexer; pub use source_db::SourceDb; pub use span::{SourceId, Span}; -pub use token::{Token, TokenLocation}; +pub use token::{NumericLiteral, Token, TokenLocation}; pub struct Lexer<'a> { source: SourceIterator<'a>, diff --git a/ndc_lexer/src/number.rs b/ndc_lexer/src/number.rs index db65d7bc..538df38b 100644 --- a/ndc_lexer/src/number.rs +++ b/ndc_lexer/src/number.rs @@ -1,7 +1,7 @@ use num::{BigInt, Complex, Num}; use super::Error; -use super::{Lexer, Token, TokenLocation}; +use super::{Lexer, NumericLiteral, Token, TokenLocation}; pub trait NumberLexer { fn lex_number(&mut self) -> Result; @@ -9,6 +9,12 @@ pub trait NumberLexer { trait NumberLexerHelper { fn lex_to_buffer(&mut self, buf: &mut String, is_valid: impl Fn(char) -> bool); + fn lex_integer_with_radix( + &mut self, + start_offset: usize, + radix: u32, + allow_number_suffix: bool, + ) -> Result; } impl NumberLexerHelper for Lexer<'_> { @@ -26,123 +32,86 @@ impl NumberLexerHelper for Lexer<'_> { } } } -} -impl NumberLexer for Lexer<'_> { - #[allow(clippy::too_many_lines)] - fn lex_number(&mut self) -> Result { + fn lex_integer_with_radix( + &mut self, + start_offset: usize, + radix: u32, + allow_number_suffix: bool, + ) -> Result { let mut buf = String::new(); + self.lex_to_buffer(&mut buf, |c| c.is_digit(radix)); - let start_offset = self.source.current_offset(); - let first_char = self - .source - .next() - .expect("the existance of the first char was guaranteed by the caller"); - - // If the string starts with `0b` it must be a binary literal - if first_char == '0' && matches!(self.source.peek(), Some('b')) { - self.source.next(); // eat the 'b' - - self.lex_to_buffer(&mut buf, |c| c == '1' || c == '0'); - - let is_number = if matches!(self.source.peek(), Some('n')) { - self.source.next(); - true - } else { - false - }; - - match self.source.peek() { - Some(c) if c.is_ascii_digit() => { - self.source.next(); - return Err(Error::text( - "invalid digit for base 2 literal".to_string(), - self.source.span(), - )); - } - Some(c) if c.is_ascii_alphabetic() => { - self.source.next(); - return Err(Error::text( - "invalid suffix for base 2 literal".to_string(), - self.source.span(), - )); - } - _ => {} - } - - let token = if is_number { - buf_to_number_token_with_radix(&buf, 2) - } else { - buf_to_primitive_token_with_radix(&buf, 2, self.source.create_span(start_offset))? - }; - return match token { - Some(token) => Ok(TokenLocation { - token, - span: self.source.create_span(start_offset), - }), - None => Err(Error::text( - "invalid base 2 number".to_string(), + let is_number = if matches!(self.source.peek(), Some('n')) { + if !allow_number_suffix { + return Err(Error::text( + "the `n` suffix is not supported on arbitrary-radix literals".to_string(), self.source.create_span(start_offset), - )), - }; - } - - if first_char == '0' && matches!(self.source.peek(), Some('x')) { + )); + } self.source.next(); + true + } else { + false + }; - self.lex_to_buffer(&mut buf, |c| c.is_ascii_hexdigit()); - - let is_number = if matches!(self.source.peek(), Some('n')) { + match self.source.peek() { + Some(c) if c.is_ascii_digit() => { + let span = self.source.span(); self.source.next(); - true - } else { - false - }; - - let token = if is_number { - buf_to_number_token_with_radix(&buf, 16) - } else { - buf_to_primitive_token_with_radix(&buf, 16, self.source.create_span(start_offset))? - }; - return match token { - Some(token) => Ok(TokenLocation { - token, - span: self.source.create_span(start_offset), - }), - None => Err(Error::text( - "invalid base 16 number".to_string(), - self.source.create_span(start_offset), - )), - }; + return Err(Error::text( + format!("invalid digit for base {radix} literal"), + span, + )); + } + Some(c) if c.is_ascii_alphabetic() => { + let span = self.source.span(); + self.source.next(); + return Err(Error::text( + format!("invalid suffix for base {radix} literal"), + span, + )); + } + _ => {} } - if first_char == '0' && matches!(self.source.peek(), Some('o')) { - self.source.next(); + let span = self.source.create_span(start_offset); + let literal = if is_number { + buf_to_number_literal_with_radix(&buf, radix) + } else { + buf_to_primitive_literal_with_radix(&buf, radix, span)? + } + .ok_or_else(|| Error::text(format!("invalid base {radix} number"), span))?; - self.lex_to_buffer(&mut buf, |c| matches!(c, '0'..='7')); + Ok(TokenLocation { + token: Token::NumericLiteral(literal), + span, + }) + } +} - let is_number = if matches!(self.source.peek(), Some('n')) { - self.source.next(); - true - } else { - false - }; +impl NumberLexer for Lexer<'_> { + #[allow(clippy::too_many_lines)] + fn lex_number(&mut self) -> Result { + let mut buf = String::new(); - let token = if is_number { - buf_to_number_token_with_radix(&buf, 8) - } else { - buf_to_primitive_token_with_radix(&buf, 8, self.source.create_span(start_offset))? - }; - return match token { - Some(token) => Ok(TokenLocation { - token, - span: self.source.create_span(start_offset), - }), - None => Err(Error::text( - "invalid base 16 number".to_string(), - self.source.create_span(start_offset), - )), + let start_offset = self.source.current_offset(); + let first_char = self + .source + .next() + .expect("the existence of the first char was guaranteed by the caller"); + + if first_char == '0' { + let radix = match self.source.peek() { + Some('b') => Some(2), + Some('o') => Some(8), + Some('x') => Some(16), + _ => None, }; + if let Some(radix) = radix { + self.source.next(); + return self.lex_integer_with_radix(start_offset, radix, true); + } } // The first digit of the literal is not part of a radix but it's part of the number @@ -189,47 +158,21 @@ impl NumberLexer for Lexer<'_> { )); }; - match radix { + return match radix { 2..=36 => { - let mut buf = String::new(); - self.lex_to_buffer(&mut buf, validator_for_radix(usize::from(radix))); - - if matches!(self.source.peek(), Some('n')) { - return Err(Error::text( - "the `n` suffix is not supported on arbitrary-radix literals" - .to_string(), - self.source.create_span(start_offset), - )); - } - - return match buf_to_primitive_token_with_radix( - &buf, - u32::from(radix), - self.source.create_span(start_offset), - )? { - Some(token) => Ok(TokenLocation { - token, - span: self.source.create_span(start_offset), - }), - None => Err(Error::text( - "invalid base 16 number".to_string(), - self.source.create_span(start_offset), - )), - }; - } - _ => { - return Err(Error::text( - "invalid radix, must be between 2 and 36 OR 64".to_string(), - self.source.create_span(start_offset), - )); + self.lex_integer_with_radix(start_offset, u32::from(radix), false) } - } + _ => Err(Error::text( + "invalid radix, must be between 2 and 36 OR 64".to_string(), + self.source.create_span(start_offset), + )), + }; } 'n' => { self.source.next(); let token = if is_float { buf.parse::() - .map(Token::NumberFloat) + .map(NumericLiteral::NumberFloat) .map_err(|_error| { Error::text( format!("invalid Number literal '{buf}n'"), @@ -237,7 +180,7 @@ impl NumberLexer for Lexer<'_> { ) })? } else { - buf_to_number_token_with_radix(&buf, 10).ok_or_else(|| { + buf_to_number_literal_with_radix(&buf, 10).ok_or_else(|| { Error::text( format!("invalid Number literal '{buf}n'"), self.source.create_span(start_offset), @@ -245,7 +188,7 @@ impl NumberLexer for Lexer<'_> { })? }; return Ok(TokenLocation { - token, + token: Token::NumericLiteral(token), span: self.source.create_span(start_offset), }); } @@ -260,7 +203,9 @@ impl NumberLexer for Lexer<'_> { }; return Ok(TokenLocation { - token: Token::Complex(Complex::new(0.0, num)), + token: Token::NumericLiteral(NumericLiteral::Complex(Complex::new( + 0.0, num, + ))), span: self.source.create_span(start_offset), }); } @@ -270,27 +215,27 @@ impl NumberLexer for Lexer<'_> { } let Some(token) = - buf_to_primitive_token_with_radix(&buf, 10, self.source.create_span(start_offset))? - .or_else(|| buf.parse::().map(Token::Float64).ok()) + buf_to_primitive_literal_with_radix(&buf, 10, self.source.create_span(start_offset))? + .or_else(|| buf.parse::().map(NumericLiteral::Float64).ok()) else { // If we've lexed the int/float correctly this error should never happen, that's why it's probably safe to panic panic!("unable to convert buffer into Token"); }; Ok(TokenLocation { - token, + token: Token::NumericLiteral(token), span: self.source.create_span(start_offset), }) } } -fn buf_to_primitive_token_with_radix( +fn buf_to_primitive_literal_with_radix( buf: &str, radix: u32, span: crate::Span, -) -> Result, Error> { +) -> Result, Error> { if let Ok(num) = i64::from_str_radix(buf, radix) { - return Ok(Some(Token::Int64(num))); + return Ok(Some(NumericLiteral::Int64(num))); } let Ok(value) = BigInt::from_str_radix(buf, radix) else { @@ -303,14 +248,10 @@ fn buf_to_primitive_token_with_radix( )) } -fn buf_to_number_token_with_radix(buf: &str, radix: u32) -> Option { +fn buf_to_number_literal_with_radix(buf: &str, radix: u32) -> Option { BigInt::from_str_radix(buf, radix) .ok() - .map(Token::NumberInt) -} - -fn validator_for_radix(radix: usize) -> impl Fn(char) -> bool { - move |c| "0123456789abcdefghijlkmnopqrstuvwxyz"[0..radix].contains(c.to_ascii_lowercase()) + .map(NumericLiteral::NumberInt) } #[cfg(test)] @@ -318,6 +259,73 @@ mod tests { use super::*; use crate::SourceId; + fn lex_one(source: &str) -> Result { + Lexer::new(source, SourceId::SYNTHETIC) + .next() + .expect("literal should produce a lexer result") + } + + #[test] + fn prefixed_integer_literals_share_number_suffix_handling() { + for (source, value) in [ + ("0b101010n", 42.into()), + ("0o52n", 42.into()), + ("0x2an", 42.into()), + ] { + let token = lex_one(source).expect("literal should be valid"); + + assert_eq!( + token.token, + Token::NumericLiteral(NumericLiteral::NumberInt(value)), + "unexpected token for {source}" + ); + assert_eq!(token.span.range(), 0..source.len()); + } + } + + #[test] + fn radix_errors_report_the_actual_base() { + for (source, expected) in [ + ("0b", "invalid base 2 number"), + ("0o", "invalid base 8 number"), + ("0x", "invalid base 16 number"), + ("8r", "invalid base 8 number"), + ] { + let error = lex_one(source).expect_err("literal should be invalid"); + + assert_eq!( + error.to_string(), + expected, + "unexpected diagnostic for {source}" + ); + } + } + + #[test] + fn prefixed_integer_literals_reject_invalid_digits_and_suffixes() { + for (source, expected, expected_range) in [ + ("0b2", "invalid digit for base 2 literal", 2..3), + ("0o8", "invalid digit for base 8 literal", 2..3), + ("0xg", "invalid suffix for base 16 literal", 2..3), + ("0b1n2", "invalid digit for base 2 literal", 4..5), + ("0o7nq", "invalid suffix for base 8 literal", 4..5), + ("0xffnq", "invalid suffix for base 16 literal", 5..6), + ] { + let error = lex_one(source).expect_err("literal should be invalid"); + + assert_eq!( + error.to_string(), + expected, + "unexpected diagnostic for {source}" + ); + assert_eq!( + error.location().range(), + expected_range, + "unexpected diagnostic span for {source}" + ); + } + } + #[test] fn oversized_bare_integer_literals_are_rejected_by_the_lexer() { for literal in [ @@ -327,10 +335,7 @@ mod tests { "0x8000000000000000", "16r8000000000000000", ] { - let error = Lexer::new(literal, SourceId::SYNTHETIC) - .next() - .expect("literal should produce a lexer result") - .expect_err("oversized bare literal should fail lexing"); + let error = lex_one(literal).expect_err("oversized bare literal should fail lexing"); assert_eq!( error.to_string(), diff --git a/ndc_lexer/src/token.rs b/ndc_lexer/src/token.rs index 723d9da6..92ee577e 100644 --- a/ndc_lexer/src/token.rs +++ b/ndc_lexer/src/token.rs @@ -4,15 +4,37 @@ use std::fmt; use super::Span; -#[derive(PartialEq, Clone)] -pub enum Token { - String(String), +#[derive(Debug, PartialEq, Clone)] +pub enum NumericLiteral { Int64(i64), Float64(f64), NumberInt(BigInt), NumberFloat(f64), Complex(Complex64), - Infinity, +} + +impl fmt::Display for NumericLiteral { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Int64(n) => write!(f, "{n}"), + Self::Float64(n) => { + let mut buffer = ryu::Buffer::new(); + write!(f, "{}", buffer.format(*n)) + } + Self::NumberInt(n) => write!(f, "{n}n"), + Self::NumberFloat(n) => { + let mut buffer = ryu::Buffer::new(); + write!(f, "{}n", buffer.format(*n)) + } + Self::Complex(n) => write!(f, "{n}"), + } + } +} + +#[derive(PartialEq, Clone)] +pub enum Token { + String(String), + NumericLiteral(NumericLiteral), Identifier(String), OpAssign(Box), @@ -99,24 +121,9 @@ impl fmt::Display for Token { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s: &str = match self { Self::String(str) => str, - Self::Int64(n) => { - return write!(f, "{n}"); - } - Self::Float64(n) => { - let mut buffer = ryu::Buffer::new(); - return write!(f, "{}", buffer.format(*n)); - } - Self::NumberInt(n) => { - return write!(f, "{n}n"); - } - Self::NumberFloat(n) => { - let mut buffer = ryu::Buffer::new(); - return write!(f, "{}n", buffer.format(*n)); - } - Self::Complex(n) => { + Self::NumericLiteral(n) => { return write!(f, "{n}"); } - Self::Infinity => "Inf", Self::Identifier(ident) => ident, // Self::DeclareVar => ":=", Self::EqualsSign => "=", @@ -314,8 +321,8 @@ impl From for Token { fn from(value: String) -> Self { match value.as_str() { // YOLO for now just have Inf and NaN here - "Inf" => Self::Float64(f64::INFINITY), - "NaN" => Self::Float64(f64::NAN), + "Inf" => Self::NumericLiteral(NumericLiteral::Float64(f64::INFINITY)), + "NaN" => Self::NumericLiteral(NumericLiteral::Float64(f64::NAN)), // Normal keywords "and" => Self::LogicAnd, "as" => Self::As, diff --git a/ndc_lsp/src/scope_resolve.rs b/ndc_lsp/src/scope_resolve.rs index 1d622d9e..106803be 100644 --- a/ndc_lsp/src/scope_resolve.rs +++ b/ndc_lsp/src/scope_resolve.rs @@ -203,11 +203,7 @@ fn collect(expr: &ExpressionLocation, scope: Span, out: &mut Vec) { Expression::Identifier { .. } | Expression::BoolLiteral(_) | Expression::StringLiteral(_) - | Expression::Int64Literal(_) - | Expression::Float64Literal(_) - | Expression::NumberIntLiteral(_) - | Expression::NumberFloatLiteral(_) - | Expression::ComplexLiteral(_) + | Expression::NumericLiteral(_) | Expression::Break | Expression::Continue => {} Expression::StructDeclaration { name, .. } => { diff --git a/ndc_lsp/src/visitor.rs b/ndc_lsp/src/visitor.rs index 57a260c8..cd0cf37b 100644 --- a/ndc_lsp/src/visitor.rs +++ b/ndc_lsp/src/visitor.rs @@ -188,11 +188,7 @@ fn child_expressions(expr: &ExpressionLocation) -> Vec<&ExpressionLocation> { Expression::Identifier { .. } | Expression::BoolLiteral(_) | Expression::StringLiteral(_) - | Expression::Int64Literal(_) - | Expression::Float64Literal(_) - | Expression::NumberIntLiteral(_) - | Expression::NumberFloatLiteral(_) - | Expression::ComplexLiteral(_) + | Expression::NumericLiteral(_) | Expression::Break | Expression::Continue | Expression::StructDeclaration { .. } => {} @@ -359,11 +355,7 @@ fn walk_expression(visitor: &mut impl AstVisitor, expr: &ExpressionLocation) { Expression::Identifier { .. } | Expression::BoolLiteral(_) | Expression::StringLiteral(_) - | Expression::Int64Literal(_) - | Expression::Float64Literal(_) - | Expression::NumberIntLiteral(_) - | Expression::NumberFloatLiteral(_) - | Expression::ComplexLiteral(_) + | Expression::NumericLiteral(_) | Expression::Break | Expression::Continue | Expression::StructDeclaration { .. } => {} diff --git a/ndc_parser/Cargo.toml b/ndc_parser/Cargo.toml index 7ab23ab4..eaf02ce8 100644 --- a/ndc_parser/Cargo.toml +++ b/ndc_parser/Cargo.toml @@ -6,6 +6,5 @@ version.workspace = true [dependencies] ndc_core.workspace = true ndc_lexer.workspace = true -num.workspace = true derive_more = { workspace = true } -thiserror.workspace = true \ No newline at end of file +thiserror.workspace = true diff --git a/ndc_parser/src/expression.rs b/ndc_parser/src/expression.rs index 0fd26c0d..9c3c8670 100644 --- a/ndc_parser/src/expression.rs +++ b/ndc_parser/src/expression.rs @@ -3,9 +3,7 @@ use crate::parser::Error as ParseError; use crate::type_expr::TypeExpr; use ndc_core::r#struct::StructId; use ndc_core::{StaticType, TypeSignature}; -use ndc_lexer::Span; -use num::BigInt; -use num::complex::Complex64; +use ndc_lexer::{NumericLiteral, Span}; use std::sync::atomic::{AtomicU32, Ordering}; /// Unique identity for an AST node. Used as a key in side tables (e.g. the @@ -95,11 +93,7 @@ pub enum Expression { // Literals BoolLiteral(bool), StringLiteral(String), - Int64Literal(i64), - Float64Literal(f64), - NumberIntLiteral(BigInt), - NumberFloatLiteral(f64), - ComplexLiteral(Complex64), + NumericLiteral(NumericLiteral), Identifier { name: String, resolved: Binding, diff --git a/ndc_parser/src/parser.rs b/ndc_parser/src/parser.rs index 0e3ca175..095cc9ed 100644 --- a/ndc_parser/src/parser.rs +++ b/ndc_parser/src/parser.rs @@ -8,7 +8,7 @@ use crate::expression::{Expression, StructField}; use crate::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; use crate::type_expr::TypeExpr; use ndc_core::{Parameter, StaticType, TypeSignature}; -use ndc_lexer::{Span, Token, TokenLocation}; +use ndc_lexer::{NumericLiteral, Span, Token, TokenLocation}; pub struct Parser { tokens: Vec, @@ -794,7 +794,8 @@ impl Parser { .. } => { *start = Some(Box::new( - Expression::Int64Literal(0).to_location(index_expression.span), + Expression::NumericLiteral(NumericLiteral::Int64(0)) + .to_location(index_expression.span), )); } _ => {} @@ -1067,11 +1068,7 @@ impl Parser { let expression = match token_location.token { Token::False => Expression::BoolLiteral(false), Token::True => Expression::BoolLiteral(true), - Token::Int64(num) => Expression::Int64Literal(num), - Token::Float64(num) => Expression::Float64Literal(num), - Token::NumberInt(num) => Expression::NumberIntLiteral(num), - Token::NumberFloat(num) => Expression::NumberFloatLiteral(num), - Token::Complex(num) => Expression::ComplexLiteral(num), + Token::NumericLiteral(literal) => Expression::NumericLiteral(literal), Token::String(value) => Expression::StringLiteral(value), Token::Identifier(identifier) => Expression::Identifier { name: identifier, diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index b920e323..d58050b2 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -3,7 +3,7 @@ use crate::value::{CompiledFunction, Function}; use crate::{Object, Value}; use ndc_core::r#struct::StructRegistry; use ndc_core::{StaticType, TypeSignature}; -use ndc_lexer::Span; +use ndc_lexer::{NumericLiteral, Span}; use ndc_parser::{ AugmentedAssignmentPlan, Binding, Candidate, CaptureSource, Expression, ExpressionLocation, ForBody, ForIteration, FunctionParameter, LogicalOperator, Lvalue, ResolvedVar, @@ -212,28 +212,19 @@ impl Compiler { let idx = self.ir.add_constant(Value::string(s)); self.ir.write(OpCode::Constant(idx), span); } - Expression::Int64Literal(i) => { - let idx = self.ir.add_constant(Value::int(i)); - self.ir.write(OpCode::Constant(idx), span); - } - Expression::Float64Literal(f) => { - let idx = self.ir.add_constant(Value::float(f)); - self.ir.write(OpCode::Constant(idx), span); - } - Expression::NumberIntLiteral(i) => { - let idx = self - .ir - .add_constant(Value::number(ndc_core::num::AdvancedNumber::Int(i))); - self.ir.write(OpCode::Constant(idx), span); - } - Expression::NumberFloatLiteral(f) => { - let idx = self - .ir - .add_constant(Value::number(ndc_core::num::AdvancedNumber::Float(f))); - self.ir.write(OpCode::Constant(idx), span); - } - Expression::ComplexLiteral(c) => { - let idx = self.ir.add_constant(Value::complex(c)); + Expression::NumericLiteral(literal) => { + let value = match literal { + NumericLiteral::Int64(i) => Value::int(i), + NumericLiteral::Float64(f) => Value::float(f), + NumericLiteral::NumberInt(i) => { + Value::number(ndc_core::num::AdvancedNumber::Int(i)) + } + NumericLiteral::NumberFloat(f) => { + Value::number(ndc_core::num::AdvancedNumber::Float(f)) + } + NumericLiteral::Complex(c) => Value::complex(c), + }; + let idx = self.ir.add_constant(value); self.ir.write(OpCode::Constant(idx), span); } Expression::Identifier { name, resolved } => { diff --git a/tests/proptest/tests/panic.rs b/tests/proptest/tests/panic.rs index 5221801f..aa44a0d4 100644 --- a/tests/proptest/tests/panic.rs +++ b/tests/proptest/tests/panic.rs @@ -29,7 +29,7 @@ use ndc_analyser::{Analyser, ScopeTree}; use ndc_core::FunctionRegistry; use ndc_core::r#struct::StructRegistry; -use ndc_lexer::{Span, Token, TokenLocation}; +use ndc_lexer::{NumericLiteral, Span, Token, TokenLocation}; use ndc_parser::Parser; use ndc_vm::compiler::Compiler; use ndc_vm::value::{Function as VmFunction, Object as VmObject}; @@ -99,7 +99,6 @@ fn install_quiet_panic_hook() { fn arb_token() -> impl Strategy { let mut atoms: Vec = vec![ // Literals (unit) - Token::Infinity, Token::True, Token::False, // Operators @@ -168,16 +167,24 @@ fn arb_token() -> impl Strategy { atoms.push(Token::String(s.to_string())); } for i in [-2_i64, -1, 0, 1, 2, 42, i64::MAX] { - atoms.push(Token::Int64(i)); + atoms.push(Token::NumericLiteral(NumericLiteral::Int64(i))); } for f in [0.0_f64, 1.0, -1.0, 0.5, f64::INFINITY, f64::NAN] { - atoms.push(Token::Float64(f)); + atoms.push(Token::NumericLiteral(NumericLiteral::Float64(f))); } - atoms.push(Token::NumberInt(num::BigInt::from(0))); - atoms.push(Token::NumberInt(num::BigInt::from(i128::MAX))); - atoms.push(Token::NumberFloat(0.5)); - atoms.push(Token::Complex(num::complex::Complex64::new(0.0, 1.0))); - atoms.push(Token::Complex(num::complex::Complex64::new(2.0, -3.0))); + atoms.push(Token::NumericLiteral(NumericLiteral::NumberInt( + num::BigInt::from(0), + ))); + atoms.push(Token::NumericLiteral(NumericLiteral::NumberInt( + num::BigInt::from(i128::MAX), + ))); + atoms.push(Token::NumericLiteral(NumericLiteral::NumberFloat(0.5))); + atoms.push(Token::NumericLiteral(NumericLiteral::Complex( + num::complex::Complex64::new(0.0, 1.0), + ))); + atoms.push(Token::NumericLiteral(NumericLiteral::Complex( + num::complex::Complex64::new(2.0, -3.0), + ))); // Inner token of an `OpAssign`. Lexer invariant: only augmentable tokens // appear here, so we mirror that to avoid finding "panics" that no real From b190841febe35e6f75b1bafcb664a1dff77c573e Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 3 Sep 2026 11:34:04 +0200 Subject: [PATCH 16/31] Fix some performance degradation --- benches/programs/closures.ndc | 2 +- benches/programs/perlin.ndc | 4 +- ndc_core/src/num.rs | 102 ++++++++++++++++++++++++++++++++++ ndc_stdlib/src/math.rs | 19 +++++-- ndc_vm/src/value/numeric.rs | 33 ++++++++++- 5 files changed, 150 insertions(+), 10 deletions(-) diff --git a/benches/programs/closures.ndc b/benches/programs/closures.ndc index a5ddd233..fd9c42c5 100644 --- a/benches/programs/closures.ndc +++ b/benches/programs/closures.ndc @@ -1,6 +1,6 @@ let total = 0; for _ in 0..20 { let adders = [fn(x) => x + i for i in 0..10_000]; - total = total + adders.map(fn(f) => f(0)).sum; + total = total + (adders.map(fn(f) => f(0)) as List).sum; }; print(total); diff --git a/benches/programs/perlin.ndc b/benches/programs/perlin.ndc index d452b581..c0287494 100644 --- a/benches/programs/perlin.ndc +++ b/benches/programs/perlin.ndc @@ -5,7 +5,7 @@ fn fade(t) => t * t * t * (t * (t * 6 - 15) + 10); fn lerp(a, b, t) => a + t * (b - a); -fn gradient(h, x, y) => (vectors[h % 8] * (x, y)).sum +fn gradient(h, x, y) => ((vectors[h % 8] * (x, y)) as Tuple).sum fn perlin(x, y, perm) { let xi, yi = int(x) & 255, int(y) & 255; @@ -33,4 +33,4 @@ fn benchmark_perlin(grid_size) { } let noise = benchmark_perlin(100); -print("Min:", noise.min, "Max:", noise.max, "Avg:", (noise.sum / noise.len)); +print("Min:", noise.min, "Max:", noise.max, "Avg:", ((noise as List).sum / noise.len)); diff --git a/ndc_core/src/num.rs b/ndc_core/src/num.rs index 1491704f..c497a159 100644 --- a/ndc_core/src/num.rs +++ b/ndc_core/src/num.rs @@ -99,8 +99,44 @@ impl Default for AdvancedNumber { } } +/// Tags keeping the two hashing schemes below from colliding with each other. +const HASH_TAG_EXACT_I64: u8 = 0; +const HASH_TAG_CANONICAL: u8 = 1; + +/// Hash a number whose exact value is the integer `value`. +/// +/// Every numeric representation routes integers here, so `5`, `5.0`, `5n` and +/// `10/2` produce one hash without allocating a [`BigInt`] to say so. +pub fn hash_exact_i64(value: i64, state: &mut H) { + HASH_TAG_EXACT_I64.hash(state); + value.hash(state); +} + +/// `5.0` answers `Some(5)`; `2.5`, `1e300`, `inf` and `NaN` answer `None`. +#[must_use] +pub fn exact_f64_to_i64(value: f64) -> Option { + // `i64::MIN` is exactly -2^63, so negating it gives the first f64 above + // `i64::MAX`. The bound is exclusive because `i64::MAX` itself rounds up + // to 2^63 when converted. + const LOWER: f64 = i64::MIN as f64; + const UPPER: f64 = -LOWER; + + // `fract` is NaN for infinities and NaN, so both fail this comparison. + if value.fract() == 0.0 && (LOWER..UPPER).contains(&value) { + Some(value as i64) + } else { + None + } +} + impl Hash for AdvancedNumber { fn hash(&self, state: &mut H) { + if let Some(value) = self.as_exact_i64() { + hash_exact_i64(value, state); + return; + } + + HASH_TAG_CANONICAL.hash(state); self.canonical().hash(state); } } @@ -370,6 +406,24 @@ impl Div for &AdvancedNumber { } impl AdvancedNumber { + /// The exact value as an `i64`, when this number is a real integer that + /// fits one. `5n`, `5.0`, `10/2` and `5+0i` all answer `Some(5)`, while + /// `2.5`, `1e300`, `inf` and `5+1i` answer `None`. + /// + /// Every representation of one integer answers the same `Some`, which is + /// what lets [`Hash`] skip building a [`CanonicalNumber`] for it. + #[must_use] + pub fn as_exact_i64(&self) -> Option { + match self { + Self::Int(value) => value.to_i64(), + Self::Float(value) => exact_f64_to_i64(*value), + // A reduced rational is an integer exactly when its denominator is + // one, which `is_integer` checks without dividing. + Self::Rational(value) => value.is_integer().then(|| value.numer().to_i64())?, + Self::Complex(value) => (value.im == 0.0).then(|| exact_f64_to_i64(value.re))?, + } + } + fn canonical(&self) -> CanonicalNumber { match self { Self::Int(value) => CanonicalNumber { @@ -816,6 +870,54 @@ mod tests { assert_ne!(tenth, AdvancedNumber::Float(0.1)); } + #[test] + fn every_representation_of_one_integer_hashes_alike() { + let five = [ + AdvancedNumber::Int(BigInt::from(5)), + AdvancedNumber::Float(5.0), + AdvancedNumber::rational(BigRational::new(10.into(), 2.into())), + AdvancedNumber::complex(5.0, -0.0), + ]; + + for value in &five { + assert_eq!(value.as_exact_i64(), Some(5)); + assert_eq!(*value, five[0]); + assert_eq!(hash(value), hash(&five[0])); + } + } + + #[test] + fn values_off_the_integer_fast_path_still_agree() { + let huge = AdvancedNumber::Int(BigInt::from(u64::MAX) * BigInt::from(u64::MAX)); + let half = AdvancedNumber::rational(BigRational::new(1.into(), 2.into())); + + assert_eq!(huge.as_exact_i64(), None); + assert_eq!(half.as_exact_i64(), None); + + // 0.5 is exactly 1/2, so the two representations stay interchangeable + // on the canonical path. + assert_eq!(half, AdvancedNumber::Float(0.5)); + assert_eq!(hash(&half), hash(&AdvancedNumber::Float(0.5))); + assert_ne!(hash(&huge), hash(&half)); + } + + #[test] + fn integer_fast_path_stops_at_the_i64_boundaries() { + assert_eq!(exact_f64_to_i64(i64::MIN as f64), Some(i64::MIN)); + // 2^63 is one past i64::MAX, so it falls back instead of wrapping. + assert_eq!(exact_f64_to_i64(-(i64::MIN as f64)), None); + assert_eq!(exact_f64_to_i64(f64::INFINITY), None); + assert_eq!(exact_f64_to_i64(f64::NAN), None); + assert_eq!(exact_f64_to_i64(2.5), None); + + // Negative zero is zero, so it must not take a bucket of its own. + assert_eq!(exact_f64_to_i64(-0.0), Some(0)); + assert_eq!( + hash(&AdvancedNumber::Float(-0.0)), + hash(&AdvancedNumber::Int(BigInt::from(0))) + ); + } + #[test] fn nan_is_equal_and_sorts_after_infinity() { let nan = AdvancedNumber::Float(f64::NAN); diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 8dc4ca6e..6374c548 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -190,17 +190,26 @@ fn eval_binary( } } +#[inline] fn numeric_ref(mode: NumericMode, value: &Value) -> Result, VmError> { match value.numeric_ref() { Some(number) if number.mode() == mode => Ok(number), - _ => Err(VmError::native(format!( - "expected {}, got {}", - mode.static_type(), - value.static_type() - ))), + _ => Err(wrong_mode(mode, value)), } } +/// Kept out of line so the operand check above stays small enough to inline +/// into each registered overload. +#[cold] +#[inline(never)] +fn wrong_mode(mode: NumericMode, value: &Value) -> VmError { + VmError::native(format!( + "expected {}, got {}", + mode.static_type(), + value.static_type() + )) +} + fn eval_int_binary(operation: BinaryOperation, left: i64, right: i64) -> Result { if right == 0 && matches!( diff --git a/ndc_vm/src/value/numeric.rs b/ndc_vm/src/value/numeric.rs index 0c21956e..8d9d5725 100644 --- a/ndc_vm/src/value/numeric.rs +++ b/ndc_vm/src/value/numeric.rs @@ -1,5 +1,5 @@ use ndc_core::StaticType; -use ndc_core::num::AdvancedNumber; +use ndc_core::num::{AdvancedNumber, hash_exact_i64}; use num::{FromPrimitive, ToPrimitive}; use std::cmp::Ordering; use std::hash::{Hash, Hasher}; @@ -160,7 +160,10 @@ impl Ord for NumericRef<'_> { impl Hash for NumericRef<'_> { fn hash(&self, state: &mut H) { match self { - Self::Int(value) => AdvancedNumber::Int((*value).into()).hash(state), + // Both arms agree with `AdvancedNumber`'s hash, which routes + // integers through `hash_exact_i64` too, so `5`, `5.0` and `5n` + // land in the same bucket. + Self::Int(value) => hash_exact_i64(*value, state), Self::Float(value) => AdvancedNumber::Float(*value).hash(state), Self::Number(value) => value.hash(state), } @@ -213,6 +216,32 @@ fn compare_int_float(integer: i64, float: f64) -> Ordering { #[cfg(test)] mod tests { use super::*; + use std::collections::hash_map::DefaultHasher; + + fn hash(value: NumericRef<'_>) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() + } + + #[test] + fn equal_numeric_refs_hash_alike_across_modes() { + let five = AdvancedNumber::Int(5.into()); + let five_halves = AdvancedNumber::rational(num::BigRational::new(5.into(), 2.into())); + + for reference in [NumericRef::Float(5.0), NumericRef::Number(&five)] { + assert_eq!(reference, NumericRef::Int(5)); + assert_eq!(hash(reference), hash(NumericRef::Int(5))); + } + + // Values that miss the integer fast path still have to agree. + assert_eq!(NumericRef::Float(2.5), NumericRef::Number(&five_halves)); + assert_eq!( + hash(NumericRef::Float(2.5)), + hash(NumericRef::Number(&five_halves)) + ); + assert_ne!(hash(NumericRef::Int(5)), hash(NumericRef::Float(2.5))); + } #[test] fn numeric_modes_promote_by_runtime_representation() { From 847d1bdec9906a21788e532f08abf859e11ddf30 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 3 Sep 2026 12:56:20 +0200 Subject: [PATCH 17/31] =?UTF-8?q?perf(stdlib):=20read=20numeric=20operands?= =?UTF-8?q?=20straight=20from=20their=20slots=20=E2=9A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pick the evaluator per overload at registration instead of rediscovering the output mode on every call, and give each integer operation its own precondition so add, subtract and multiply test nothing but overflow. Collapse the fixed-signature natives onto one declare_typed! macro so a type is named once instead of three times, and report operand mismatches from a single cold path. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_stdlib/src/math.rs | 627 +++++++++++++++++++++++------------------ 1 file changed, 348 insertions(+), 279 deletions(-) diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 6374c548..97512cae 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -83,6 +83,74 @@ fn declare( })); } +/// Declares a native whose operands and result have fixed types. +/// +/// A type name is written once and used twice: as the operand's declared +/// [`StaticType`] and as the [`Value`] variant it must arrive in. A signature +/// therefore cannot drift away from the destructuring that enforces it, and +/// the mismatch error is reported from one place. +/// +/// Each operand is bound by name to a reference to its payload, and the body +/// evaluates to the result's payload — or, with a `Result<_>` result type, to a +/// `Result` carrying it: +/// +/// ```ignore +/// declare_typed!(env, "&", (left: Int, right: Int) -> Int, "…", left & right); +/// declare_typed!(env, "-", (value: Int) -> Result, "…", +/// value.checked_neg().ok_or_else(|| overflowed())); +/// ``` +macro_rules! declare_typed { + ($env:expr, $name:expr, ($($operand:ident: $operand_type:ident),+) -> Result<$result:ident>, + $documentation:expr, $body:expr) => { + declare( + $env, + $name, + vec![$(StaticType::$operand_type),+], + StaticType::$result, + $documentation, + move |args| { + let [$(Value::$operand_type($operand)),+] = args else { + return Err(wrong_operands(&[$(StaticType::$operand_type),+], args)); + }; + $body.map(|payload| wrap_payload!($result, payload)) + }, + ) + }; + ($env:expr, $name:expr, ($($operand:ident: $operand_type:ident),+) -> $result:ident, + $documentation:expr, $body:expr) => { + declare_typed!( + $env, $name, ($($operand: $operand_type),+) -> Result<$result>, $documentation, + Ok::<_, VmError>($body) + ) + }; +} + +/// Wraps a result payload as a [`Value`]. `Number` is not a plain variant +/// wrapper, so it needs its own arm. +macro_rules! wrap_payload { + (Number, $payload:expr) => { + Value::from_number($payload) + }; + ($variant:ident, $payload:expr) => { + Value::$variant($payload) + }; +} + +/// Reports operands that do not match a native's declared signature. +#[cold] +#[inline(never)] +fn wrong_operands(expected: &[StaticType], args: &[Value]) -> VmError { + let list = |types: Vec| types.join(", "); + let expected = list(expected.iter().map(StaticType::to_string).collect()); + let got = list( + args.iter() + .map(|arg| arg.static_type().to_string()) + .collect(), + ); + + VmError::native(format!("expected ({expected}), got ({got})")) +} + fn arity(args: &[Value], expected: usize) -> Result<(), VmError> { if args.len() == expected { Ok(()) @@ -128,24 +196,53 @@ fn register_binary_arithmetic(env: &mut FunctionRegistry>) { for operation in OPERATIONS { for (left_mode, right_mode) in NUMERIC_PAIRS_BY_DYNAMIC_PRIORITY.into_iter().rev() { let output_mode = left_mode.promote(right_mode); - declare( - env, - operation.name(), - vec![left_mode.static_type(), right_mode.static_type()], - output_mode.static_type(), - operation.documentation(), - move |args| { - arity(args, 2)?; - eval_binary( - operation, - left_mode, - right_mode, - output_mode, - &args[0], - &args[1], - ) - }, - ); + let parameters = vec![left_mode.static_type(), right_mode.static_type()]; + let (name, documentation) = (operation.name(), operation.documentation()); + + // Choosing the evaluator per overload here, instead of letting one + // shared closure rediscover the output mode on every call, is what + // lets `1 + 2` read two `i64` slots and add them. + match output_mode { + NumericMode::Int => declare( + env, + name, + parameters, + StaticType::Int, + documentation, + move |args| { + let [left, right] = args else { + return Err(wrong_arity(args)); + }; + eval_int_operands(operation, left, right) + }, + ), + NumericMode::Float => declare( + env, + name, + parameters, + StaticType::Float, + documentation, + move |args| { + let [left, right] = args else { + return Err(wrong_arity(args)); + }; + eval_float_operands(operation, left_mode, right_mode, left, right) + }, + ), + NumericMode::Number => declare( + env, + name, + parameters, + StaticType::Number, + documentation, + move |args| { + let [left, right] = args else { + return Err(wrong_arity(args)); + }; + eval_number_operands(operation, left_mode, right_mode, left, right) + }, + ), + } } } } @@ -158,38 +255,87 @@ fn eval_binary( left: &Value, right: &Value, ) -> Result { - let left = numeric_ref(left_mode, left)?; - let right = numeric_ref(right_mode, right)?; - match output_mode { - NumericMode::Int => { - let left = left - .as_int() - .expect("Int output requires an Int left operand"); - let right = right - .as_int() - .expect("Int output requires an Int right operand"); - eval_int_binary(operation, left, right).map(Value::Int) - } - NumericMode::Float => { - let left = left - .to_primitive_float() - .expect("Float output only combines primitive operands"); - let right = right - .to_primitive_float() - .expect("Float output only combines primitive operands"); - Ok(Value::Float(eval_float_binary(operation, left, right))) - } - NumericMode::Number => { - let left = left.to_advanced_number(); - let right = right.to_advanced_number(); - eval_number_binary(operation, left, right) - .map(Value::from_number) - .map_err(native_error) - } + NumericMode::Int => eval_int_operands(operation, left, right), + NumericMode::Float => eval_float_operands(operation, left_mode, right_mode, left, right), + NumericMode::Number => eval_number_operands(operation, left_mode, right_mode, left, right), + } +} + +/// `promote` only answers `Int` for two `Int` operands, so both are plain +/// `i64` slots and need no numeric-mode round trip. +#[inline] +fn eval_int_operands( + operation: BinaryOperation, + left: &Value, + right: &Value, +) -> Result { + let Value::Int(left_int) = left else { + return Err(wrong_mode(NumericMode::Int, left)); + }; + let Value::Int(right_int) = right else { + return Err(wrong_mode(NumericMode::Int, right)); + }; + + eval_int_binary(operation, *left_int, *right_int).map(Value::Int) +} + +#[inline] +fn eval_float_operands( + operation: BinaryOperation, + left_mode: NumericMode, + right_mode: NumericMode, + left: &Value, + right: &Value, +) -> Result { + let Some(left_float) = primitive_float(left_mode, left) else { + return Err(wrong_mode(left_mode, left)); + }; + let Some(right_float) = primitive_float(right_mode, right) else { + return Err(wrong_mode(right_mode, right)); + }; + + Ok(Value::Float(eval_float_binary( + operation, + left_float, + right_float, + ))) +} + +fn eval_number_operands( + operation: BinaryOperation, + left_mode: NumericMode, + right_mode: NumericMode, + left: &Value, + right: &Value, +) -> Result { + let left = numeric_ref(left_mode, left)?.to_advanced_number(); + let right = numeric_ref(right_mode, right)?.to_advanced_number(); + + eval_number_binary(operation, left, right) + .map(Value::from_number) + .map_err(native_error) +} + +/// Reads an operand of a `Float` overload: an `Int` widens and a `Float` +/// passes through. A `Number` operand never reaches one, because any `Number` +/// promotes the whole operation to `Number`. +#[inline] +fn primitive_float(mode: NumericMode, value: &Value) -> Option { + match (mode, value) { + (NumericMode::Int, Value::Int(value)) => Some(*value as f64), + (NumericMode::Float, Value::Float(value)) => Some(*value), + _ => None, } } +/// Kept out of line so the operand destructuring above stays inlineable. +#[cold] +#[inline(never)] +fn wrong_arity(args: &[Value]) -> VmError { + VmError::native(format!("expected 2 arguments, got {}", args.len())) +} + #[inline] fn numeric_ref(mode: NumericMode, value: &Value) -> Result, VmError> { match value.numeric_ref() { @@ -211,43 +357,69 @@ fn wrong_mode(mode: NumericMode, value: &Value) -> VmError { } fn eval_int_binary(operation: BinaryOperation, left: i64, right: i64) -> Result { - if right == 0 - && matches!( - operation, - BinaryOperation::Div - | BinaryOperation::FloorDiv - | BinaryOperation::Rem - | BinaryOperation::RemEuclid - ) - { - return Err(VmError::native("division by zero".to_string())); - } - if right < 0 && matches!(operation, BinaryOperation::Pow) { - return Err(VmError::native( - "negative integer exponents require Number operands".to_string(), - )); - } let failed = || { VmError::native(format!( "integer operation overflowed or is undefined: {left} {} {right}", operation.name() )) }; + + // Each operation carries its own precondition, so add, subtract and + // multiply test nothing but their own overflow flag. match operation { BinaryOperation::Add => left.checked_add(right).ok_or_else(failed), BinaryOperation::Sub => left.checked_sub(right).ok_or_else(failed), BinaryOperation::Mul => left.checked_mul(right).ok_or_else(failed), - BinaryOperation::Div => left.checked_div(right).ok_or_else(failed), - BinaryOperation::FloorDiv => checked_floor_div(left, right).ok_or_else(failed), - BinaryOperation::Rem => left.checked_rem(right).ok_or_else(failed), - BinaryOperation::RemEuclid => left.checked_rem_euclid(right).ok_or_else(failed), - BinaryOperation::Pow => u32::try_from(right) - .ok() - .and_then(|right| left.checked_pow(right)) - .ok_or_else(failed), + BinaryOperation::Div => { + nonzero_divisor(right)?; + left.checked_div(right).ok_or_else(failed) + } + BinaryOperation::FloorDiv => { + nonzero_divisor(right)?; + checked_floor_div(left, right).ok_or_else(failed) + } + BinaryOperation::Rem => { + nonzero_divisor(right)?; + left.checked_rem(right).ok_or_else(failed) + } + BinaryOperation::RemEuclid => { + nonzero_divisor(right)?; + left.checked_rem_euclid(right).ok_or_else(failed) + } + BinaryOperation::Pow => { + if right < 0 { + return Err(negative_exponent()); + } + u32::try_from(right) + .ok() + .and_then(|right| left.checked_pow(right)) + .ok_or_else(failed) + } } } +/// A zero divisor is reported as such rather than as the overflow the checked +/// operation would otherwise report. +#[inline] +fn nonzero_divisor(divisor: i64) -> Result<(), VmError> { + if divisor == 0 { + return Err(division_by_zero()); + } + Ok(()) +} + +#[cold] +#[inline(never)] +fn division_by_zero() -> VmError { + VmError::native("division by zero".to_string()) +} + +#[cold] +#[inline(never)] +fn negative_exponent() -> VmError { + VmError::native("negative integer exponents require Number operands".to_string()) +} + fn checked_floor_div(left: i64, right: i64) -> Option { let quotient = left.checked_div(right)?; let remainder = left.checked_rem(right)?; @@ -289,50 +461,46 @@ fn eval_number_binary( } fn register_unary_arithmetic(env: &mut FunctionRegistry>) { - declare( + declare_typed!( env, "-", - vec![StaticType::Int], - StaticType::Int, + (value: Int) -> Result, "Negates an integer.", - |args| { - let [Value::Int(value)] = args else { - return Err(VmError::native("expected one Int argument".to_string())); - }; - value - .checked_neg() - .map(Value::Int) - .ok_or_else(|| VmError::native("integer negation overflowed".to_string())) - }, + value + .checked_neg() + .ok_or_else(|| VmError::native("integer negation overflowed".to_string())) ); - declare( + declare_typed!( env, "-", - vec![StaticType::Float], - StaticType::Float, + (value: Float) -> Float, "Negates a floating-point number.", - |args| { - let [Value::Float(value)] = args else { - return Err(VmError::native("expected one Float argument".to_string())); - }; - Ok(Value::Float(-value)) - }, + -value ); - declare( + declare_typed!( env, "-", - vec![StaticType::Number], - StaticType::Number, + (value: Number) -> Number, "Negates an advanced number.", - |args| { - let [Value::Number(value)] = args else { - return Err(VmError::native("expected one Number argument".to_string())); - }; - Ok(Value::from_number(value.as_ref().clone().neg())) - }, + value.as_ref().clone().neg() ); } +/// Orders two operands, reporting the pair that has no ordering between them. +fn compare_operands(args: &[Value]) -> Result { + let [left, right] = args else { + return Err(wrong_arity(args)); + }; + + left.partial_cmp(right).ok_or_else(|| { + VmError::native(format!( + "cannot compare {} and {}", + left.static_type(), + right.static_type() + )) + }) +} + fn register_comparisons(env: &mut FunctionRegistry>) { for (name, predicate, docs) in [ ( @@ -362,17 +530,7 @@ fn register_comparisons(env: &mut FunctionRegistry>) { vec![StaticType::Any, StaticType::Any], StaticType::Bool, docs, - move |args| { - arity(args, 2)?; - let ordering = args[0].partial_cmp(&args[1]).ok_or_else(|| { - VmError::native(format!( - "cannot compare {} and {}", - args[0].static_type(), - args[1].static_type() - )) - })?; - Ok(Value::Bool(predicate(ordering))) - }, + move |args| Ok(Value::Bool(predicate(compare_operands(args)?))), ); } @@ -410,15 +568,7 @@ fn register_comparisons(env: &mut FunctionRegistry>) { StaticType::Int, docs, move |args| { - arity(args, 2)?; - let ordering = args[0].partial_cmp(&args[1]).ok_or_else(|| { - VmError::native(format!( - "cannot compare {} and {}", - args[0].static_type(), - args[1].static_type() - )) - })?; - let result = match ordering { + let result = match compare_operands(args)? { Ordering::Less => -1, Ordering::Equal => 0, Ordering::Greater => 1, @@ -447,18 +597,12 @@ fn register_bitwise(env: &mut FunctionRegistry>) { "Computes bitwise XOR of two integers.", ), ] { - declare( + declare_typed!( env, name, - vec![StaticType::Int, StaticType::Int], - StaticType::Int, + (left: Int, right: Int) -> Int, docs, - move |args| { - let [Value::Int(left), Value::Int(right)] = args else { - return Err(VmError::native("expected two Int arguments".to_string())); - }; - Ok(Value::Int(operation(*left, *right))) - }, + operation(*left, *right) ); } @@ -479,73 +623,49 @@ fn register_bitwise(env: &mut FunctionRegistry>) { "Computes logical XOR of two booleans.", ), ] { - declare( + declare_typed!( env, name, - vec![StaticType::Bool, StaticType::Bool], - StaticType::Bool, + (left: Bool, right: Bool) -> Bool, docs, - move |args| { - let [Value::Bool(left), Value::Bool(right)] = args else { - return Err(VmError::native("expected two Bool arguments".to_string())); - }; - Ok(Value::Bool(operation(*left, *right))) - }, + operation(*left, *right) ); } - declare( + declare_typed!( env, "~", - vec![StaticType::Int], - StaticType::Int, + (value: Int) -> Int, "Computes bitwise NOT of an integer.", - |args| { - let [Value::Int(value)] = args else { - return Err(VmError::native("expected one Int argument".to_string())); - }; - Ok(Value::Int(value.not())) - }, + value.not() ); for name in ["!", "not"] { - declare( + declare_typed!( env, name, - vec![StaticType::Bool], - StaticType::Bool, + (value: Bool) -> Bool, "Computes logical negation.", - |args| { - let [Value::Bool(value)] = args else { - return Err(VmError::native("expected one Bool argument".to_string())); - }; - Ok(Value::Bool(!value)) - }, + !value ); } for (name, left_shift) in [("<<", true), (">>", false)] { - declare( + declare_typed!( env, name, - vec![StaticType::Int, StaticType::Int], - StaticType::Int, + (left: Int, right: Int) -> Result, "Shifts an integer by a checked non-negative amount.", - move |args| { - let [Value::Int(left), Value::Int(right)] = args else { - return Err(VmError::native("expected two Int arguments".to_string())); - }; - let right = u32::try_from(*right) + { + let amount = u32::try_from(*right) .map_err(|_error| VmError::native("invalid shift amount".to_string()))?; - let result = if left_shift { - left.checked_shl(right) + if left_shift { + left.checked_shl(amount) } else { - left.checked_shr(right) - }; - result - .map(Value::Int) - .ok_or_else(|| VmError::native("invalid shift amount".to_string())) - }, + left.checked_shr(amount) + } + .ok_or_else(|| VmError::native("invalid shift amount".to_string())) + } ); } } @@ -730,53 +850,39 @@ fn register_number_helpers(env: &mut FunctionRegistry>) { } for (name, imaginary) in [("real", false), ("imag", true)] { - declare( + declare_typed!( env, name, - vec![StaticType::Number], - StaticType::Number, + (value: Number) -> Number, "Returns a component of an advanced number.", - move |args| { - let [Value::Number(value)] = args else { - return Err(VmError::native("expected one Number argument".to_string())); - }; - let component = match (value.as_ref(), imaginary) { - (AdvancedNumber::Complex(value), false) => AdvancedNumber::Float(value.re), - (AdvancedNumber::Complex(value), true) => AdvancedNumber::Float(value.im), - (_, false) => value.as_ref().clone(), - (_, true) => AdvancedNumber::Int(BigInt::from(0)), - }; - Ok(Value::from_number(component)) - }, + match (value.as_ref(), imaginary) { + (AdvancedNumber::Complex(value), false) => AdvancedNumber::Float(value.re), + (AdvancedNumber::Complex(value), true) => AdvancedNumber::Float(value.im), + (_, false) => value.as_ref().clone(), + (_, true) => AdvancedNumber::Int(BigInt::from(0)), + } ); } for (name, numerator) in [("numerator", true), ("denominator", false)] { - declare( + declare_typed!( env, name, - vec![StaticType::Number], - StaticType::Number, + (value: Number) -> Number, "Returns a component of an exact Number fraction.", - move |args| { - let [Value::Number(value)] = args else { - return Err(VmError::native("expected one Number argument".to_string())); - }; - let value = match value.as_ref() { - AdvancedNumber::Int(value) if numerator => AdvancedNumber::Int(value.clone()), - AdvancedNumber::Int(_) => AdvancedNumber::Int(BigInt::from(1)), - AdvancedNumber::Rational(value) if numerator => { - AdvancedNumber::Int(value.numer().clone()) - } - AdvancedNumber::Rational(value) => AdvancedNumber::Int(value.denom().clone()), - _ => { - return Err(VmError::native( - "expected an exact integer or rational Number".to_string(), - )); - } - }; - Ok(Value::from_number(value)) - }, + match value.as_ref() { + AdvancedNumber::Int(value) if numerator => AdvancedNumber::Int(value.clone()), + AdvancedNumber::Int(_) => AdvancedNumber::Int(BigInt::from(1)), + AdvancedNumber::Rational(value) if numerator => { + AdvancedNumber::Int(value.numer().clone()) + } + AdvancedNumber::Rational(value) => AdvancedNumber::Int(value.denom().clone()), + _ => { + return Err(VmError::native( + "expected an exact integer or rational Number".to_string(), + )); + } + } ); } } @@ -827,30 +933,21 @@ fn unary_preserving( } fn register_integer_helpers(env: &mut FunctionRegistry>) { - declare( + declare_typed!( env, "factorial", - vec![StaticType::Int], - StaticType::Int, + (value: Int) -> Result, "Returns the checked factorial of a non-negative Int.", - |args| { - let [Value::Int(value)] = args else { - return Err(VmError::native("expected one Int argument".to_string())); - }; + { if *value < 0 { return Err(VmError::native( "cannot compute the factorial of a negative number".to_string(), )); } - let result = (1..=*value) - .try_fold(1i64, i64::checked_mul) - .ok_or_else(|| { - VmError::native( - "integer factorial overflowed; use a Number argument".to_string(), - ) - })?; - Ok(Value::Int(result)) - }, + (1..=*value).try_fold(1i64, i64::checked_mul).ok_or_else(|| { + VmError::native("integer factorial overflowed; use a Number argument".to_string()) + }) + } ); declare( env, @@ -874,21 +971,14 @@ fn register_integer_helpers(env: &mut FunctionRegistry>) { ), ("lcm", |left: &BigInt, right: &BigInt| left.lcm(right)), ] { - declare( + declare_typed!( env, name, - vec![StaticType::Int, StaticType::Int], - StaticType::Int, + (left: Int, right: Int) -> Result, "Computes an integer divisor operation with checked i64 output.", - move |args| { - let [Value::Int(left), Value::Int(right)] = args else { - return Err(VmError::native("expected two Int arguments".to_string())); - }; - operation(&BigInt::from(*left), &BigInt::from(*right)) - .to_i64() - .map(Value::Int) - .ok_or_else(|| VmError::native("integer result overflowed".to_string())) - }, + operation(&BigInt::from(*left), &BigInt::from(*right)) + .to_i64() + .ok_or_else(|| VmError::native("integer result overflowed".to_string())) ); declare( env, @@ -1196,60 +1286,39 @@ impl Transcendental { fn register_transcendentals(env: &mut FunctionRegistry>) { for function in Transcendental::ALL { - declare( + declare_typed!( env, function.name(), - vec![StaticType::Int], - StaticType::Float, + (value: Int) -> Float, function.documentation(NumericMode::Int), - move |args| { - let [Value::Int(value)] = args else { - return Err(VmError::native("expected one Int argument".to_string())); - }; - Ok(Value::Float(function.apply_float(*value as f64))) - }, + function.apply_float(*value as f64) ); - declare( + declare_typed!( env, function.name(), - vec![StaticType::Float], - StaticType::Float, + (value: Float) -> Float, function.documentation(NumericMode::Float), - move |args| { - let [Value::Float(value)] = args else { - return Err(VmError::native("expected one Float argument".to_string())); - }; - Ok(Value::Float(function.apply_float(*value))) - }, + function.apply_float(*value) ); - declare( + declare_typed!( env, function.name(), - vec![StaticType::Number], - StaticType::Number, + (value: Number) -> Number, function.documentation(NumericMode::Number), - move |args| { - let [Value::Number(value)] = args else { - return Err(VmError::native("expected one Number argument".to_string())); - }; - let result = match value.as_ref() { - AdvancedNumber::Complex(value) => { - AdvancedNumber::Complex(function.apply_complex(*value)) - } - value => { - let input = value.to_f64().expect("non-complex Number is real"); - let result = function.apply_float(input); - if result.is_nan() && !input.is_nan() { - AdvancedNumber::Complex( - function.apply_complex(Complex64::new(input, 0.0)), - ) - } else { - AdvancedNumber::Float(result) - } + match value.as_ref() { + AdvancedNumber::Complex(value) => { + AdvancedNumber::Complex(function.apply_complex(*value)) + } + value => { + let input = value.to_f64().expect("non-complex Number is real"); + let result = function.apply_float(input); + if result.is_nan() && !input.is_nan() { + AdvancedNumber::Complex(function.apply_complex(Complex64::new(input, 0.0))) + } else { + AdvancedNumber::Float(result) } - }; - Ok(Value::from_number(result)) - }, + } + } ); } } From 123232be873026d9f2e9b64ac21aebe5fda0d948 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 3 Sep 2026 14:14:17 +0200 Subject: [PATCH 18/31] chore: cleanup num code --- ndc_core/src/num.rs | 293 +++++++++--------------------------- ndc_core/src/static_type.rs | 34 ----- ndc_vm/src/value/mod.rs | 10 -- ndc_vm/src/value/numeric.rs | 32 ++-- 4 files changed, 79 insertions(+), 290 deletions(-) diff --git a/ndc_core/src/num.rs b/ndc_core/src/num.rs index c497a159..225ecbc4 100644 --- a/ndc_core/src/num.rs +++ b/ndc_core/src/num.rs @@ -1,10 +1,9 @@ use std::cmp::Ordering; use std::fmt; use std::hash::{Hash, Hasher}; -use std::ops::{Add, Div, Mul, Neg, Not, Rem, Sub}; +use std::ops::{Add, Div, Mul, Neg, Rem, Sub}; use crate::StaticType; -use num::bigint::TryFromBigIntError; use num::complex::{Complex64, ComplexFloat}; use num::{BigInt, BigRational, Complex, FromPrimitive, Signed, ToPrimitive, Zero}; @@ -154,17 +153,6 @@ impl Neg for AdvancedNumber { } } -impl Not for AdvancedNumber { - type Output = Self; - - fn not(self) -> Self::Output { - match self { - Self::Int(int) => int.not().into(), - _ => f64::NAN.into(), - } - } -} - trait Unbox { type Output; fn unbox(self) -> Self::Output; @@ -208,100 +196,57 @@ impl BinaryOperatorError { } macro_rules! impl_binary_operator { - ($self:ty, $other:ty, $trait:ident, $method:ident,$intmethod:expr,$floatmethod:expr,$rationalmethod:expr,$complexmethod:expr) => { + ($self:ty, $other:ty, $trait:ident, $method:ident) => { impl $trait<$other> for $self { type Output = Result; fn $method(self, other: $other) -> Self::Output { Ok(match (self, other) { // Integer (AdvancedNumber::Int(left), AdvancedNumber::Int(right)) => { - AdvancedNumber::Int($intmethod(left, right)) + AdvancedNumber::Int($trait::$method(left, right)) } // Complex (AdvancedNumber::Complex(left), right) => { - AdvancedNumber::Complex($complexmethod(left, right.to_complex())) + AdvancedNumber::Complex($trait::$method(left, right.to_complex())) } (left, AdvancedNumber::Complex(right)) => { - AdvancedNumber::Complex($complexmethod(left.to_complex(), right)) + AdvancedNumber::Complex($trait::$method(left.to_complex(), right)) } // Float // NOTE: these `expect` calls are safe because complex has already been handled - (AdvancedNumber::Float(left), right) => AdvancedNumber::Float($floatmethod( - left, - right.to_f64().expect("cannot convert complex to float"), - )), - (left, AdvancedNumber::Float(right)) => AdvancedNumber::Float($floatmethod( - left.to_f64().expect("cannot convert complex to float"), - right, - )), - // Rational - // NOTE: these `expect` calls are safe because complex and float are handled - (left, AdvancedNumber::Rational(right)) => { - AdvancedNumber::rational($rationalmethod( - left.to_rational().expect("cannot convert to rational"), - right.unbox(), - )) + (AdvancedNumber::Float(left), right) => { + AdvancedNumber::Float($trait::$method(left, right.expect_f64())) } - (AdvancedNumber::Rational(left), right) => { - AdvancedNumber::rational($rationalmethod( - left.unbox(), - right.to_rational().expect("cannot convert to rational"), - )) + (left, AdvancedNumber::Float(right)) => { + AdvancedNumber::Float($trait::$method(left.expect_f64(), right)) } + // Rational + // NOTE: these `expect` calls are safe because complex and float are handled + (left, AdvancedNumber::Rational(right)) => AdvancedNumber::rational( + $trait::$method(left.expect_rational(), right.unbox()), + ), + (AdvancedNumber::Rational(left), right) => AdvancedNumber::rational( + $trait::$method(left.unbox(), right.expect_rational()), + ), }) } } }; } +/// Implement `$trait` for every owned/borrowed combination of `AdvancedNumber`. macro_rules! impl_binary_operator_all { - ($implement:ident,$method:ident,$intmethod:expr,$floatmethod:expr,$rationalmethod:expr,$complexmethod:expr) => { - impl_binary_operator!( - AdvancedNumber, - AdvancedNumber, - $implement, - $method, - $intmethod, - $floatmethod, - $rationalmethod, - $complexmethod - ); - impl_binary_operator!( - AdvancedNumber, - &AdvancedNumber, - $implement, - $method, - $intmethod, - $floatmethod, - $rationalmethod, - $complexmethod - ); - impl_binary_operator!( - &AdvancedNumber, - AdvancedNumber, - $implement, - $method, - $intmethod, - $floatmethod, - $rationalmethod, - $complexmethod - ); - impl_binary_operator!( - &AdvancedNumber, - &AdvancedNumber, - $implement, - $method, - $intmethod, - $floatmethod, - $rationalmethod, - $complexmethod - ); + ($trait:ident, $method:ident) => { + impl_binary_operator!(AdvancedNumber, AdvancedNumber, $trait, $method); + impl_binary_operator!(AdvancedNumber, &AdvancedNumber, $trait, $method); + impl_binary_operator!(&AdvancedNumber, AdvancedNumber, $trait, $method); + impl_binary_operator!(&AdvancedNumber, &AdvancedNumber, $trait, $method); }; } -impl_binary_operator_all!(Add, add, Add::add, Add::add, Add::add, Add::add); -impl_binary_operator_all!(Sub, sub, Sub::sub, Sub::sub, Sub::sub, Sub::sub); -impl_binary_operator_all!(Mul, mul, Mul::mul, Mul::mul, Mul::mul, Mul::mul); +impl_binary_operator_all!(Add, add); +impl_binary_operator_all!(Sub, sub); +impl_binary_operator_all!(Mul, mul); /// Returns `true` for the number kinds that use exact (integer/rational) /// arithmetic. @@ -328,19 +273,11 @@ impl Rem for AdvancedNumber { (Self::Complex(left), right) => Self::Complex(left % right.to_complex()), (left, Self::Complex(right)) => Self::Complex(left.to_complex() % right), // Float - (Self::Float(left), right) => { - Self::Float(left % right.to_f64().expect("cannot convert complex to float")) - } - (left, Self::Float(right)) => { - Self::Float(left.to_f64().expect("cannot convert complex to float") % right) - } + (Self::Float(left), right) => Self::Float(left % right.expect_f64()), + (left, Self::Float(right)) => Self::Float(left.expect_f64() % right), // Rational - (left, Self::Rational(right)) => Self::rational( - left.to_rational().expect("cannot convert to rational") % right.unbox(), - ), - (Self::Rational(left), right) => Self::rational( - left.unbox() % right.to_rational().expect("cannot convert to rational"), - ), + (left, Self::Rational(right)) => Self::rational(left.expect_rational() % right.unbox()), + (Self::Rational(left), right) => Self::rational(left.unbox() % right.expect_rational()), }) } } @@ -450,11 +387,6 @@ impl AdvancedNumber { Self::Complex(Complex64 { re, im }) } - #[must_use] - pub fn float(f: f64) -> Self { - Self::Float(f) - } - #[must_use] pub fn rational(rat: BigRational) -> Self { Self::Rational(Box::new(rat)) @@ -546,117 +478,30 @@ impl AdvancedNumber { } Ok(match (self, rhs) { - // Int vs others - (Self::Int(p1), Self::Int(p2)) => return Self::int_pow(&p1, &p2), - (Self::Int(p1), Self::Float(p2)) => { - let p1 = bigint_to_float(&p1); - if p1 < 0.0 && p2.fract() != 0.0 { - Self::Complex(Complex64::from(p1).powf(p2)) - } else { - Self::Float(p1.powf(p2)) - } - } - (Self::Int(p1), Self::Complex(p2)) => { - Self::Complex(Complex::from(bigint_to_float(&p1)).powc(p2)) + // Exact results first: an integer exponent keeps an exact base exact. + (Self::Int(base), Self::Int(exponent)) => return Self::int_pow(&base, &exponent), + (Self::Int(base), Self::Rational(exponent)) if exponent.is_integer() => { + return Self::int_pow(&base, &exponent.to_integer()); } - (Self::Int(p1), Self::Rational(p2)) => { - if p2.is_integer() { - return Self::int_pow(&p1, &p2.to_integer()); - } - - let p1 = bigint_to_float(&p1); - let p2 = rational_to_float(&p2); - if p1 < 0.0 { - Self::Complex(Complex64::from(p1).powf(p2)) - } else { - Self::Float(p1.powf(p2)) - } + (Self::Rational(base), Self::Int(exponent)) => { + Self::Rational(Box::new(num::pow::Pow::pow(&*base, exponent))) } - - // Rational vs Others - (Self::Rational(p1), Self::Int(p2)) => { - Self::Rational(Box::new(num::pow::Pow::pow(&*p1, p2))) + (Self::Rational(base), Self::Rational(exponent)) + if exponent.is_integer() && exponent.to_i32().is_some() => + { + let exponent = exponent.to_i32().expect("checked by the match guard"); + Self::Rational(Box::new(base.pow(exponent))) } - (Self::Rational(p1), Self::Rational(p2)) => { - if p2.is_integer() - && let Some(p2) = p2.to_i32() - { - return Ok(Self::Rational(Box::new(p1.pow(p2)))); - } - let p1 = rational_to_float(&p1); - let p2 = rational_to_float(&p2); - if p1 < 0.0 { - Self::Complex(Complex64::from(p1).powf(p2)) - } else { - Self::Float(p1.powf(p2)) - } - } - (Self::Rational(p1), Self::Float(p2)) => { - let p1 = rational_to_float(&p1); - if p1 < 0.0 && p2.fract() != 0.0 { - Self::Complex(Complex64::from(p1).powf(p2)) - } else { - Self::Float(p1.powf(p2)) - } - } - (Self::Rational(p1), Self::Complex(p2)) => { - Self::Complex(rational_to_complex(&p1).powc(p2)) - } + // A complex operand on either side keeps the result complex. + (Self::Complex(base), exponent) => Self::Complex(base.powc(exponent.to_complex())), + (base, Self::Complex(exponent)) => Self::Complex(base.to_complex().powc(exponent)), - // Float vs others - (Self::Float(p1), Self::Float(p2)) => { - if p1 < 0.0 && p2.fract() != 0.0 { - Self::Complex(Complex64::from(p1).powf(p2)) - } else { - Self::Float(p1.powf(p2)) - } - } - (Self::Float(p1), Self::Complex(p2)) => Self::Complex(Complex::from(p1).powc(p2)), - (Self::Float(p1), Self::Int(p2)) => Self::Float(p1.powf(bigint_to_float(&p2))), - (Self::Float(p1), Self::Rational(p2)) => { - let p2 = rational_to_float(&p2); - if p1 < 0.0 && p2.fract() != 0.0 { - Self::Complex(Complex64::from(p1).powf(p2)) - } else { - Self::Float(p1.powf(p2)) - } - } - - // Complex vs others - (Self::Complex(p1), Self::Complex(p2)) => Self::Complex(p1.powc(p2)), - (Self::Complex(p1), Self::Float(p2)) => Self::Complex(p1.powc(Complex::from(p2))), - (Self::Complex(p1), Self::Int(p2)) => { - Self::Complex(p1.powc(Complex::from(bigint_to_float(&p2)))) - } - (Self::Complex(p1), Self::Rational(p2)) => { - Self::Complex(p1.powc(rational_to_complex(&p2))) - } + // Everything else is real, so evaluate in floating point. + (base, exponent) => float_pow(base.expect_f64(), exponent.expect_f64()), }) } - /// # Errors - /// Returns a `NumberConversionError` if you try to convert Inf, NaN, or Complex to an int - pub fn to_int_lossy(&self) -> Result { - let n = match self { - Self::Int(i) => Self::Int(i.clone()), - Self::Float(f) => { - if let Some(bi) = BigInt::from_f64(*f) { - Self::Int(bi) - } else { - return Err(NumberConversionError(format!("cannot convert {f} to int"))); - } - } - Self::Rational(r) => Self::Int(r.to_integer()), - Self::Complex(c) => { - return Err(NumberConversionError(format!( - "cannot convert complex number {c} to int" - ))); - } - }; - Ok(n) - } - #[must_use] pub fn to_complex(&self) -> Complex64 { match self { @@ -677,6 +522,18 @@ impl AdvancedNumber { } } + /// The `f64` value of a number that a caller has already established is not + /// complex. Panics otherwise. + fn expect_f64(&self) -> f64 { + self.to_f64().expect("cannot convert complex to float") + } + + /// The exact rational value of a number that a caller has already + /// established is an integer or a rational. Panics otherwise. + fn expect_rational(&self) -> BigRational { + self.to_rational().expect("cannot convert to rational") + } + #[must_use] pub fn to_rational(&self) -> Option { match self { @@ -743,25 +600,6 @@ implement_rounding!(ceil); implement_rounding!(floor); implement_rounding!(round); -#[derive(thiserror::Error, Debug)] -pub enum NumberToUsizeError { - #[error("expected a non-negative integer, got {0}")] - UnsupportedVariant(StaticType), - #[error("this integer is out of range (must be non-negative and small enough to be an index)")] - FromBigIntError(#[from] TryFromBigIntError), -} - -impl TryFrom for usize { - type Error = NumberToUsizeError; - - fn try_from(value: AdvancedNumber) -> Result { - match value { - AdvancedNumber::Int(value) => Ok(Self::try_from(value)?), - n => Err(NumberToUsizeError::UnsupportedVariant(n.static_type())), - } - } -} - #[derive(thiserror::Error, Debug)] pub enum NumberToFloatError { #[error("cannot convert {0} to float")] @@ -821,6 +659,17 @@ impl fmt::Display for AdvancedNumber { } } +/// `base ^ exponent` in floating point, escaping to the complex plane when a +/// negative base is raised to a fractional power: `(-8.0) ^ 2.0` is `64.0`, +/// but `(-8.0) ^ 0.5` has no real value. +fn float_pow(base: f64, exponent: f64) -> AdvancedNumber { + if base < 0.0 && exponent.fract() != 0.0 { + AdvancedNumber::Complex(Complex64::from(base).powf(exponent)) + } else { + AdvancedNumber::Float(base.powf(exponent)) + } +} + fn rational_to_float(r: &BigRational) -> f64 { r.to_f64().unwrap_or(f64::NAN) } @@ -839,10 +688,6 @@ fn rational_to_complex(r: &BigRational) -> Complex { Complex::from(r.to_f64().unwrap_or(f64::NAN)) } -#[derive(thiserror::Error, Debug)] -#[error("{0}")] -pub struct NumberConversionError(String); - #[cfg(test)] mod tests { use super::*; diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 994da2d9..4ebd7d46 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -15,40 +15,6 @@ impl Default for TypeSignature { } impl TypeSignature { - /// Matches argument types to a signature. Returns `None` for a mismatch, - /// or a score where exact matches cost zero and subtype matches cost one. - pub fn calc_type_score(&self, types: &[StaticType]) -> Option { - match self { - Self::Variadic => Some(0), - Self::Exact(signature) => { - if types.len() == signature.len() { - let mut acc = 0; - for (a, b) in types.iter().zip(signature.iter()) { - let dist = if a == &b.type_name { - 0 - } else if a.is_subtype(&b.type_name) { - 1 - } else { - return None; - }; - acc += dist; - } - - return Some(acc); - } - - None - } - } - } - - pub fn arity(&self) -> Option { - match self { - Self::Variadic => None, - Self::Exact(args) => Some(args.len()), - } - } - pub fn from_annotated_bindings(bindings: Vec<(String, Option)>) -> Self { Self::Exact( bindings diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 6fff53a3..497eb4f9 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -573,20 +573,10 @@ impl Value { } } - pub fn to_advanced_number(&self) -> Option { - self.numeric_ref().map(NumericRef::to_advanced_number) - } - /// Wrap an advanced numeric payload as a Number value. pub fn from_number(n: AdvancedNumber) -> Self { Self::number(n) } - - /// Convert a numeric VM value to `f64`, coercing integers and rationals. - /// Returns `None` for non-numeric values (Bool, None, String, …). - pub fn to_f64(&self) -> Option { - self.numeric_ref().and_then(NumericRef::to_f64) - } } impl Object { diff --git a/ndc_vm/src/value/numeric.rs b/ndc_vm/src/value/numeric.rs index 8d9d5725..5c21833a 100644 --- a/ndc_vm/src/value/numeric.rs +++ b/ndc_vm/src/value/numeric.rs @@ -55,13 +55,6 @@ impl<'a> NumericRef<'a> { } } - pub fn as_int(self) -> Option { - match self { - Self::Int(value) => Some(value), - Self::Float(_) | Self::Number(_) => None, - } - } - pub fn as_number(self) -> Option<&'a AdvancedNumber> { match self { Self::Number(value) => Some(value), @@ -118,21 +111,11 @@ impl<'a> NumericRef<'a> { (Self::Float(left), Self::Float(right)) => compare_floats(left, right), (Self::Int(left), Self::Float(right)) => compare_int_float(left, right), (Self::Float(left), Self::Int(right)) => compare_int_float(right, left).reverse(), - (Self::Number(left), Self::Number(right)) => left - .partial_cmp(right) - .expect("AdvancedNumber values are totally ordered"), - (Self::Int(left), Self::Number(right)) => AdvancedNumber::Int(left.into()) - .partial_cmp(right) - .expect("AdvancedNumber values are totally ordered"), - (Self::Float(left), Self::Number(right)) => AdvancedNumber::Float(left) - .partial_cmp(right) - .expect("AdvancedNumber values are totally ordered"), - (Self::Number(left), Self::Int(right)) => left - .partial_cmp(&AdvancedNumber::Int(right.into())) - .expect("AdvancedNumber values are totally ordered"), - (Self::Number(left), Self::Float(right)) => left - .partial_cmp(&AdvancedNumber::Float(right)) - .expect("AdvancedNumber values are totally ordered"), + // A `Number` on either side falls back to its exact ordering. The + // primitive side is promoted, but a `Number` is never cloned. + (Self::Number(left), Self::Number(right)) => exact_cmp(left, right), + (Self::Number(left), right) => exact_cmp(left, &right.to_advanced_number()), + (left, Self::Number(right)) => exact_cmp(&left.to_advanced_number(), right), } } } @@ -170,6 +153,11 @@ impl Hash for NumericRef<'_> { } } +fn exact_cmp(left: &AdvancedNumber, right: &AdvancedNumber) -> Ordering { + left.partial_cmp(right) + .expect("AdvancedNumber values are totally ordered") +} + fn float_to_i64(value: f64) -> Option { if value.is_finite() { num::BigInt::from_f64(value.trunc())?.to_i64() From c4fc39344c5c9efd0fe10b6cdd5ce31d91a31d0c Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 3 Sep 2026 14:19:04 +0200 Subject: [PATCH 19/31] chore: move number from core to vm --- Cargo.lock | 4 +--- ndc_core/Cargo.toml | 3 --- ndc_core/src/lib.rs | 1 - ndc_macros/src/vm_convert.rs | 8 ++++---- ndc_stdlib/src/math.rs | 2 +- ndc_stdlib/src/rand.rs | 2 +- ndc_stdlib/src/serde.rs | 2 +- ndc_vm/Cargo.toml | 1 + ndc_vm/src/compiler.rs | 4 ++-- ndc_vm/src/value/mod.rs | 3 ++- ndc_core/src/num.rs => ndc_vm/src/value/number.rs | 2 +- ndc_vm/src/value/numeric.rs | 3 ++- 12 files changed, 16 insertions(+), 19 deletions(-) rename ndc_core/src/num.rs => ndc_vm/src/value/number.rs (99%) diff --git a/Cargo.lock b/Cargo.lock index b3c5b5da..55f81929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,9 +1172,6 @@ version = "0.3.0" dependencies = [ "ahash", "itertools 0.15.0", - "num", - "ryu", - "thiserror", ] [[package]] @@ -1261,6 +1258,7 @@ dependencies = [ "ndc_lexer", "ndc_parser", "num", + "ryu", "thiserror", ] diff --git a/ndc_core/Cargo.toml b/ndc_core/Cargo.toml index bf1606ae..66a05052 100644 --- a/ndc_core/Cargo.toml +++ b/ndc_core/Cargo.toml @@ -6,6 +6,3 @@ version.workspace = true [dependencies] ahash.workspace = true itertools.workspace = true -num.workspace = true -ryu.workspace = true -thiserror.workspace = true diff --git a/ndc_core/src/lib.rs b/ndc_core/src/lib.rs index 42ed6460..eda30d70 100644 --- a/ndc_core/src/lib.rs +++ b/ndc_core/src/lib.rs @@ -1,7 +1,6 @@ pub mod compare; pub mod duration; pub mod hash_map; -pub mod num; pub mod static_type; pub mod r#struct; diff --git a/ndc_macros/src/vm_convert.rs b/ndc_macros/src/vm_convert.rs index c83eb087..49fa3361 100644 --- a/ndc_macros/src/vm_convert.rs +++ b/ndc_macros/src/vm_convert.rs @@ -205,7 +205,7 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { let #temp = { let num = #raw.as_number().ok_or_else(|| #err)?; match num { - ndc_core::num::AdvancedNumber::Rational(r) => r.as_ref(), + ndc_vm::value::AdvancedNumber::Rational(r) => r.as_ref(), _ => return Err(#err), } }; @@ -222,7 +222,7 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { let #temp = { let num = #raw.as_number().ok_or_else(|| #err)?; match num { - ndc_core::num::AdvancedNumber::Complex(c) => *c, + ndc_vm::value::AdvancedNumber::Complex(c) => *c, _ => return Err(#err), } }; @@ -529,7 +529,7 @@ fn vm_return_for_classified(ty: &syn::Type) -> Option<(TokenStream, TokenStream) NdcType::BigInt => Some(( quote! { Ok(ndc_vm::value::Value::from_number( - ndc_core::num::AdvancedNumber::Int(result) + ndc_vm::value::AdvancedNumber::Int(result) )) }, quote! { ndc_core::StaticType::Number }, @@ -537,7 +537,7 @@ fn vm_return_for_classified(ty: &syn::Type) -> Option<(TokenStream, TokenStream) NdcType::BigRational => Some(( quote! { Ok(ndc_vm::value::Value::from_number( - ndc_core::num::AdvancedNumber::Rational(Box::new(result)) + ndc_vm::value::AdvancedNumber::Rational(Box::new(result)) )) }, quote! { ndc_core::StaticType::Number }, diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 97512cae..705189fa 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -1,7 +1,7 @@ use factorial::Factorial; -use ndc_core::num::{AdvancedNumber, BinaryOperatorError}; use ndc_core::{FunctionRegistry, StaticType}; use ndc_vm::error::VmError; +use ndc_vm::value::{AdvancedNumber, BinaryOperatorError}; use ndc_vm::value::{NativeFunc, NativeFunction, NumericMode, NumericRef, Object, Value}; use num::complex::Complex64; use num::{BigInt, BigUint, Integer, ToPrimitive}; diff --git a/ndc_stdlib/src/rand.rs b/ndc_stdlib/src/rand.rs index 6d24324d..c517d119 100644 --- a/ndc_stdlib/src/rand.rs +++ b/ndc_stdlib/src/rand.rs @@ -22,7 +22,7 @@ pub fn random_n( #[export_module] mod inner { use itertools::Itertools; - use ndc_core::num::AdvancedNumber; + use ndc_vm::value::AdvancedNumber; /// Randomly shuffles the elements of the list in place. pub fn shuffle(list: &mut [Value]) { diff --git a/ndc_stdlib/src/serde.rs b/ndc_stdlib/src/serde.rs index 0c896366..787042d5 100644 --- a/ndc_stdlib/src/serde.rs +++ b/ndc_stdlib/src/serde.rs @@ -1,7 +1,7 @@ use anyhow::{Context, bail}; use ndc_core::hash_map::HashMap; -use ndc_core::num::AdvancedNumber; use ndc_macros::export_module; +use ndc_vm::value::AdvancedNumber; use ndc_vm::value::{Object, Value}; use num::ToPrimitive; use serde_json::{Map, Number, Value as JsonValue, json}; diff --git a/ndc_vm/Cargo.toml b/ndc_vm/Cargo.toml index a3d1a268..aac4e83b 100644 --- a/ndc_vm/Cargo.toml +++ b/ndc_vm/Cargo.toml @@ -12,4 +12,5 @@ ndc_core.workspace = true ndc_lexer.workspace = true ndc_parser.workspace = true num.workspace = true +ryu.workspace = true thiserror.workspace = true diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index d58050b2..b31d7a1f 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -217,10 +217,10 @@ impl Compiler { NumericLiteral::Int64(i) => Value::int(i), NumericLiteral::Float64(f) => Value::float(f), NumericLiteral::NumberInt(i) => { - Value::number(ndc_core::num::AdvancedNumber::Int(i)) + Value::number(crate::value::AdvancedNumber::Int(i)) } NumericLiteral::NumberFloat(f) => { - Value::number(ndc_core::num::AdvancedNumber::Float(f)) + Value::number(crate::value::AdvancedNumber::Float(f)) } NumericLiteral::Complex(c) => Value::complex(c), }; diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 497eb4f9..ed33f6bc 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -1,14 +1,15 @@ mod function; +mod number; mod numeric; pub use function::*; +pub use number::{AdvancedNumber, BinaryOperatorError, NumberToFloatError, NumberToIntError}; pub use numeric::{NumericMode, NumericRef}; use crate::iterator::SharedIterator; use ndc_core::StaticType; use ndc_core::compare::FallibleOrd; use ndc_core::hash_map::{DefaultHasher, HashMap}; -use ndc_core::num::AdvancedNumber; use ndc_core::r#struct::StructInfo; use ndc_parser::ResolvedVar; use std::cell::RefCell; diff --git a/ndc_core/src/num.rs b/ndc_vm/src/value/number.rs similarity index 99% rename from ndc_core/src/num.rs rename to ndc_vm/src/value/number.rs index 225ecbc4..7241d3e8 100644 --- a/ndc_core/src/num.rs +++ b/ndc_vm/src/value/number.rs @@ -3,7 +3,7 @@ use std::fmt; use std::hash::{Hash, Hasher}; use std::ops::{Add, Div, Mul, Neg, Rem, Sub}; -use crate::StaticType; +use ndc_core::StaticType; use num::complex::{Complex64, ComplexFloat}; use num::{BigInt, BigRational, Complex, FromPrimitive, Signed, ToPrimitive, Zero}; diff --git a/ndc_vm/src/value/numeric.rs b/ndc_vm/src/value/numeric.rs index 5c21833a..a7c05f20 100644 --- a/ndc_vm/src/value/numeric.rs +++ b/ndc_vm/src/value/numeric.rs @@ -1,9 +1,10 @@ use ndc_core::StaticType; -use ndc_core::num::{AdvancedNumber, hash_exact_i64}; use num::{FromPrimitive, ToPrimitive}; use std::cmp::Ordering; use std::hash::{Hash, Hasher}; +use super::number::{AdvancedNumber, hash_exact_i64}; + /// The runtime representation mode of a numeric [`super::Value`]. /// /// This is an operational distinction used for overload selection and result From 888b9a0cb19eed5bf1c19f57fc4140e7483c56cc Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 3 Sep 2026 14:58:05 +0200 Subject: [PATCH 20/31] chore: improve documentation --- manual/src/reference/types/number.md | 2 +- manual/src/reference/variables-and-scopes.md | 17 +++++++++--- ndc_analyser/src/analyser.rs | 27 ++++++++++++++++++++ ndc_vm/src/value/mod.rs | 19 +++++++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/manual/src/reference/types/number.md b/manual/src/reference/types/number.md index b257a50a..aacaf19f 100644 --- a/manual/src/reference/types/number.md +++ b/manual/src/reference/types/number.md @@ -29,7 +29,7 @@ let octal = 0o52n; let hexadecimal = 0x2an; ``` -An integer literal without `n` must fit in `i64`. The analyser reports an error and suggests the suffixed form when it does not fit. +An integer literal without `n` must fit in `i64`. The lexer reports an error and suggests the suffixed form when it does not fit. Arbitrary-radix literals such as `16r2a` remain `Int` literals and do not accept `n`. The `i` and `j` suffixes create complex `Number` values: diff --git a/manual/src/reference/variables-and-scopes.md b/manual/src/reference/variables-and-scopes.md index 069c0e36..933a0124 100644 --- a/manual/src/reference/variables-and-scopes.md +++ b/manual/src/reference/variables-and-scopes.md @@ -45,9 +45,18 @@ print(x); // 3 The `=` operator can be used to reassign a value to an existing variable. When you reassign a variable to a value of a different type, the variable's type is widened to the least upper bound (LUB) of the old and new types. ```ndc -let x = 1; // type is Int -x = 2; // type is still Int -x = 3.14; // type widens to Any (Int and Float are siblings) +let x = 1; // the initializer and declaration have type Int +x = 2; // subsequent reads still have type Int +x = 3.14; // subsequent reads have type Any (Int and Float are siblings) +x; // type is Any +``` + +Augmented assignment widens an inferred binding from the operation's result in the same way: + +```ndc +let total = 3; +total += 0.5; // Int + Float returns Float, so subsequent reads have type Any +total; // type is Any ``` ```ndc @@ -95,7 +104,7 @@ Once a binding has an annotation, it stays locked to that type. Reassignment and ```ndc let x: Int = 5; x = "test"; // ERROR: mismatched types -x += 0.5; // ERROR: Float doesn't fit in Int +x += 0.5; // ERROR: the Float result doesn't fit in Int ``` If you want a binding that widens freely, just leave the annotation off. Annotations are opt-in. diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index 043fb1a6..37f62942 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -1529,6 +1529,33 @@ mod tests { ); } + #[test] + fn inferred_identifier_assignments_widen_subsequent_reads() { + assert_eq!(analyse_last_type("let x = 3; x = 0.5; x"), StaticType::Any,); + + let add = StaticType::Function { + parameters: Some(vec![StaticType::Int, StaticType::Float]), + return_type: Box::new(StaticType::Float), + }; + assert_eq!( + analyse_last_type_with_globals("let x = 3; x += 0.5; x", vec![("+".to_string(), add)],), + StaticType::Any, + ); + } + + #[test] + fn annotated_identifier_augmented_assignment_rejects_widening() { + let add = StaticType::Function { + parameters: Some(vec![StaticType::Int, StaticType::Float]), + return_type: Box::new(StaticType::Float), + }; + assert_analysis_error( + "let x: Int = 3; x += 0.5;", + vec![("+".to_string(), add)], + "mismatched types: found Float but expected Int", + ); + } + #[test] fn compatible_specialized_assignment_preserves_left_type() { let list_any = StaticType::List(Box::new(StaticType::Any)); diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index ed33f6bc..1438ca39 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -307,7 +307,8 @@ impl Value { /// /// When `param` is a container type whose inner types are all `Any` (e.g. /// `Sequence(Any)`, `Map { Any, Any }`, `List(Any)`), only the outer kind is - /// checked — no element iteration occurs. All other cases fall back to + /// checked — no element iteration occurs. Typed container parameters are + /// rejected rather than scanned; the remaining cases fall back to /// `self.static_type().is_subtype(param)`. pub fn matches_param(&self, param: &StaticType) -> bool { match param { @@ -1227,6 +1228,22 @@ mod tests { elements.borrow_mut().clear(); } + #[test] + fn runtime_parameter_matching_does_not_scan_lists() { + let list = Rc::new(Object::List(RefCell::new(vec![Value::Int(1)]))); + let value = Value::Object(Rc::clone(&list)); + let Object::List(elements) = list.as_ref() else { + unreachable!(); + }; + + // Holding a mutable borrow makes any attempted element scan panic. + // Dynamic dispatch must only inspect the outer container kind. + let _borrow = elements.borrow_mut(); + assert!(value.matches_param(&StaticType::List(Box::new(StaticType::Any)))); + assert!(value.matches_param(&StaticType::Sequence(Box::new(StaticType::Any,)))); + assert!(!value.matches_param(&StaticType::List(Box::new(StaticType::Int,)))); + } + #[test] fn same_representation_numbers_compare_without_changing_semantics() { assert!(Value::int(-1) < Value::int(1)); From 16ad243f6987a9fb8ca584a05883636d29ed892b Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Sat, 5 Sep 2026 13:01:01 +0200 Subject: [PATCH 21/31] =?UTF-8?q?fix(stdlib):=20keep=20i64::MIN=20remainde?= =?UTF-8?q?rs=20in=20range=20=F0=9F=94=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checked_rem` and `checked_rem_euclid` report the overflow of the quotient they imply, so `i64::MIN % -1` failed even though the remainder is 0. The divisor is already screened for zero, which leaves no way for a remainder to overflow, so wrap instead. Division keeps reporting the real overflow. Co-Authored-By: Claude Opus 5 (1M context) --- manual/src/reference/types/number.md | 4 +++- ndc_stdlib/src/math.rs | 7 +++++-- .../programs/001_math/042_int_min_remainder.ndc | 12 ++++++++++++ .../programs/001_math/043_int_min_division_error.ndc | 5 +++++ 4 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 tests/functional/programs/001_math/042_int_min_remainder.ndc create mode 100644 tests/functional/programs/001_math/043_int_min_division_error.ndc diff --git a/manual/src/reference/types/number.md b/manual/src/reference/types/number.md index aacaf19f..b9634203 100644 --- a/manual/src/reference/types/number.md +++ b/manual/src/reference/types/number.md @@ -2,7 +2,9 @@ Andy C++ exposes three sibling numeric types: -* `Int` stores a signed 64-bit integer. Checked arithmetic reports overflow. +* `Int` stores a signed 64-bit integer. Checked arithmetic reports overflow. The remainder + operators `%` and `%%` are the exception: once the divisor is non-zero the result always + fits, even where the quotient it implies would not. * `Float` stores an IEEE 754 `f64`. * `Number` supports arbitrary-size integers, exact rational values, floats, and complex values. diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 705189fa..5b1e060f 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -378,13 +378,16 @@ fn eval_int_binary(operation: BinaryOperation, left: i64, right: i64) -> Result< nonzero_divisor(right)?; checked_floor_div(left, right).ok_or_else(failed) } + // Once the divisor is nonzero a remainder always fits: `i64::MIN % -1` + // is 0, even though the quotient it implies overflows. The checked + // variants report that quotient overflow, so wrap instead. BinaryOperation::Rem => { nonzero_divisor(right)?; - left.checked_rem(right).ok_or_else(failed) + Ok(left.wrapping_rem(right)) } BinaryOperation::RemEuclid => { nonzero_divisor(right)?; - left.checked_rem_euclid(right).ok_or_else(failed) + Ok(left.wrapping_rem_euclid(right)) } BinaryOperation::Pow => { if right < 0 { diff --git a/tests/functional/programs/001_math/042_int_min_remainder.ndc b/tests/functional/programs/001_math/042_int_min_remainder.ndc new file mode 100644 index 00000000..191e8629 --- /dev/null +++ b/tests/functional/programs/001_math/042_int_min_remainder.ndc @@ -0,0 +1,12 @@ +// A remainder stays representable even when the quotient it implies does not: +// `i64::MIN % -1` is 0 while `i64::MIN / -1` overflows. The checked remainder +// reports that quotient overflow, so `%` and `%%` wrap instead of failing. +let min = -9223372036854775807 - 1; +assert_eq(min % -1, 0); +assert_eq(min %% -1, 0); + +// ordinary remainder semantics are unchanged +assert_eq(-7 % 3, -1); +assert_eq(-7 %% 3, 2); +assert_eq(7 % -3, 1); +assert_eq(7 %% -3, 1); diff --git a/tests/functional/programs/001_math/043_int_min_division_error.ndc b/tests/functional/programs/001_math/043_int_min_division_error.ndc new file mode 100644 index 00000000..42403b46 --- /dev/null +++ b/tests/functional/programs/001_math/043_int_min_division_error.ndc @@ -0,0 +1,5 @@ +// Unlike the remainder, the quotient `i64::MIN / -1` is `2^63` and does not +// fit in an Int, so division still reports the overflow. +// expect-error: integer operation overflowed +let min = -9223372036854775807 - 1; +print(min / -1); From e1486ceac36bd0bb4574d7366c0d3b52121bbb56 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Sat, 5 Sep 2026 13:01:07 +0200 Subject: [PATCH 22/31] =?UTF-8?q?fix(vm):=20accept=20Number=20floats=20fro?= =?UTF-8?q?m=20comparators=20=E2=9A=96=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cmp_to_zero` enumerated the exact Number variants and let the rest fall through to a rejection, so a comparator returning a float-backed Number failed with "must return a number, got Number". Handle the float with the same NaN check the primitive branch uses. The match is now exhaustive, so a new variant is a compile error rather than a silent rejection, and the complex arm says which half of "number" is missing. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_vm/src/value/mod.rs | 12 +++++++----- .../013_comparator_number_results.ndc | 17 +++++++++++++++++ .../014_comparator_complex_error.ndc | 4 ++++ 3 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 tests/functional/programs/603_stdlib_seq/013_comparator_number_results.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/014_comparator_complex_error.ndc diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 1438ca39..0d3df772 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -3,7 +3,7 @@ mod number; mod numeric; pub use function::*; -pub use number::{AdvancedNumber, BinaryOperatorError, NumberToFloatError, NumberToIntError}; +pub use number::{AdvancedNumber, BinaryOperatorError, NumberToFloatError}; pub use numeric::{NumericMode, NumericRef}; use crate::iterator::SharedIterator; @@ -939,13 +939,15 @@ impl Value { .ok_or_else(|| "NaN in comparator result".to_string()), Self::Number(number) => match number.as_ref() { AdvancedNumber::Int(i) => Ok(i.cmp(&num::BigInt::from(0))), + AdvancedNumber::Float(f) => f + .partial_cmp(&0.0) + .ok_or_else(|| "NaN in comparator result".to_string()), AdvancedNumber::Rational(r) => Ok(r .as_ref() .cmp(&num::BigRational::from(num::BigInt::from(0)))), - _ => Err(format!( - "comparator must return a number, got {}", - self.static_type() - )), + AdvancedNumber::Complex(_) => { + Err("comparator must return a real number, got a complex number".to_string()) + } }, _ => Err(format!( "comparator must return a number, got {}", diff --git a/tests/functional/programs/603_stdlib_seq/013_comparator_number_results.ndc b/tests/functional/programs/603_stdlib_seq/013_comparator_number_results.ndc new file mode 100644 index 00000000..4da43368 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/013_comparator_number_results.ndc @@ -0,0 +1,17 @@ +// Comparators may return any real Number. A Number backed by a float used to +// fall through to the "not a number" arm, so sorting Numbers by subtraction +// failed even though the same comparator worked on plain Floats. +let xs = [1.5n, 0.5n, 2.5n]; +xs.sort_by(fn(a, b) => a - b); +assert_eq(xs, [0.5n, 1.5n, 2.5n]); + +assert_eq([1.5n, 0.5n].min_by(fn(a, b) => a - b), 0.5n); +assert_eq([1.5n, 0.5n].max_by(fn(a, b) => a - b), 1.5n); + +// exact integer and rational results keep working +assert_eq([3n, 1n, 2n].min_by(fn(a, b) => a - b), 1n); +assert_eq([3n, 1n, 2n].min_by(fn(a, b) => (a - b) / 2n), 1n); + +// plain Int and Float comparators are unaffected +assert_eq([3, 1, 2].min_by(fn(a, b) => a - b), 1); +assert_eq([3.5, 1.5].min_by(fn(a, b) => a - b), 1.5); diff --git a/tests/functional/programs/603_stdlib_seq/014_comparator_complex_error.ndc b/tests/functional/programs/603_stdlib_seq/014_comparator_complex_error.ndc new file mode 100644 index 00000000..30052234 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/014_comparator_complex_error.ndc @@ -0,0 +1,4 @@ +// A complex result has no ordering, so it is still rejected — but the message +// now says which part of "number" the comparator failed. +// expect-error: comparator must return a real number +[3, 1].min_by(fn(a, b) => (a - b) * 1i); From b563cf4461efb82ca25c2c8096943e46fd767602 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Sat, 5 Sep 2026 13:01:11 +0200 Subject: [PATCH 23/31] =?UTF-8?q?fix(macros):=20match=20the=20Number=20the?= =?UTF-8?q?se=20params=20declare=20=F0=9F=8E=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening the declared type of `&BigRational` and `Complex64` parameters to `StaticType::Number` left their runtime extraction matching one variant, so such a native advertised every Number and then rejected most of them. Route both through the existing conversions instead. Nothing declares these parameters today, so this is a trap disarmed rather than a bug fixed. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_macros/src/vm_convert.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/ndc_macros/src/vm_convert.rs b/ndc_macros/src/vm_convert.rs index 49fa3361..8a7d8915 100644 --- a/ndc_macros/src/vm_convert.rs +++ b/ndc_macros/src/vm_convert.rs @@ -199,32 +199,26 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { } NdcType::BigRationalRef => { - let err = arg_error(position, "rational"); + let err = arg_error(position, "an exact integer or rational Number"); VmInputArg { extract: quote! { let #temp = { let num = #raw.as_number().ok_or_else(|| #err)?; - match num { - ndc_vm::value::AdvancedNumber::Rational(r) => r.as_ref(), - _ => return Err(#err), - } + num.to_rational().ok_or_else(|| #err)? }; }, - pass: quote! { #temp }, + pass: quote! { &#temp }, static_type: quote! { ndc_core::StaticType::Number }, } } NdcType::Complex64 => { - let err = arg_error(position, "complex"); + let err = arg_error(position, "a Number"); VmInputArg { extract: quote! { let #temp = { let num = #raw.as_number().ok_or_else(|| #err)?; - match num { - ndc_vm::value::AdvancedNumber::Complex(c) => *c, - _ => return Err(#err), - } + num.to_complex() }; }, pass: quote! { #temp }, From 1d29e7f665e0de8e1d421747de41cea1cbb7f70b Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Sat, 5 Sep 2026 13:01:16 +0200 Subject: [PATCH 24/31] =?UTF-8?q?refactor(stdlib):=20give=20randi=20Int=20?= =?UTF-8?q?bounds=20only=20=F0=9F=8E=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Number overloads converted through an i64 conversion that accepted only exact BigInt values, so `randi(0, 10.0n)` and `randi(0, 20n/2n)` failed at runtime with "cannot convert Number to int". Dropping them moves the rejection to resolve time and matches randi already having no Float overloads. That conversion now has no callers, so it goes too. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_stdlib/src/rand.rs | 27 ------------------- ndc_vm/src/value/number.rs | 21 --------------- .../006_randi_number_bounds.ndc | 13 --------- .../006_randi_number_bounds_error.ndc | 4 +++ 4 files changed, 4 insertions(+), 61 deletions(-) delete mode 100644 tests/functional/programs/606_stdlib_rand/006_randi_number_bounds.ndc create mode 100644 tests/functional/programs/606_stdlib_rand/006_randi_number_bounds_error.ndc diff --git a/ndc_stdlib/src/rand.rs b/ndc_stdlib/src/rand.rs index c517d119..f6eea107 100644 --- a/ndc_stdlib/src/rand.rs +++ b/ndc_stdlib/src/rand.rs @@ -135,36 +135,9 @@ mod inner { random_n(0, upper) } - #[function(name = "randi")] - /// Generate a random Int between 0 (inclusive) and `upper` (exclusive) - pub fn randi_upper_number(upper: &AdvancedNumber) -> anyhow::Result { - random_n(0, upper.try_into()?) - } - #[function(name = "randi")] /// Generate a random Int between `lower` (inclusive) and `upper` (exclusive) pub fn randi_int_int(lower: i64, upper: i64) -> anyhow::Result { random_n(lower, upper) } - - #[function(name = "randi")] - /// Generate a random Int between `lower` (inclusive) and `upper` (exclusive) - pub fn randi_int_number(lower: i64, upper: &AdvancedNumber) -> anyhow::Result { - random_n(lower, upper.try_into()?) - } - - #[function(name = "randi")] - /// Generate a random Int between `lower` (inclusive) and `upper` (exclusive) - pub fn randi_number_int(lower: &AdvancedNumber, upper: i64) -> anyhow::Result { - random_n(lower.try_into()?, upper) - } - - #[function(name = "randi")] - /// Generate a random Int between `lower` (inclusive) and `upper` (exclusive) - pub fn randi_number_number( - lower: &AdvancedNumber, - upper: &AdvancedNumber, - ) -> anyhow::Result { - random_n(lower.try_into()?, upper.try_into()?) - } } diff --git a/ndc_vm/src/value/number.rs b/ndc_vm/src/value/number.rs index 7241d3e8..09b556ff 100644 --- a/ndc_vm/src/value/number.rs +++ b/ndc_vm/src/value/number.rs @@ -624,27 +624,6 @@ impl TryFrom<&AdvancedNumber> for f64 { } } -#[derive(thiserror::Error, Debug)] -pub enum NumberToIntError { - #[error("cannot convert {0} to int")] - UnsupportedType(StaticType), - #[error("cannot convert {0} to int")] - UnsupportedValue(AdvancedNumber), -} - -impl TryFrom<&AdvancedNumber> for i64 { - type Error = NumberToIntError; - - fn try_from(value: &AdvancedNumber) -> Result { - match value { - AdvancedNumber::Int(integer) => integer - .try_into() - .map_err(|_err| NumberToIntError::UnsupportedValue(value.clone())), - _ => Err(Self::Error::UnsupportedType(value.static_type())), - } - } -} - impl fmt::Display for AdvancedNumber { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/tests/functional/programs/606_stdlib_rand/006_randi_number_bounds.ndc b/tests/functional/programs/606_stdlib_rand/006_randi_number_bounds.ndc deleted file mode 100644 index 8a6aade1..00000000 --- a/tests/functional/programs/606_stdlib_rand/006_randi_number_bounds.ndc +++ /dev/null @@ -1,13 +0,0 @@ -// randi accepts integer Number bounds in any combination with Int -for _ in 0..100 { - let x: Int = randi(10n); - assert(x >= 0 and x < 10); -} - -for _ in 0..100 { - let x: Int = randi(-5n, 5n); - assert(x >= -5 and x < 5); -} - -assert_eq(randi(5n, 6), 5); -assert_eq(randi(5, 6n), 5); diff --git a/tests/functional/programs/606_stdlib_rand/006_randi_number_bounds_error.ndc b/tests/functional/programs/606_stdlib_rand/006_randi_number_bounds_error.ndc new file mode 100644 index 00000000..18e5bbdb --- /dev/null +++ b/tests/functional/programs/606_stdlib_rand/006_randi_number_bounds_error.ndc @@ -0,0 +1,4 @@ +// randi takes Int bounds only. Number bounds are rejected when the call is +// resolved rather than converted, so `10n` never silently loses precision. +// expect-error: No function called 'randi' +randi(10n); From 9b2e87afa93feb36ac45a82559f0c915f65e9ce4 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Sat, 5 Sep 2026 13:42:59 +0200 Subject: [PATCH 25/31] =?UTF-8?q?fix(vm):=20repair=20exact-number=20result?= =?UTF-8?q?s=20and=20hashing=20=F0=9F=94=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes to AdvancedNumber, all reachable from ordinary programs: `0 ^ negative` only guarded the integer base, so a zero rational reached num-rational directly and panicked the process. One check before the match covers every exact operand pair and lets int_pow drop its own copy. A non-finite exponent has a NaN `fract()`, which compares unequal to zero and sent a negative base into the complex plane: `(-2n) ^ Inf` answered a complex NaN where the float path answers Inf. Found by the Codex review. Division and the remainders build their result as a fraction, and a denominator of one never collapsed, so `4n / 2n` stayed a Rational that printed as `2` but no longer matched the Int arm serde switches on. The constructor now normalizes, which fixes every caller at once. Hashing a value no i64 could hold built an exact BigRational, so float map keys allocated twice per insert. Values an f64 represents exactly now hash from their bits; only the rest reach the canonical form. 200k float keys go from 176ms to 48ms. Equal values still share a bucket in every mode, which the new test pins down across floats, dyadic rationals and big integers. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_vm/src/value/number.rs | 100 ++++++++++++++---- .../046_exact_zero_negative_power.ndc | 4 + .../001_math/047_non_finite_exponent.ndc | 10 ++ .../048_exact_results_stay_integers.ndc | 11 ++ .../001_math/049_numeric_key_identity.ndc | 24 +++++ 5 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 tests/functional/programs/001_math/046_exact_zero_negative_power.ndc create mode 100644 tests/functional/programs/001_math/047_non_finite_exponent.ndc create mode 100644 tests/functional/programs/001_math/048_exact_results_stay_integers.ndc create mode 100644 tests/functional/programs/001_math/049_numeric_key_identity.ndc diff --git a/ndc_vm/src/value/number.rs b/ndc_vm/src/value/number.rs index 09b556ff..92c165fe 100644 --- a/ndc_vm/src/value/number.rs +++ b/ndc_vm/src/value/number.rs @@ -98,9 +98,10 @@ impl Default for AdvancedNumber { } } -/// Tags keeping the two hashing schemes below from colliding with each other. +/// Tags keeping the three hashing schemes below from colliding with each other. const HASH_TAG_EXACT_I64: u8 = 0; const HASH_TAG_CANONICAL: u8 = 1; +const HASH_TAG_EXACT_F64: u8 = 2; /// Hash a number whose exact value is the integer `value`. /// @@ -135,6 +136,22 @@ impl Hash for AdvancedNumber { return; } + // A value an `f64` represents exactly hashes from its bits, which + // keeps float keys off the allocating path entirely. Only the values + // no float can express build the exact rational form. + if let Some(value) = self.as_exact_f64() { + HASH_TAG_EXACT_F64.hash(state); + // Every NaN compares equal here, so every NaN must hash alike + // regardless of the payload bits it carries. + let bits = if value.is_nan() { + f64::NAN.to_bits() + } else { + value.to_bits() + }; + bits.hash(state); + return; + } + HASH_TAG_CANONICAL.hash(state); self.canonical().hash(state); } @@ -234,19 +251,9 @@ macro_rules! impl_binary_operator { }; } -/// Implement `$trait` for every owned/borrowed combination of `AdvancedNumber`. -macro_rules! impl_binary_operator_all { - ($trait:ident, $method:ident) => { - impl_binary_operator!(AdvancedNumber, AdvancedNumber, $trait, $method); - impl_binary_operator!(AdvancedNumber, &AdvancedNumber, $trait, $method); - impl_binary_operator!(&AdvancedNumber, AdvancedNumber, $trait, $method); - impl_binary_operator!(&AdvancedNumber, &AdvancedNumber, $trait, $method); - }; -} - -impl_binary_operator_all!(Add, add); -impl_binary_operator_all!(Sub, sub); -impl_binary_operator_all!(Mul, mul); +impl_binary_operator!(AdvancedNumber, AdvancedNumber, Add, add); +impl_binary_operator!(AdvancedNumber, AdvancedNumber, Sub, sub); +impl_binary_operator!(AdvancedNumber, AdvancedNumber, Mul, mul); /// Returns `true` for the number kinds that use exact (integer/rational) /// arithmetic. @@ -361,6 +368,31 @@ impl AdvancedNumber { } } + /// The value as the `f64` that represents it *exactly*, or `None` when + /// the conversion would lose anything. One step up from + /// [`Self::as_exact_i64`]: a float always qualifies, a rational only when + /// its denominator is a power of two that survives the mantissa. + /// + /// Equal values agree here, because exactness is a property of the number + /// and not of the variant holding it: `0.5` and `1n/2n` both answer + /// `Some(0.5)`, so both reach the same hash. + fn as_exact_f64(&self) -> Option { + match self { + Self::Int(value) => { + let candidate = value.to_f64()?; + (BigInt::from_f64(candidate).as_ref() == Some(value)).then_some(candidate) + } + Self::Float(value) => Some(*value), + Self::Rational(value) => { + let candidate = value.to_f64()?; + BigRational::from_float(candidate)? + .eq(value.as_ref()) + .then_some(candidate) + } + Self::Complex(value) => (value.im == 0.0).then_some(value.re), + } + } + fn canonical(&self) -> CanonicalNumber { match self { Self::Int(value) => CanonicalNumber { @@ -388,7 +420,14 @@ impl AdvancedNumber { } #[must_use] + /// Wrap an exact fraction, collapsing a denominator of 1 back to + /// [`Self::Int`]. Division and the remainders all land here, so without + /// the collapse `4n / 2n` stays a `Rational` that prints as `2` but no + /// longer matches the `Int` arm any consumer switches on. pub fn rational(rat: BigRational) -> Self { + if rat.is_integer() { + return Self::Int(rat.to_integer()); + } Self::Rational(Box::new(rat)) } @@ -440,14 +479,10 @@ impl AdvancedNumber { } /// Raise an integer base to a (possibly negative) integer exponent. - /// A negative exponent yields the reciprocal as a rational, except - /// `0 ^ negative`, which is division by zero and returns an error - /// instead of panicking with a zero denominator. + /// A negative exponent yields the reciprocal as a rational; `pow` has + /// already rejected the `0 ^ negative` pair that would divide by zero. fn int_pow(base: &BigInt, exponent: &BigInt) -> Result { if exponent.is_negative() { - if base.is_zero() { - return Err(BinaryOperatorError::new("division by zero".to_string())); - } let denominator = num::pow::Pow::pow(base.clone(), exponent.magnitude()); Ok(Self::Rational(Box::new(BigRational::new( BigInt::from(1), @@ -477,6 +512,24 @@ impl AdvancedNumber { )); } + // `0 ^ negative` is division by zero rather than a value. Both exact + // bases need the guard here: the rational arms below would otherwise + // panic inside num-rational with a zero denominator, taking the whole + // process down. Inexact zeroes keep their float infinity. + let exact_zero_base = match &self { + Self::Int(base) => base.is_zero(), + Self::Rational(base) => base.is_zero(), + Self::Float(_) | Self::Complex(_) => false, + }; + let exact_negative_exponent = match &rhs { + Self::Int(exponent) => exponent.is_negative(), + Self::Rational(exponent) => exponent.is_negative(), + Self::Float(_) | Self::Complex(_) => false, + }; + if exact_zero_base && exact_negative_exponent { + return Err(BinaryOperatorError::new("division by zero".to_string())); + } + Ok(match (self, rhs) { // Exact results first: an integer exponent keeps an exact base exact. (Self::Int(base), Self::Int(exponent)) => return Self::int_pow(&base, &exponent), @@ -642,7 +695,10 @@ impl fmt::Display for AdvancedNumber { /// negative base is raised to a fractional power: `(-8.0) ^ 2.0` is `64.0`, /// but `(-8.0) ^ 0.5` has no real value. fn float_pow(base: f64, exponent: f64) -> AdvancedNumber { - if base < 0.0 && exponent.fract() != 0.0 { + // `fract()` is NaN for an infinite or NaN exponent, which compares + // unequal to zero and would send a finite negative base into the complex + // plane. `powf` already answers those correctly. + if base < 0.0 && exponent.is_finite() && exponent.fract() != 0.0 { AdvancedNumber::Complex(Complex64::from(base).powf(exponent)) } else { AdvancedNumber::Float(base.powf(exponent)) @@ -699,7 +755,7 @@ mod tests { let five = [ AdvancedNumber::Int(BigInt::from(5)), AdvancedNumber::Float(5.0), - AdvancedNumber::rational(BigRational::new(10.into(), 2.into())), + AdvancedNumber::Rational(Box::new(BigRational::new(10.into(), 2.into()))), AdvancedNumber::complex(5.0, -0.0), ]; diff --git a/tests/functional/programs/001_math/046_exact_zero_negative_power.ndc b/tests/functional/programs/001_math/046_exact_zero_negative_power.ndc new file mode 100644 index 00000000..c5b04cbd --- /dev/null +++ b/tests/functional/programs/001_math/046_exact_zero_negative_power.ndc @@ -0,0 +1,4 @@ +// `0 ^ negative` is division by zero for every exact base. The rational base +// used to reach num-rational directly and panic the whole process. +// expect-error: division by zero +print((0n/1n) ^ -1n); diff --git a/tests/functional/programs/001_math/047_non_finite_exponent.ndc b/tests/functional/programs/001_math/047_non_finite_exponent.ndc new file mode 100644 index 00000000..63d37ea4 --- /dev/null +++ b/tests/functional/programs/001_math/047_non_finite_exponent.ndc @@ -0,0 +1,10 @@ +// `fract()` is NaN for a non-finite exponent, which compares unequal to zero +// and used to send a negative base into the complex plane. Infinite exponents +// have real answers and must stay on the real path. +let inf = Number(1.0 / 0.0); +assert_eq((-2n) ^ inf, Number(1.0 / 0.0)); +assert_eq((-2n) ^ Number(-1.0 / 0.0), 0.0n); + +// a genuinely fractional exponent still escapes to the complex plane +assert(imag((-8n) ^ 0.5n) != 0.0n); +assert_eq((-8n) ^ 2n, 64n); diff --git a/tests/functional/programs/001_math/048_exact_results_stay_integers.ndc b/tests/functional/programs/001_math/048_exact_results_stay_integers.ndc new file mode 100644 index 00000000..950d8a23 --- /dev/null +++ b/tests/functional/programs/001_math/048_exact_results_stay_integers.ndc @@ -0,0 +1,11 @@ +// Division and the remainders build their result as a fraction. A denominator +// of one has to collapse back to an integer, or the value prints as `2` while +// no longer matching the integer arm any consumer switches on. +assert_eq(json_encode(4n / 2n), "2"); +assert_eq(json_encode(7n %% 3n), "1"); +assert_eq(json_encode(7n % 3n), "1"); +assert_eq(json_encode(2n ^ 2n), "4"); + +// genuine fractions are untouched +assert_eq(4n / 3n, 4n / 3n); +assert_eq(denominator(4n / 3n), 3n); diff --git a/tests/functional/programs/001_math/049_numeric_key_identity.ndc b/tests/functional/programs/001_math/049_numeric_key_identity.ndc new file mode 100644 index 00000000..148f3caa --- /dev/null +++ b/tests/functional/programs/001_math/049_numeric_key_identity.ndc @@ -0,0 +1,24 @@ +// Hashing takes a fast path for values an f64 represents exactly. Equal +// values must land in the same bucket whichever mode holds them, and unequal +// ones must not be merged by the shortcut. +let dyadic = %{}; +dyadic[0.5] = "float"; +dyadic[1n/2n] = "rational"; +assert_eq(dyadic.len(), 1); + +let inexact = %{}; +inexact[1n/3n] = "exact"; +inexact[1.0/3.0] = "approx"; +assert_eq(inexact.len(), 2); + +let integers = %{}; +integers[5] = "int"; +integers[5.0] = "float"; +integers[5n] = "number"; +assert_eq(integers.len(), 1); + +// a big integer that is still exactly an f64 agrees with that float +let big = %{}; +big[2n ^ 100n] = "bigint"; +big[Number(2.0 ^ 100)] = "float"; +assert_eq(big.len(), 1); From fa503cac0c89b4553b40aef8ea6d78a7862a1320 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Sat, 5 Sep 2026 13:43:06 +0200 Subject: [PATCH 26/31] =?UTF-8?q?fix(stdlib):=20report=20left=20shifts=20t?= =?UTF-8?q?hat=20overflow=20their=20Int=20result=20=E2=AC=85=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checked_shl` only rejects a shift amount of 64 or more, never a result that does not fit, so `1 << 63` wrapped to a negative Int in silence while every other Int operation reported its overflow — and the manual already claimed shifts were checked. Verify the shift is reversible instead. Three other repairs in the same file: The error helpers formatted operands with the unbounded `static_type()`, so comparing a self-referential list aborted the process with a stack overflow while building the message. They use the bounded `diagnostic_type()` the same PR introduced for this hazard. `convert_to_int` described its argument before the numeric fast path that discards the description, walking whole containers to do it. It now only describes on the error paths. Restores the per-function documentation on ceil, floor, round, abs, signum, real, imag, numerator, denominator, gcd and lcm, which the rewrite had replaced with one shared placeholder per group; the same strings feed LSP hover. Registration of the unary helpers is reversed to match register_binary_arithmetic, so a dynamically dispatched abs(1) tests the Int candidate first rather than last. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_stdlib/src/math.rs | 109 +++++++++++++----- .../001_math/044_int_shift_overflow.ndc | 5 + .../001_math/045_int_shift_in_range.ndc | 6 + 3 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 tests/functional/programs/001_math/044_int_shift_overflow.ndc create mode 100644 tests/functional/programs/001_math/045_int_shift_in_range.ndc diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 5b1e060f..c4f5fa8e 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -144,7 +144,7 @@ fn wrong_operands(expected: &[StaticType], args: &[Value]) -> VmError { let expected = list(expected.iter().map(StaticType::to_string).collect()); let got = list( args.iter() - .map(|arg| arg.static_type().to_string()) + .map(|arg| arg.diagnostic_type().to_string()) .collect(), ); @@ -352,7 +352,7 @@ fn wrong_mode(mode: NumericMode, value: &Value) -> VmError { VmError::native(format!( "expected {}, got {}", mode.static_type(), - value.static_type() + value.diagnostic_type() )) } @@ -498,8 +498,8 @@ fn compare_operands(args: &[Value]) -> Result { left.partial_cmp(right).ok_or_else(|| { VmError::native(format!( "cannot compare {} and {}", - left.static_type(), - right.static_type() + left.diagnostic_type(), + right.diagnostic_type() )) }) } @@ -663,11 +663,23 @@ fn register_bitwise(env: &mut FunctionRegistry>) { let amount = u32::try_from(*right) .map_err(|_error| VmError::native("invalid shift amount".to_string()))?; if left_shift { - left.checked_shl(amount) + // `checked_shl` only rejects an out-of-range amount, never + // a result that does not fit, so `1 << 63` would other- + // wise wrap to a negative silently. Verify the shift is + // reversible instead; zero survives any amount. + if *left == 0 { + Ok(0) + } else { + left.checked_shl(amount) + .filter(|shifted| shifted >> amount == *left) + .ok_or_else(|| { + VmError::native("integer shift overflowed".to_string()) + }) + } } else { left.checked_shr(amount) + .ok_or_else(|| VmError::native("invalid shift amount".to_string())) } - .ok_or_else(|| VmError::native("invalid shift amount".to_string())) } ); } @@ -801,27 +813,46 @@ enum PreservingUnary { } fn register_number_helpers(env: &mut FunctionRegistry>) { - for mode in NumericMode::ALL { + // Reversed for the same reason as `register_binary_arithmetic`: runtime + // candidates are inspected in reverse registration order, so registering + // `Int` last puts it first where dynamic dispatch looks. + for mode in NumericMode::ALL.into_iter().rev() { declare( env, "signum", vec![mode.static_type()], mode.static_type(), - "Returns the sign of a number.", + "Returns -1 if the number is negative, 0 if it is zero, and 1 if it is positive.", move |args| unary_preserving(args, mode, PreservingUnary::Signum), ); - for (name, operation) in [ - ("ceil", PreservingUnary::Ceil), - ("floor", PreservingUnary::Floor), - ("round", PreservingUnary::Round), - ("abs", PreservingUnary::Abs), + for (name, operation, documentation) in [ + ( + "ceil", + PreservingUnary::Ceil, + "Returns the smallest integer greater than or equal to the number.", + ), + ( + "floor", + PreservingUnary::Floor, + "Returns the largest integer less than or equal to the number.", + ), + ( + "round", + PreservingUnary::Round, + "Rounds the number to the nearest integer, with ties rounding away from zero.", + ), + ( + "abs", + PreservingUnary::Abs, + "Returns the absolute value of a number.", + ), ] { declare( env, name, vec![mode.static_type()], mode.static_type(), - "Applies a numeric operation while preserving the numeric mode.", + documentation, move |args| unary_preserving(args, mode, operation), ); } @@ -852,12 +883,19 @@ fn register_number_helpers(env: &mut FunctionRegistry>) { } } - for (name, imaginary) in [("real", false), ("imag", true)] { + for (name, imaginary, documentation) in [ + ("real", false, "Returns the real part of a complex number."), + ( + "imag", + true, + "Returns the imaginary part of a complex number.", + ), + ] { declare_typed!( env, name, (value: Number) -> Number, - "Returns a component of an advanced number.", + documentation, match (value.as_ref(), imaginary) { (AdvancedNumber::Complex(value), false) => AdvancedNumber::Float(value.re), (AdvancedNumber::Complex(value), true) => AdvancedNumber::Float(value.im), @@ -867,12 +905,23 @@ fn register_number_helpers(env: &mut FunctionRegistry>) { ); } - for (name, numerator) in [("numerator", true), ("denominator", false)] { + for (name, numerator, documentation) in [ + ( + "numerator", + true, + "Returns the numerator of a rational number.", + ), + ( + "denominator", + false, + "Returns the denominator of a rational number.", + ), + ] { declare_typed!( env, name, (value: Number) -> Number, - "Returns a component of an exact Number fraction.", + documentation, match value.as_ref() { AdvancedNumber::Int(value) if numerator => AdvancedNumber::Int(value.clone()), AdvancedNumber::Int(_) => AdvancedNumber::Int(BigInt::from(1)), @@ -930,7 +979,7 @@ fn unary_preserving( _ => Err(VmError::native(format!( "expected {}, got {}", mode.static_type(), - args[0].static_type() + args[0].diagnostic_type() ))), } } @@ -967,18 +1016,23 @@ fn register_integer_helpers(env: &mut FunctionRegistry>) { }, ); - for (name, operation) in [ + for (name, operation, documentation) in [ ( "gcd", (|left: &BigInt, right: &BigInt| left.gcd(right)) as fn(&BigInt, &BigInt) -> BigInt, + "Returns the greatest common divisor of two integers.", + ), + ( + "lcm", + |left: &BigInt, right: &BigInt| left.lcm(right), + "Returns the least common multiple of two integers.", ), - ("lcm", |left: &BigInt, right: &BigInt| left.lcm(right)), ] { declare_typed!( env, name, (left: Int, right: Int) -> Result, - "Computes an integer divisor operation with checked i64 output.", + documentation, operation(&BigInt::from(*left), &BigInt::from(*right)) .to_i64() .ok_or_else(|| VmError::native("integer result overflowed".to_string())) @@ -1089,11 +1143,12 @@ fn register_conversions(env: &mut FunctionRegistry>) { } fn convert_to_int(value: &Value) -> Result { - let static_type = value.static_type(); + // Describing the value is only needed on the error paths, and for a + // container that description walks the whole structure. + let cannot_convert = + || VmError::native(format!("cannot convert {} to Int", value.diagnostic_type())); if let Some(number) = value.numeric_ref() { - return number - .to_i64_truncating() - .ok_or_else(|| VmError::native(format!("cannot convert {static_type} to Int"))); + return number.to_i64_truncating().ok_or_else(cannot_convert); } let converted = match value { @@ -1105,7 +1160,7 @@ fn convert_to_int(value: &Value) -> Result { Value::None => None, Value::Int(_) | Value::Float(_) | Value::Number(_) => unreachable!("handled above"), }; - converted.ok_or_else(|| VmError::native(format!("cannot convert {static_type} to Int"))) + converted.ok_or_else(cannot_convert) } fn convert_to_float(value: &Value) -> Result { diff --git a/tests/functional/programs/001_math/044_int_shift_overflow.ndc b/tests/functional/programs/001_math/044_int_shift_overflow.ndc new file mode 100644 index 00000000..726f8e6d --- /dev/null +++ b/tests/functional/programs/001_math/044_int_shift_overflow.ndc @@ -0,0 +1,5 @@ +// `checked_shl` only rejects an out-of-range shift amount, so `1 << 63` used +// to wrap to a negative Int silently while every other Int operation reported +// its overflow. +// expect-error: integer shift overflowed +print(1 << 63); diff --git a/tests/functional/programs/001_math/045_int_shift_in_range.ndc b/tests/functional/programs/001_math/045_int_shift_in_range.ndc new file mode 100644 index 00000000..58114687 --- /dev/null +++ b/tests/functional/programs/001_math/045_int_shift_in_range.ndc @@ -0,0 +1,6 @@ +// Shifts that fit are unaffected, including the two edges: `-1 << 63` is +// exactly i64::MIN, and zero survives any amount. +assert_eq(1 << 62, 4611686018427387904); +assert_eq(-1 << 63, -9223372036854775807 - 1); +assert_eq(0 << 64, 0); +assert_eq(8 >> 2, 2); From 9578fd01b7e4348ea70aea162c1bff190b9c2657 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Sat, 5 Sep 2026 13:43:33 +0200 Subject: [PATCH 27/31] =?UTF-8?q?fix(vm):=20say=20what=20is=20actually=20w?= =?UTF-8?q?rong=20with=20a=20rejected=20value=20=F0=9F=93=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes `diagnostic_type()` so ndc_stdlib can reach the bounded description its error helpers now use; it was pub(crate), which left the unbounded `static_type()` as the only option outside this crate. Every non-Int range bound claimed the integer was too large. Now that `n` literals are the only way to write a big integer, a small Number bound is the common case, and the message sent you looking for an overflow that was not there. Only an integer that genuinely does not fit reports a size problem. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_vm/src/value/mod.rs | 6 +++--- ndc_vm/src/vm.rs | 18 ++++++++++++++++-- .../008_iterators/007_range_bound_types.ndc | 4 ++++ .../bug0033_cyclic_container_diagnostic.ndc | 7 +++++++ 4 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 tests/functional/programs/008_iterators/007_range_bound_types.ndc create mode 100644 tests/functional/programs/900_bugs/bug0033_cyclic_container_diagnostic.ndc diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 0d3df772..ea6f1b59 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -273,7 +273,7 @@ impl Value { /// Returns a runtime type description whose recursion depth and total /// number of inspected values are bounded. Once either limit is reached, /// the remaining subtree is widened to `Any`. - pub(crate) fn diagnostic_type(&self) -> StaticType { + pub fn diagnostic_type(&self) -> StaticType { let mut budget = DIAGNOSTIC_TYPE_VALUE_BUDGET; self.static_type_with_budget(DIAGNOSTIC_TYPE_MAX_DEPTH, &mut budget) } @@ -1192,8 +1192,8 @@ mod tests { Value::Float(1.0), Value::number(AdvancedNumber::Int(1.into())), Value::number(AdvancedNumber::Float(1.0)), - Value::number(AdvancedNumber::rational(num::BigRational::from_integer( - 1.into(), + Value::number(AdvancedNumber::Rational(Box::new( + num::BigRational::from_integer(1.into()), ))), Value::complex(num::Complex::new(1.0, 0.0)), ]; diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index a994a334..7baf824c 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -493,7 +493,7 @@ impl Vm { let end = if bounded { let v = self.stack.pop().expect("stack underflow"); let Value::Int(n) = v else { - return Err(VmError::new("Integer too large for range bounds", span)); + return Err(VmError::new(bad_range_bound(&v), span)); }; Some(n) } else { @@ -501,7 +501,7 @@ impl Vm { }; let start = self.stack.pop().expect("stack underflow"); let Value::Int(start) = start else { - return Err(VmError::new("Integer too large for range bounds", span)); + return Err(VmError::new(bad_range_bound(&start), span)); }; let iter: Rc> = match (inclusive, end) { (_, None) => Rc::new(RefCell::new(UnboundedRangeIter::new(start))), @@ -1247,3 +1247,17 @@ impl VmCallable<'_> { self.vm.call_callback(self.function.clone(), args) } } + +/// Explain why a range bound was rejected. Only an integer too big for an +/// `Int` is a size problem; every other non-`Int` bound is a type problem, and +/// since `n` literals are the only way to write a big integer, a small +/// `Number` bound is now the common case. +fn bad_range_bound(value: &Value) -> String { + if let Some(number) = value.as_number() + && matches!(number, crate::value::AdvancedNumber::Int(_)) + && number.as_exact_i64().is_none() + { + return "Integer too large for range bounds".to_string(); + } + format!("range bounds must be Int, got {}", value.diagnostic_type()) +} diff --git a/tests/functional/programs/008_iterators/007_range_bound_types.ndc b/tests/functional/programs/008_iterators/007_range_bound_types.ndc new file mode 100644 index 00000000..7cf89390 --- /dev/null +++ b/tests/functional/programs/008_iterators/007_range_bound_types.ndc @@ -0,0 +1,4 @@ +// Every non-Int bound used to claim the integer was too large. Only an +// integer that genuinely does not fit is a size problem. +// expect-error: range bounds must be Int, got Number +for i in 0n..3n { print(i); } diff --git a/tests/functional/programs/900_bugs/bug0033_cyclic_container_diagnostic.ndc b/tests/functional/programs/900_bugs/bug0033_cyclic_container_diagnostic.ndc new file mode 100644 index 00000000..aa7f0fe2 --- /dev/null +++ b/tests/functional/programs/900_bugs/bug0033_cyclic_container_diagnostic.ndc @@ -0,0 +1,7 @@ +// Formatting the "cannot compare" error walked the self-referential list +// forever and aborted the process with a stack overflow. The bounded +// description stops at a fixed depth instead. +// expect-error: cannot compare +let a = []; +a.push(a); +print(a < 1); From e801fc1969ffb99bc2b7d9474b7b181df9442abb Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Sat, 5 Sep 2026 13:43:33 +0200 Subject: [PATCH 28/31] =?UTF-8?q?refactor(vm):=20drop=20a=20redundant=20ra?= =?UTF-8?q?nge=20check=20=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `float_to_i64` allocated a BigInt to range-test a finite f64. Truncating first makes it exactly the conversion `exact_f64_to_i64` already performs with two comparisons, so it delegates. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_vm/src/value/numeric.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ndc_vm/src/value/numeric.rs b/ndc_vm/src/value/numeric.rs index a7c05f20..c2e4f259 100644 --- a/ndc_vm/src/value/numeric.rs +++ b/ndc_vm/src/value/numeric.rs @@ -1,5 +1,5 @@ use ndc_core::StaticType; -use num::{FromPrimitive, ToPrimitive}; +use num::ToPrimitive; use std::cmp::Ordering; use std::hash::{Hash, Hasher}; @@ -159,12 +159,11 @@ fn exact_cmp(left: &AdvancedNumber, right: &AdvancedNumber) -> Ordering { .expect("AdvancedNumber values are totally ordered") } +/// Truncate toward zero, answering `None` when the result would not fit. +/// Truncating first makes this the exact conversion of a whole number, so it +/// reuses that bounds check rather than allocating a `BigInt` to range-test. fn float_to_i64(value: f64) -> Option { - if value.is_finite() { - num::BigInt::from_f64(value.trunc())?.to_i64() - } else { - None - } + super::number::exact_f64_to_i64(value.trunc()) } /// Match `AdvancedNumber`'s total ordering without constructing exact rational From 2a6a1afbc5fcec00a75073eb1652d364e6558551 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Sat, 5 Sep 2026 13:43:33 +0200 Subject: [PATCH 29/31] =?UTF-8?q?docs(manual):=20spell=20out=20Number's=20?= =?UTF-8?q?zero-divisor=20result=20=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `5n % 0n` is NaN where `5 % 0` is an error. The page covered division but left the remainders implicit. Co-Authored-By: Claude Opus 5 (1M context) --- manual/src/reference/types/number.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/manual/src/reference/types/number.md b/manual/src/reference/types/number.md index b9634203..4e52884a 100644 --- a/manual/src/reference/types/number.md +++ b/manual/src/reference/types/number.md @@ -81,11 +81,17 @@ assert_eq(sqrt(-1n), 1i); ```ndc assert_eq(1n / 0n, Inf); +assert_eq(1n \ 0n, Inf); let nan = 0n / 0n; assert(nan == nan); ``` +Both remainder operators follow the same rule, so `5n % 0n` and `5n %% 0n` are +`NaN` where `5 % 0` and `5 %% 0` are errors. Moving an accumulator from `Int` +to `Number` therefore trades the zero-divisor diagnostic for a value that +propagates. + ## Equality, hashing, and ordering Numeric equality compares exact values across all three modes. Equal values produce the same map or set hash. Andy C++ converts each finite Float to its exact binary rational value for this comparison, so decimal approximation does not make values equal: From 46b2e3ee9d0450e0954d06eccfc6fad08d3e25e0 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Mon, 7 Sep 2026 08:51:05 +0200 Subject: [PATCH 30/31] =?UTF-8?q?fix(vm):=20normalize=20every=20exact=20fr?= =?UTF-8?q?action,=20not=20just=20the=20ones=20from=20division=20?= =?UTF-8?q?=F0=9F=94=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsing a denominator of one inside `rational()` only helps callers that go through it, and the power branches built the variant directly: `1n ^ -1n` and `(1n/2n) ^ -1n` are whole numbers that stayed wrapped as rationals, so json_encode refused them. Reported by the Codex review. The `From` conversion had the same hole and is the wider one, since every `.into()` on a computed fraction went through it. Both now route to the normalizing constructor, as does `abs`, which cannot produce a whole value today but would be the next place to leak one. Documents the invariant on the variant itself: a `Rational` never holds a whole number, and `rational()` is how you get one. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_vm/src/value/number.rs | 17 +++++++++++------ .../048_exact_results_stay_integers.ndc | 11 +++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/ndc_vm/src/value/number.rs b/ndc_vm/src/value/number.rs index 92c165fe..6988d083 100644 --- a/ndc_vm/src/value/number.rs +++ b/ndc_vm/src/value/number.rs @@ -11,6 +11,11 @@ use num::{BigInt, BigRational, Complex, FromPrimitive, Signed, ToPrimitive, Zero pub enum AdvancedNumber { Int(BigInt), Float(f64), + /// Never holds a whole number: a denominator of one belongs in + /// [`Self::Int`], or consumers that switch on the variant — serde is the + /// one in tree — reject a value that is an integer in every other sense. + /// Build this through [`Self::rational`], which enforces that; the `From` + /// conversion routes there too. Rational(Box), Complex(Complex64), } @@ -70,7 +75,7 @@ impl From for AdvancedNumber { impl From for AdvancedNumber { fn from(value: BigRational) -> Self { - Self::Rational(Box::new(value)) + Self::rational(value) } } @@ -484,10 +489,10 @@ impl AdvancedNumber { fn int_pow(base: &BigInt, exponent: &BigInt) -> Result { if exponent.is_negative() { let denominator = num::pow::Pow::pow(base.clone(), exponent.magnitude()); - Ok(Self::Rational(Box::new(BigRational::new( + Ok(Self::rational(BigRational::new( BigInt::from(1), denominator, - )))) + ))) } else { Ok(Self::Int(num::pow::Pow::pow( base.clone(), @@ -537,13 +542,13 @@ impl AdvancedNumber { return Self::int_pow(&base, &exponent.to_integer()); } (Self::Rational(base), Self::Int(exponent)) => { - Self::Rational(Box::new(num::pow::Pow::pow(&*base, exponent))) + Self::rational(num::pow::Pow::pow(&*base, exponent)) } (Self::Rational(base), Self::Rational(exponent)) if exponent.is_integer() && exponent.to_i32().is_some() => { let exponent = exponent.to_i32().expect("checked by the match guard"); - Self::Rational(Box::new(base.pow(exponent))) + Self::rational(base.pow(exponent)) } // A complex operand on either side keeps the result complex. @@ -601,7 +606,7 @@ impl AdvancedNumber { match self { Self::Int(i) => Self::Int(i.abs()), Self::Float(f) => Self::Float(f.abs()), - Self::Rational(r) => Self::Rational(Box::new(r.abs())), + Self::Rational(r) => Self::rational(r.abs()), Self::Complex(c) => Self::Float(c.abs()), } } diff --git a/tests/functional/programs/001_math/048_exact_results_stay_integers.ndc b/tests/functional/programs/001_math/048_exact_results_stay_integers.ndc index 950d8a23..6aacee7d 100644 --- a/tests/functional/programs/001_math/048_exact_results_stay_integers.ndc +++ b/tests/functional/programs/001_math/048_exact_results_stay_integers.ndc @@ -9,3 +9,14 @@ assert_eq(json_encode(2n ^ 2n), "4"); // genuine fractions are untouched assert_eq(4n / 3n, 4n / 3n); assert_eq(denominator(4n / 3n), 3n); + +// A negative power reaches the same normalization: the reciprocal of a whole +// number can itself be whole, and the rational-base branches likewise. +assert_eq(json_encode(1n ^ -1n), "1"); +assert_eq(json_encode((1n/2n) ^ -1n), "2"); +assert_eq(json_encode((1n/2n) ^ -2n), "4"); +assert_eq(json_encode(abs(-4n/2n)), "2"); + +// a reciprocal that is genuinely fractional keeps its exact form +assert_eq(2n ^ -1n, 1n/2n); +assert_eq(denominator(2n ^ -1n), 2n); From 10751c1257162ef9197141f8a8865c227747f589 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Mon, 7 Sep 2026 11:31:32 +0200 Subject: [PATCH 31/31] =?UTF-8?q?refactor(vm):=20make=20a=20whole=20fracti?= =?UTF-8?q?on=20unrepresentable=20=F0=9F=94=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third report of one bug shape: `AdvancedNumber::Rational` must never hold a whole number, and every construction site that forgot leaked a value that is an integer in every sense except the variant holding it. Normalizing the constructor fixed division and the remainders, routing the power branches fixed those, and Codex then found the macro's return path. A doc comment was not going to hold the line. The payload is now an `ExactFraction` whose field is private to this module, so `AdvancedNumber::rational` is the only way to build the variant and the rule holds by construction. Both routes are compile errors from outside: AdvancedNumber::Rational(Box::new(r)) // mismatched types AdvancedNumber::Rational(ExactFraction(Box::new(r))) // private field Reading is unchanged — it derefs to the fraction, and Unbox/AsRef/Display bridge the sites that took a Box before, so the 35 match sites stay as they were. The macro's `BigRational` return arm is fixed here rather than by the compiler, because nothing returns one yet and the arm never expands; anyone who adds such a native now gets an error instead of a silent leak. Two tests built a whole fraction on purpose to check it hashed like an integer. That state no longer exists, so they assert the guarantee that replaced it: the constructor hands back `Int`, and a genuine fraction is left alone. Co-Authored-By: Claude Opus 5 (1M context) --- ndc_macros/src/vm_convert.rs | 2 +- ndc_vm/src/value/mod.rs | 8 ++-- ndc_vm/src/value/number.rs | 78 ++++++++++++++++++++++++++++++------ 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/ndc_macros/src/vm_convert.rs b/ndc_macros/src/vm_convert.rs index 8a7d8915..4fa23d0e 100644 --- a/ndc_macros/src/vm_convert.rs +++ b/ndc_macros/src/vm_convert.rs @@ -531,7 +531,7 @@ fn vm_return_for_classified(ty: &syn::Type) -> Option<(TokenStream, TokenStream) NdcType::BigRational => Some(( quote! { Ok(ndc_vm::value::Value::from_number( - ndc_vm::value::AdvancedNumber::Rational(Box::new(result)) + ndc_vm::value::AdvancedNumber::rational(result) )) }, quote! { ndc_core::StaticType::Number }, diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index ea6f1b59..008c1999 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -3,7 +3,7 @@ mod number; mod numeric; pub use function::*; -pub use number::{AdvancedNumber, BinaryOperatorError, NumberToFloatError}; +pub use number::{AdvancedNumber, BinaryOperatorError, ExactFraction, NumberToFloatError}; pub use numeric::{NumericMode, NumericRef}; use crate::iterator::SharedIterator; @@ -1192,8 +1192,10 @@ mod tests { Value::Float(1.0), Value::number(AdvancedNumber::Int(1.into())), Value::number(AdvancedNumber::Float(1.0)), - Value::number(AdvancedNumber::Rational(Box::new( - num::BigRational::from_integer(1.into()), + // the constructor collapses this to an integer rather than + // leaving a whole fraction in the rational variant + Value::number(AdvancedNumber::rational(num::BigRational::from_integer( + 1.into(), ))), Value::complex(num::Complex::new(1.0, 0.0)), ]; diff --git a/ndc_vm/src/value/number.rs b/ndc_vm/src/value/number.rs index 6988d083..8df73269 100644 --- a/ndc_vm/src/value/number.rs +++ b/ndc_vm/src/value/number.rs @@ -11,15 +11,44 @@ use num::{BigInt, BigRational, Complex, FromPrimitive, Signed, ToPrimitive, Zero pub enum AdvancedNumber { Int(BigInt), Float(f64), - /// Never holds a whole number: a denominator of one belongs in - /// [`Self::Int`], or consumers that switch on the variant — serde is the - /// one in tree — reject a value that is an integer in every other sense. - /// Build this through [`Self::rational`], which enforces that; the `From` - /// conversion routes there too. - Rational(Box), + /// Never holds a whole number — see [`ExactFraction`], which is why that + /// is not merely a convention. + Rational(ExactFraction), Complex(Complex64), } +/// The payload of [`AdvancedNumber::Rational`], holding a fraction that is +/// never whole: a denominator of one belongs in [`AdvancedNumber::Int`], or +/// consumers that switch on the variant reject a value that is an integer in +/// every other sense. +/// +/// The field is private so that [`AdvancedNumber::rational`] is the only way +/// to build one, which makes that rule hold by construction rather than by +/// everyone remembering it. Reading is unrestricted: it derefs to the +/// fraction it wraps. +#[derive(Debug, Clone)] +pub struct ExactFraction(Box); + +impl ExactFraction { + fn into_inner(self) -> BigRational { + *self.0 + } +} + +impl std::ops::Deref for ExactFraction { + type Target = BigRational; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for ExactFraction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + #[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)] enum CanonicalScalar { NegInfinity, @@ -169,7 +198,7 @@ impl Neg for AdvancedNumber { match self { Self::Int(i) => i.neg().into(), Self::Float(f) => f.neg().into(), - Self::Rational(r) => r.neg().into(), + Self::Rational(r) => r.into_inner().neg().into(), Self::Complex(c) => c.neg().into(), } } @@ -180,17 +209,23 @@ trait Unbox { fn unbox(self) -> Self::Output; } -impl Unbox for Box { +impl Unbox for ExactFraction { type Output = BigRational; fn unbox(self) -> Self::Output { - *self + self.into_inner() } } -impl<'a> Unbox for &'a Box { +impl<'a> Unbox for &'a ExactFraction { type Output = &'a BigRational; fn unbox(self) -> Self::Output { - &**self + self + } +} + +impl AsRef for ExactFraction { + fn as_ref(&self) -> &BigRational { + self } } @@ -433,7 +468,7 @@ impl AdvancedNumber { if rat.is_integer() { return Self::Int(rat.to_integer()); } - Self::Rational(Box::new(rat)) + Self::Rational(ExactFraction(Box::new(rat))) } pub fn static_type(&self) -> StaticType { @@ -760,7 +795,6 @@ mod tests { let five = [ AdvancedNumber::Int(BigInt::from(5)), AdvancedNumber::Float(5.0), - AdvancedNumber::Rational(Box::new(BigRational::new(10.into(), 2.into()))), AdvancedNumber::complex(5.0, -0.0), ]; @@ -771,6 +805,24 @@ mod tests { } } + /// A whole fraction has no rational representation to hash: the + /// constructor is the only way to build the variant and it hands back an + /// integer instead, which is what keeps every consumer that switches on + /// the variant honest. + #[test] + fn a_whole_fraction_becomes_an_integer() { + let five = AdvancedNumber::rational(BigRational::new(10.into(), 2.into())); + assert!(matches!(five, AdvancedNumber::Int(_))); + assert_eq!(five, AdvancedNumber::Int(BigInt::from(5))); + + let whole = AdvancedNumber::from(BigRational::from_integer(7.into())); + assert!(matches!(whole, AdvancedNumber::Int(_))); + + // a genuine fraction is left alone + let half = AdvancedNumber::rational(BigRational::new(1.into(), 2.into())); + assert!(matches!(half, AdvancedNumber::Rational(_))); + } + #[test] fn values_off_the_integer_fast_path_still_agree() { let huge = AdvancedNumber::Int(BigInt::from(u64::MAX) * BigInt::from(u64::MAX));