From 76d0760bbd6ed200a3d3c94ec8fe2dbdbae60302 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 12:40:54 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(core):=20return=20error=20instead=20of?= =?UTF-8?q?=20panicking=20on=20integer=20division=20by=20zero=20?= =?UTF-8?q?=F0=9F=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integer `%`, `%%` and `\` by zero crashed the interpreter with a Rust panic ("attempt to divide by zero") originating in i64/num-bigint arithmetic. `1 % 0`, `5 \ 0`, a BigInt `% 0` and a rational `% 0` all aborted the process instead of producing a recoverable runtime error. Guard the exact-arithmetic remainder and floor-division paths against a zero divisor and surface a "division by zero" error. Float `%` keeps its IEEE NaN behaviour and `/` keeps returning infinity, matching the existing documented semantics. Adds functional regression tests and documents the behaviour in the manual. --- manual/src/reference/types/number.md | 8 ++ ndc_core/src/num.rs | 93 ++++++++++++++++++- ndc_stdlib/src/math.rs | 46 +++++++-- .../programs/001_math/024_modulo_by_zero.ndc | 2 + .../001_math/025_modulo_by_zero_bigint.ndc | 2 + .../001_math/026_floor_div_by_zero.ndc | 2 + .../001_math/027_euclidean_modulo_by_zero.ndc | 2 + .../001_math/028_rational_modulo_by_zero.ndc | 3 + 8 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 tests/functional/programs/001_math/024_modulo_by_zero.ndc create mode 100644 tests/functional/programs/001_math/025_modulo_by_zero_bigint.ndc create mode 100644 tests/functional/programs/001_math/026_floor_div_by_zero.ndc create mode 100644 tests/functional/programs/001_math/027_euclidean_modulo_by_zero.ndc create mode 100644 tests/functional/programs/001_math/028_rational_modulo_by_zero.ndc diff --git a/manual/src/reference/types/number.md b/manual/src/reference/types/number.md index 4b86211d..500b5f4b 100644 --- a/manual/src/reference/types/number.md +++ b/manual/src/reference/types/number.md @@ -30,6 +30,14 @@ Andy C++ has four number types: | `>=<` | Reverse compare | `false` | `false` | | `<>` | Concatenate string values | `true` | `false` | +### Division by zero + +Dividing by zero with `/` follows floating-point semantics and yields infinity +(for example `1 / 0` is `inf`). The integer operators `%` (modulo), `%%` +(euclidean remainder) and `\` (floor division) have no meaningful result when +the divisor is zero, so they raise a runtime error (`division by zero`) instead. +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` | diff --git a/ndc_core/src/num.rs b/ndc_core/src/num.rs index 4633d7f1..cfefe301 100644 --- a/ndc_core/src/num.rs +++ b/ndc_core/src/num.rs @@ -314,7 +314,71 @@ macro_rules! impl_binary_operator_all { 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!(Rem, rem, Rem::rem, Rem::rem, Rem::rem, Rem::rem); + +/// 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(_)) +} + +impl Rem for Number { + 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"). + if is_exact(&self) && is_exact(&rhs) && rhs.is_zero() { + return Err(BinaryOperatorError::new("division by zero".to_string())); + } + Ok(match (self, rhs) { + // Integer + (Self::Int(left), Self::Int(right)) => Self::Int(left % right), + // Complex + (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) + } + // 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"), + ), + }) + } +} + +impl Rem<&Self> for Number { + type Output = Result; + + fn rem(self, rhs: &Self) -> Self::Output { + self % rhs.clone() + } +} + +impl Rem for &Number { + type Output = Result; + + fn rem(self, rhs: Number) -> Self::Output { + self.clone() % rhs + } +} + +impl Rem<&Number> for &Number { + type Output = Result; + + fn rem(self, rhs: &Number) -> Self::Output { + self.clone() % rhs.clone() + } +} impl Div<&Number> for &Number { type Output = Number; @@ -377,12 +441,26 @@ impl Number { } } + #[must_use] + pub fn is_zero(&self) -> bool { + match self { + Self::Int(i) => i.is_zero(), + Self::Float(f) => *f == 0.0, + Self::Rational(r) => r.is_zero(), + Self::Complex(c) => c.is_zero(), + } + } + pub fn checked_rem_euclid(self, rhs: Self) -> Result { match (self, rhs) { - (Self::Int(p1), Self::Int(p2)) => p1 - .checked_rem_euclid(&p2) - .ok_or(BinaryOperatorError::new("operation failed".to_string())) - .map(Self::Int), + (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( @@ -394,6 +472,11 @@ impl Number { } pub fn floor_div(self, rhs: Self) -> Result { + // Floor division of an exact value by zero panics (`i64::div_euclid` + // and the bigint path), so reject it the same way remainder does. + if is_exact(&self) && is_exact(&rhs) && rhs.is_zero() { + return Err(BinaryOperatorError::new("division by zero".to_string())); + } match (self, rhs) { // Handle this case separately because it's faster?? (Self::Int(Int::Int64(l)), Self::Int(Int::Int64(r))) => { diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index 5732c824..e19da491 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -301,12 +301,46 @@ pub mod f64 { std::ops::Mul::mul, "Multiplies two integers." ); - implement_binary_operator_on_int!( - "%", - checked_rem, - std::ops::Rem::rem, - "Returns the remainder of dividing 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))) + } + } + [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)) + } + _ => Err(VmError::native(format!( + "expected 2 arguments, got {}", + args.len() + ))), + })), + })); // Float-specific overloads: operate directly on f64. macro_rules! implement_binary_operator_on_float { diff --git a/tests/functional/programs/001_math/024_modulo_by_zero.ndc b/tests/functional/programs/001_math/024_modulo_by_zero.ndc new file mode 100644 index 00000000..560bbd17 --- /dev/null +++ b/tests/functional/programs/001_math/024_modulo_by_zero.ndc @@ -0,0 +1,2 @@ +// expect-error: division by zero +print(5 % 0); 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 new file mode 100644 index 00000000..2c81be23 --- /dev/null +++ b/tests/functional/programs/001_math/025_modulo_by_zero_bigint.ndc @@ -0,0 +1,2 @@ +// expect-error: division by zero +print(170141183460469231731687303715884105727 % 0); 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 new file mode 100644 index 00000000..567793e7 --- /dev/null +++ b/tests/functional/programs/001_math/026_floor_div_by_zero.ndc @@ -0,0 +1,2 @@ +// expect-error: division by zero +print(5 \ 0); diff --git a/tests/functional/programs/001_math/027_euclidean_modulo_by_zero.ndc b/tests/functional/programs/001_math/027_euclidean_modulo_by_zero.ndc new file mode 100644 index 00000000..5fd9ae6a --- /dev/null +++ b/tests/functional/programs/001_math/027_euclidean_modulo_by_zero.ndc @@ -0,0 +1,2 @@ +// expect-error: division by zero +print(5 %% 0); 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 new file mode 100644 index 00000000..7365e79c --- /dev/null +++ b/tests/functional/programs/001_math/028_rational_modulo_by_zero.ndc @@ -0,0 +1,3 @@ +// expect-error: division by zero +let a = 1 / 3; +print(a % 0); From 272c7fcf6858c13978d2ead7dd988b9cccfc651f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 04:58:56 +0000 Subject: [PATCH 2/2] fix(core): keep floor division by zero returning infinity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit over-corrected: it made floor division (`\`) by zero raise an error for every operand type. But only the i64 fast path actually panicked — rational and BigInt floor division by zero already promoted to a float and returned infinity, matching `/`. Erroring there was a regression. Guard just the i64 `div_euclid` fast path (which also panics on `i64::MIN \ -1`) and fall back to the general float-promoting path, so all floor divisions by zero return `inf` again. Remainder (`%`, `%%`) still errors, since those genuinely panicked before and have no infinite result. Updates the floor-div regression tests and the manual accordingly. --- manual/src/reference/types/number.md | 11 ++++++----- ndc_core/src/num.rs | 19 ++++++++++--------- .../001_math/026_floor_div_by_zero.ndc | 4 +++- .../029_floor_div_by_zero_rational.ndc | 6 ++++++ 4 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 tests/functional/programs/001_math/029_floor_div_by_zero_rational.ndc diff --git a/manual/src/reference/types/number.md b/manual/src/reference/types/number.md index 500b5f4b..604698d0 100644 --- a/manual/src/reference/types/number.md +++ b/manual/src/reference/types/number.md @@ -32,11 +32,12 @@ Andy C++ has four number types: ### Division by zero -Dividing by zero with `/` follows floating-point semantics and yields infinity -(for example `1 / 0` is `inf`). The integer operators `%` (modulo), `%%` -(euclidean remainder) and `\` (floor division) have no meaningful result when -the divisor is zero, so they raise a runtime error (`division by zero`) instead. -Floating-point `%` follows IEEE semantics and returns `NaN`. +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: diff --git a/ndc_core/src/num.rs b/ndc_core/src/num.rs index cfefe301..1584477b 100644 --- a/ndc_core/src/num.rs +++ b/ndc_core/src/num.rs @@ -472,16 +472,17 @@ impl Number { } pub fn floor_div(self, rhs: Self) -> Result { - // Floor division of an exact value by zero panics (`i64::div_euclid` - // and the bigint path), so reject it the same way remainder does. - if is_exact(&self) && is_exact(&rhs) && rhs.is_zero() { - return Err(BinaryOperatorError::new("division by zero".to_string())); - } match (self, rhs) { - // Handle this case separately because it's faster?? - (Self::Int(Int::Int64(l)), Self::Int(Int::Int64(r))) => { - Ok(Self::Int(Int::Int64(l.div_euclid(r)))) - } + // 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()), } } 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 567793e7..0c926052 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,2 +1,4 @@ -// expect-error: division by zero +// Floor division by zero promotes to a float and yields infinity, the same +// as `/` (it must not panic). print(5 \ 0); +// expect-output: inf 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 new file mode 100644 index 00000000..74797972 --- /dev/null +++ b/tests/functional/programs/001_math/029_floor_div_by_zero_rational.ndc @@ -0,0 +1,6 @@ +// Regression: rational and BigInt floor division by zero yields infinity, +// not an error (matches `/`). +print((1 / 3) \ 0); +print(170141183460469231731687303715884105727 \ 0); +// expect-output: inf +// expect-output: inf