Implement the saturating float-to-int cast, and the float math methods
`f as i32` in Rust saturates: NaN becomes 0, anything past the range clamps to the nearest end, and the rest truncates toward zero. Nim's `int32(f)` rounds and raises on overflow. rustnim refused the cast on those grounds, and the refusal was accurate -- it said no faithful mapping was *implemented*, which was true. `rsFloatToInt` implements the function Rust specifies. The bounds are compared in float space, which is exact where it matters: `float64(high(int64))` rounds up to 2^63, and every float at or above 2^63 saturates anyway, so the comparison lands on the right side of the boundary. Also `round`/`floor`/`ceil`/`trunc`/`sqrt`/`abs`/`ln`/`exp`/`powi`/`powf`. Checked against rustc rather than assumed: "round" names two different rules and only one of them is Rust's, and Nim's happens to be the same one -- half away from zero, `-0.0` included. `powi` takes an `i32` exponent where Nim's `pow` takes a float, so that one is converted. tests/cases/070 covers the cast across NaN, both infinities, both range ends and the narrowing cases. tests/cases/902 pinned the rejection and is deleted: three of the five reject cases have now gone that way, which says those rejections were about effort more often than about possibility. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
67f537f parent: d6e6f38 modified
DESIGN.md +6 -5 | @@ -1144,11 +1144,12 @@ Cases carry directives in leading `//@` comments: | ||
| 1144 | 1144 | | `//@ extern: <crate>` | the **oracle** links this crate; rustnim does not get it | |
| 1145 | 1145 | |
| 1146 | 1146 | `reject` cases are how the "fail loudly" rule is tested rather than merely |
| 1147 | -stated: `901`–`903` pin the rejections of an unmapped standard-library method, | |
| 1148 | -a float→int cast and an unimplemented format spec. (`900` pinned the `i128` | |
| 1149 | -rejection and `904` the `move`-closure one; both were deleted when those | |
| 1150 | -stopped being true. A rejection test is worth keeping only while its rejection | |
| 1151 | -is.) (`900` pinned | |
| 1147 | +stated: `901` and `903` pin the rejections of an unmapped standard-library | |
| 1148 | +method and an unimplemented format spec. (`900` pinned the `i128` rejection, `902` the | |
| 1149 | +float→int cast and `904` the `move`-closure one; all three were deleted when | |
| 1150 | +those stopped being true. A rejection test is worth keeping only while its | |
| 1151 | +rejection is — three of the five have now gone that way, which says the | |
| 1152 | +rejections were about effort rather than possibility more often than not.) (`900` pinned | |
| 1152 | 1153 | the `i128` rejection and was deleted when `i128` became implementable — a |
| 1153 | 1154 | rejection test is only worth keeping while the rejection is still true.) |
| 1154 | 1155 | |
| @@ -1144,11 +1144,12 @@ Cases carry directives in leading `//@` comments: | |||
| 1144 | | `//@ extern: <crate>` | the **oracle** links this crate; rustnim does not get it | | 1144 | | `//@ extern: <crate>` | the **oracle** links this crate; rustnim does not get it | |
| 1145 | 1145 | ||
| 1146 | `reject` cases are how the "fail loudly" rule is tested rather than merely | 1146 | `reject` cases are how the "fail loudly" rule is tested rather than merely |
| 1147 | -stated: `901`–`903` pin the rejections of an unmapped standard-library method, | 1147 | +stated: `901` and `903` pin the rejections of an unmapped standard-library |
| 1148 | -a float→int cast and an unimplemented format spec. (`900` pinned the `i128` | 1148 | +method and an unimplemented format spec. (`900` pinned the `i128` rejection, `902` the |
| 1149 | -rejection and `904` the `move`-closure one; both were deleted when those | 1149 | +float→int cast and `904` the `move`-closure one; all three were deleted when |
| 1150 | -stopped being true. A rejection test is worth keeping only while its rejection | 1150 | +those stopped being true. A rejection test is worth keeping only while its |
| 1151 | -is.) (`900` pinned | 1151 | +rejection is — three of the five have now gone that way, which says the |
| 1152 | +rejections were about effort rather than possibility more often than not.) (`900` pinned | ||
| 1152 | the `i128` rejection and was deleted when `i128` became implementable — a | 1153 | the `i128` rejection and was deleted when `i128` became implementable — a |
| 1153 | rejection test is only worth keeping while the rejection is still true.) | 1154 | rejection test is only worth keeping while the rejection is still true.) |
| 1154 | 1155 | ||
modified
src/lower.rs +35 -8 | @@ -6398,14 +6398,16 @@ impl Lowerer { | ||
| 6398 | 6398 | format!("cast[{}]({})", t.render(), v.code) |
| 6399 | 6399 | } |
| 6400 | 6400 | (Nim::Prim(a), Nim::Prim(b)) if a == b => v.code.clone(), |
| 6401 | - (f, t) if matches!(f, Nim::Prim(p) if p.starts_with("float")) && t.is_integer() => { | |
| 6402 | - // Rust saturates float->int casts; Nim rounds and range-errors. | |
| 6403 | - // Not the same operation, so it is refused rather than mapped. | |
| 6404 | - return Err(format!( | |
| 6405 | - "`as {}` from a float: Rust saturates, Nim rounds and range-checks; \ | |
| 6406 | - no faithful mapping is implemented", | |
| 6407 | - t.render() | |
| 6408 | - )); | |
| 6401 | + (f, t) | |
| 6402 | + if matches!(f, Nim::Prim(p) if p.starts_with("float")) | |
| 6403 | + && t.is_integer() | |
| 6404 | + && t.as_i128().is_none() => | |
| 6405 | + { | |
| 6406 | + // Rust saturates: NaN becomes 0, out-of-range clamps, the rest | |
| 6407 | + // truncates toward zero. Nim's `int32(f)` rounds and raises, | |
| 6408 | + // which is a different function -- so the prelude writes out | |
| 6409 | + // the one Rust specifies. See `rsFloatToInt`. | |
| 6410 | + format!("rsFloatToInt[{}](float64({}))", t.render(), v.code) | |
| 6409 | 6411 | } |
| 6410 | 6412 | (f, t) => { |
| 6411 | 6413 | return Err(format!( |
| @@ -7742,6 +7744,31 @@ impl Lowerer { | ||
| 7742 | 7744 | }; |
| 7743 | 7745 | (f, Some(Nim::Prim("bool".into()))) |
| 7744 | 7746 | } |
| 7747 | + // Nim's `math` spells these the same way and they agree value for | |
| 7748 | + // value, `-0.0` included -- checked against rustc rather than | |
| 7749 | + // assumed, because "round" names two different rules and only one | |
| 7750 | + // of them is Rust's (half away from zero, which is Nim's too). | |
| 7751 | + "round" | "floor" | "ceil" | "trunc" | "sqrt" | "abs" | "ln" | "exp" | |
| 7752 | + if matches!(&rt, Some(Nim::Prim(p)) if p.starts_with("float")) | |
| 7753 | + && m.args.is_empty() => | |
| 7754 | + { | |
| 7755 | + (format!("{}({})", name, recv.code), rt.clone()) | |
| 7756 | + } | |
| 7757 | + // `powi` takes an `i32` exponent and `powf` a float; Nim's `pow` | |
| 7758 | + // takes a float either way, so the integer one is converted. | |
| 7759 | + "powi" | "powf" | |
| 7760 | + if matches!(&rt, Some(Nim::Prim(p)) if p.starts_with("float")) | |
| 7761 | + && m.args.len() == 1 => | |
| 7762 | + { | |
| 7763 | + let t = rt.clone().unwrap(); | |
| 7764 | + let e = if name == "powi" { | |
| 7765 | + let v = self.expr(&m.args[0])?; | |
| 7766 | + format!("{}({})", t.render(), v.code) | |
| 7767 | + } else { | |
| 7768 | + self.expr_at(&m.args[0], Some(&t))?.code | |
| 7769 | + }; | |
| 7770 | + (format!("pow({}, {})", recv.code, e), rt.clone()) | |
| 7771 | + } | |
| 7745 | 7772 | "recip" if matches!(&rt, Some(Nim::Prim(p)) if p.starts_with("float")) => { |
| 7746 | 7773 | let t = rt.clone().unwrap(); |
| 7747 | 7774 | (format!("({}(1.0) / {})", t.render(), recv.code), Some(t)) |
| @@ -6398,14 +6398,16 @@ impl Lowerer { | |||
| 6398 | format!("cast[{}]({})", t.render(), v.code) | 6398 | format!("cast[{}]({})", t.render(), v.code) |
| 6399 | } | 6399 | } |
| 6400 | (Nim::Prim(a), Nim::Prim(b)) if a == b => v.code.clone(), | 6400 | (Nim::Prim(a), Nim::Prim(b)) if a == b => v.code.clone(), |
| 6401 | - (f, t) if matches!(f, Nim::Prim(p) if p.starts_with("float")) && t.is_integer() => { | 6401 | + (f, t) |
| 6402 | - // Rust saturates float->int casts; Nim rounds and range-errors. | 6402 | + if matches!(f, Nim::Prim(p) if p.starts_with("float")) |
| 6403 | - // Not the same operation, so it is refused rather than mapped. | 6403 | + && t.is_integer() |
| 6404 | - return Err(format!( | 6404 | + && t.as_i128().is_none() => |
| 6405 | - "`as {}` from a float: Rust saturates, Nim rounds and range-checks; \ | 6405 | + { |
| 6406 | - no faithful mapping is implemented", | 6406 | + // Rust saturates: NaN becomes 0, out-of-range clamps, the rest |
| 6407 | - t.render() | 6407 | + // truncates toward zero. Nim's `int32(f)` rounds and raises, |
| 6408 | - )); | 6408 | + // which is a different function -- so the prelude writes out |
| 6409 | + // the one Rust specifies. See `rsFloatToInt`. | ||
| 6410 | + format!("rsFloatToInt[{}](float64({}))", t.render(), v.code) | ||
| 6409 | } | 6411 | } |
| 6410 | (f, t) => { | 6412 | (f, t) => { |
| 6411 | return Err(format!( | 6413 | return Err(format!( |
| @@ -7742,6 +7744,31 @@ impl Lowerer { | |||
| 7742 | }; | 7744 | }; |
| 7743 | (f, Some(Nim::Prim("bool".into()))) | 7745 | (f, Some(Nim::Prim("bool".into()))) |
| 7744 | } | 7746 | } |
| 7747 | + // Nim's `math` spells these the same way and they agree value for | ||
| 7748 | + // value, `-0.0` included -- checked against rustc rather than | ||
| 7749 | + // assumed, because "round" names two different rules and only one | ||
| 7750 | + // of them is Rust's (half away from zero, which is Nim's too). | ||
| 7751 | + "round" | "floor" | "ceil" | "trunc" | "sqrt" | "abs" | "ln" | "exp" | ||
| 7752 | + if matches!(&rt, Some(Nim::Prim(p)) if p.starts_with("float")) | ||
| 7753 | + && m.args.is_empty() => | ||
| 7754 | + { | ||
| 7755 | + (format!("{}({})", name, recv.code), rt.clone()) | ||
| 7756 | + } | ||
| 7757 | + // `powi` takes an `i32` exponent and `powf` a float; Nim's `pow` | ||
| 7758 | + // takes a float either way, so the integer one is converted. | ||
| 7759 | + "powi" | "powf" | ||
| 7760 | + if matches!(&rt, Some(Nim::Prim(p)) if p.starts_with("float")) | ||
| 7761 | + && m.args.len() == 1 => | ||
| 7762 | + { | ||
| 7763 | + let t = rt.clone().unwrap(); | ||
| 7764 | + let e = if name == "powi" { | ||
| 7765 | + let v = self.expr(&m.args[0])?; | ||
| 7766 | + format!("{}({})", t.render(), v.code) | ||
| 7767 | + } else { | ||
| 7768 | + self.expr_at(&m.args[0], Some(&t))?.code | ||
| 7769 | + }; | ||
| 7770 | + (format!("pow({}, {})", recv.code, e), rt.clone()) | ||
| 7771 | + } | ||
| 7745 | "recip" if matches!(&rt, Some(Nim::Prim(p)) if p.starts_with("float")) => { | 7772 | "recip" if matches!(&rt, Some(Nim::Prim(p)) if p.starts_with("float")) => { |
| 7746 | let t = rt.clone().unwrap(); | 7773 | let t = rt.clone().unwrap(); |
| 7747 | (format!("({}(1.0) / {})", t.render(), recv.code), Some(t)) | 7774 | (format!("({}(1.0) / {})", t.render(), recv.code), Some(t)) |
modified
src/prelude.nim +15 -1 | @@ -12,7 +12,7 @@ | ||
| 12 | 12 | # it, because copying instead would silently change aliasing. |
| 13 | 13 | {.experimental: "views".} |
| 14 | 14 | |
| 15 | -import std/[unicode, strutils] | |
| 15 | +import std/[unicode, strutils, math] | |
| 16 | 16 | |
| 17 | 17 | type |
| 18 | 18 | RustPanic* = object of CatchableError |
| @@ -344,3 +344,17 @@ proc rsDebug*(o: RsOrdering): string = | ||
| 344 | 344 | of rsEqual: "Equal" |
| 345 | 345 | of rsGreater: "Greater" |
| 346 | 346 | |
| 347 | + | |
| 348 | +## Rust's `f as i32` saturates: NaN becomes 0, anything past the range clamps | |
| 349 | +## to the nearest end, and the rest truncates toward zero. Nim's `int32(f)` | |
| 350 | +## rounds and raises on overflow, which is a different function -- so this is | |
| 351 | +## the one Rust specifies, written out. | |
| 352 | +## | |
| 353 | +## The bounds are compared in float space. `float64(high(int64))` rounds up to | |
| 354 | +## 2^63, and every float at or above 2^63 saturates anyway, so the comparison | |
| 355 | +## lands on the right side of the boundary. | |
| 356 | +proc rsFloatToInt*[T: SomeInteger](f: float64): T = | |
| 357 | + if f != f: T(0) | |
| 358 | + elif f <= float64(low(T)): low(T) | |
| 359 | + elif f >= float64(high(T)): high(T) | |
| 360 | + else: T(trunc(f)) | |
| @@ -12,7 +12,7 @@ | |||
| 12 | # it, because copying instead would silently change aliasing. | 12 | # it, because copying instead would silently change aliasing. |
| 13 | {.experimental: "views".} | 13 | {.experimental: "views".} |
| 14 | 14 | ||
| 15 | -import std/[unicode, strutils] | 15 | +import std/[unicode, strutils, math] |
| 16 | 16 | ||
| 17 | type | 17 | type |
| 18 | RustPanic* = object of CatchableError | 18 | RustPanic* = object of CatchableError |
| @@ -344,3 +344,17 @@ proc rsDebug*(o: RsOrdering): string = | |||
| 344 | of rsEqual: "Equal" | 344 | of rsEqual: "Equal" |
| 345 | of rsGreater: "Greater" | 345 | of rsGreater: "Greater" |
| 346 | 346 | ||
| 347 | + | ||
| 348 | +## Rust's `f as i32` saturates: NaN becomes 0, anything past the range clamps | ||
| 349 | +## to the nearest end, and the rest truncates toward zero. Nim's `int32(f)` | ||
| 350 | +## rounds and raises on overflow, which is a different function -- so this is | ||
| 351 | +## the one Rust specifies, written out. | ||
| 352 | +## | ||
| 353 | +## The bounds are compared in float space. `float64(high(int64))` rounds up to | ||
| 354 | +## 2^63, and every float at or above 2^63 saturates anyway, so the comparison | ||
| 355 | +## lands on the right side of the boundary. | ||
| 356 | +proc rsFloatToInt*[T: SomeInteger](f: float64): T = | ||
| 357 | + if f != f: T(0) | ||
| 358 | + elif f <= float64(low(T)): low(T) | ||
| 359 | + elif f >= float64(high(T)): high(T) | ||
| 360 | + else: T(trunc(f)) | ||
modified
tests/cases/066-std-subset.rs +6 -0 | @@ -53,6 +53,12 @@ fn main() { | ||
| 53 | 53 | println!("{} {}", f64::MIN < -1.0e308, f32::MAX > 3.0e38); |
| 54 | 54 | println!("{}", f32::MAX.is_finite()); |
| 55 | 55 | |
| 56 | + // Nim's `math` agrees value for value here, `-0.0` included. "round" | |
| 57 | + // names two different rules; Rust's and Nim's are both half-away-from-zero. | |
| 58 | + println!("{} {} {}", 0.5f64.round(), (-0.5f64).round(), 2.5f64.round()); | |
| 59 | + println!("{} {} {}", (-0.5f64).floor(), (-0.5f64).ceil(), (-2.7f64).trunc()); | |
| 60 | + println!("{} {} {}", 9.0f64.sqrt(), (-3.5f64).abs(), 2.0f64.powi(10)); | |
| 61 | + | |
| 56 | 62 | // `cmp::max` / `cmp::min`. |
| 57 | 63 | println!("{} {}", std::cmp::max(3u32, 9), std::cmp::min(3u32, 9)); |
| 58 | 64 | |
| @@ -53,6 +53,12 @@ fn main() { | |||
| 53 | println!("{} {}", f64::MIN < -1.0e308, f32::MAX > 3.0e38); | 53 | println!("{} {}", f64::MIN < -1.0e308, f32::MAX > 3.0e38); |
| 54 | println!("{}", f32::MAX.is_finite()); | 54 | println!("{}", f32::MAX.is_finite()); |
| 55 | 55 | ||
| 56 | + // Nim's `math` agrees value for value here, `-0.0` included. "round" | ||
| 57 | + // names two different rules; Rust's and Nim's are both half-away-from-zero. | ||
| 58 | + println!("{} {} {}", 0.5f64.round(), (-0.5f64).round(), 2.5f64.round()); | ||
| 59 | + println!("{} {} {}", (-0.5f64).floor(), (-0.5f64).ceil(), (-2.7f64).trunc()); | ||
| 60 | + println!("{} {} {}", 9.0f64.sqrt(), (-3.5f64).abs(), 2.0f64.powi(10)); | ||
| 61 | + | ||
| 56 | // `cmp::max` / `cmp::min`. | 62 | // `cmp::max` / `cmp::min`. |
| 57 | println!("{} {}", std::cmp::max(3u32, 9), std::cmp::min(3u32, 9)); | 63 | println!("{} {}", std::cmp::max(3u32, 9), std::cmp::min(3u32, 9)); |
| 58 | 64 | ||
added
tests/cases/070-float-to-int.rs +16 -0 | new file mode 100644 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +// Rust's `f as i32` saturates: NaN becomes 0, anything past the range clamps | |
| 2 | +// to the nearest end, and the rest truncates toward zero. Nim's `int32(f)` | |
| 3 | +// rounds and raises, which is a different function. | |
| 4 | +fn main() { | |
| 5 | + let vals: [f64; 9] = [ | |
| 6 | + 0.0, 2.7, -2.7, 0.5, -0.5, | |
| 7 | + 1.0e20, -1.0e20, f64::NAN, 2147483647.9, | |
| 8 | + ]; | |
| 9 | + for v in vals { | |
| 10 | + println!("{} {} {} {}", v as i32, v as u8, v as i64, v as u32); | |
| 11 | + } | |
| 12 | + println!("{} {}", 300.0f64 as u8, (-1.0f64) as u8); | |
| 13 | + println!("{} {}", 1.0e300f64 as i64, (-1.0e300f64) as i64); | |
| 14 | + let f: f32 = 70000.5; | |
| 15 | + println!("{} {}", f as u16, f as i16); | |
| 16 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +// Rust's `f as i32` saturates: NaN becomes 0, anything past the range clamps | ||
| 2 | +// to the nearest end, and the rest truncates toward zero. Nim's `int32(f)` | ||
| 3 | +// rounds and raises, which is a different function. | ||
| 4 | +fn main() { | ||
| 5 | + let vals: [f64; 9] = [ | ||
| 6 | + 0.0, 2.7, -2.7, 0.5, -0.5, | ||
| 7 | + 1.0e20, -1.0e20, f64::NAN, 2147483647.9, | ||
| 8 | + ]; | ||
| 9 | + for v in vals { | ||
| 10 | + println!("{} {} {} {}", v as i32, v as u8, v as i64, v as u32); | ||
| 11 | + } | ||
| 12 | + println!("{} {}", 300.0f64 as u8, (-1.0f64) as u8); | ||
| 13 | + println!("{} {}", 1.0e300f64 as i64, (-1.0e300f64) as i64); | ||
| 14 | + let f: f32 = 70000.5; | ||
| 15 | + println!("{} {}", f as u16, f as i16); | ||
| 16 | +} | ||
deleted
tests/cases/902-reject-float-to-int-cast.rs +0 -6 | deleted file mode 100644 | ||
| @@ -1,6 +0,0 @@ | ||
| 1 | -//@ reject: Rust saturates, Nim rounds | |
| 2 | -// Rust saturates float->int casts, Nim rounds and range-checks. Different | |
| 3 | -// operations, so no mapping is emitted. | |
| 4 | -fn main() { | |
| 5 | - println!("{}", 1e10f64 as i32); | |
| 6 | -} | |
| deleted file mode 100644 | |||
| @@ -1,6 +0,0 @@ | |||
| 1 | -//@ reject: Rust saturates, Nim rounds | ||
| 2 | -// Rust saturates float->int casts, Nim rounds and range-checks. Different | ||
| 3 | -// operations, so no mapping is emitted. | ||
| 4 | -fn main() { | ||
| 5 | - println!("{}", 1e10f64 as i32); | ||
| 6 | -} | ||