Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ jobs:
- run: cargo test --all-features
- run: git diff --exit-code # Must commit everything

test-ubuntu-aarch64:
name: Test Linux ARM64
runs-on: ubuntu-24.04-arm
steps:
- uses: taiki-e/checkout-action@b13d20b7cda4e2f325ef19895128f7ff735c0b3d # v1.3.1
- uses: oxc-project/setup-rust@cd82e1efec7fef815e2c23d296756f31c7cdc03d # v1.0.0
with:
save-cache: ${{ github.ref_name == 'main' }}
cache-key: warm-aarch64
- run: cargo ck
- run: cargo test --all-features
- run: git diff --exit-code # Must commit everything

# Separate job to save a job on PRs
test-mac:
name: Test Mac
Expand Down
207 changes: 157 additions & 50 deletions crates/oxc_ecmascript/src/to_int_32.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,79 +2,186 @@
///
/// [ToInt32]: https://tc39.es/ecma262/#sec-toint32
///
/// This is copied from [Boa](https://github.com/boa-dev/boa/blob/61567687cf4bfeca6bd548c3e72b6965e74b2461/core/engine/src/builtins/number/conversions.rs)
/// This is copied from [Boa](https://github.com/boa-dev/boa/blob/95c8d4820ad10ce32892dd75673b1d8b8854f974/core/engine/src/builtins/number/conversions.rs)
pub trait ToInt32 {
fn to_int_32(&self) -> i32;
}

impl ToInt32 for f64 {
#[expect(clippy::float_cmp, clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn to_int_32(&self) -> i32 {
const SIGN_MASK: u64 = 0x8000_0000_0000_0000;
const EXPONENT_MASK: u64 = 0x7FF0_0000_0000_0000;
const SIGNIFICAND_MASK: u64 = 0x000F_FFFF_FFFF_FFFF;
const HIDDEN_BIT: u64 = 0x0010_0000_0000_0000;
const PHYSICAL_SIGNIFICAND_SIZE: i32 = 52; // Excludes the hidden bit.
const SIGNIFICAND_SIZE: i32 = 53;

const EXPONENT_BIAS: i32 = 0x3FF + PHYSICAL_SIGNIFICAND_SIZE;
const DENORMAL_EXPONENT: i32 = -EXPONENT_BIAS + 1;

fn is_denormal(number: f64) -> bool {
(number.to_bits() & EXPONENT_MASK) == 0
#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
{
// macOS aarch64 always has jsconv feature
// SAFETY: macOS aarch64 always supports jsconv
unsafe { f64_to_int32_arm64(*self) }
}

fn exponent(number: f64) -> i32 {
if is_denormal(number) {
return DENORMAL_EXPONENT;
#[cfg(all(target_arch = "aarch64", not(target_os = "macos")))]
{
if std::arch::is_aarch64_feature_detected!("jsconv") {
// SAFETY: Feature detection confirmed jsconv is available
unsafe { f64_to_int32_arm64(*self) }
} else {
f64_to_int32_generic(*self)
}
}
#[cfg(not(target_arch = "aarch64"))]
{
f64_to_int32_generic(*self)
}
}
}

let d64 = number.to_bits();
let biased_e = ((d64 & EXPONENT_MASK) >> PHYSICAL_SIGNIFICAND_SIZE) as i32;
/// Converts a 64-bit floating point number to an `i32` using [`FJCVTZS`][FJCVTZS] instruction on ARM64.
///
/// This requires ARM v8.3-A or later with the JavaScript conversion (jsconv) feature.
///
/// [FJCVTZS]: https://developer.arm.com/documentation/dui0801/h/A64-Floating-point-Instructions/FJCVTZS
#[cfg(target_arch = "aarch64")]
#[target_feature(enable = "jsconv")]
unsafe fn f64_to_int32_arm64(number: f64) -> i32 {
if number.is_nan() {
return 0;
}
let ret: i32;
// SAFETY: Number is not nan so no floating-point exception should throw.
std::arch::asm!(
"fjcvtzs {dst:w}, {src:d}",
src = in(vreg) number,
dst = out(reg) ret,
);
ret
}

biased_e - EXPONENT_BIAS
/// Generic implementation of ToInt32 for non-ARM64 architectures or ARM64 without JSCVT
#[expect(clippy::float_cmp, clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn f64_to_int32_generic(number: f64) -> i32 {
const SIGN_MASK: u64 = 0x8000_0000_0000_0000;
const EXPONENT_MASK: u64 = 0x7FF0_0000_0000_0000;
const SIGNIFICAND_MASK: u64 = 0x000F_FFFF_FFFF_FFFF;
const HIDDEN_BIT: u64 = 0x0010_0000_0000_0000;
const PHYSICAL_SIGNIFICAND_SIZE: i32 = 52; // Excludes the hidden bit.
const SIGNIFICAND_SIZE: i32 = 53;

const EXPONENT_BIAS: i32 = 0x3FF + PHYSICAL_SIGNIFICAND_SIZE;
const DENORMAL_EXPONENT: i32 = -EXPONENT_BIAS + 1;

fn is_denormal(number: f64) -> bool {
(number.to_bits() & EXPONENT_MASK) == 0
}

fn exponent(number: f64) -> i32 {
if is_denormal(number) {
return DENORMAL_EXPONENT;
}

fn significand(number: f64) -> u64 {
let d64 = number.to_bits();
let significand = d64 & SIGNIFICAND_MASK;
let d64 = number.to_bits();
let biased_e = ((d64 & EXPONENT_MASK) >> PHYSICAL_SIGNIFICAND_SIZE) as i32;

if is_denormal(number) { significand } else { significand + HIDDEN_BIT }
}
biased_e - EXPONENT_BIAS
}

fn sign(number: f64) -> i64 {
if (number.to_bits() & SIGN_MASK) == 0 { 1 } else { -1 }
}
fn significand(number: f64) -> u64 {
let d64 = number.to_bits();
let significand = d64 & SIGNIFICAND_MASK;

if is_denormal(number) { significand } else { significand + HIDDEN_BIT }
}

let number = *self;
fn sign(number: f64) -> i64 {
if (number.to_bits() & SIGN_MASK) == 0 { 1 } else { -1 }
}

// NOTE: this also matches with negative zero
if !number.is_finite() || number == 0.0 {
// NOTE: this also matches with negative zero
if !number.is_finite() || number == 0.0 {
return 0;
}

if number.is_finite() && number <= f64::from(i32::MAX) && number >= f64::from(i32::MIN) {
let i = number as i32;
if f64::from(i) == number {
return i;
}
}

let exponent = exponent(number);
let bits = if exponent < 0 {
if exponent <= -SIGNIFICAND_SIZE {
return 0;
}

if number.is_finite() && number <= f64::from(i32::MAX) && number >= f64::from(i32::MIN) {
let i = number as i32;
if f64::from(i) == number {
return i;
}
significand(number) >> -exponent
} else {
if exponent > 31 {
return 0;
}

let exponent = exponent(number);
let bits = if exponent < 0 {
if exponent <= -SIGNIFICAND_SIZE {
return 0;
}
(significand(number) << exponent) & 0xFFFF_FFFF
};

significand(number) >> -exponent
} else {
if exponent > 31 {
return 0;
}
(sign(number) * (bits as i64)) as i32
}

#[cfg(test)]
mod test {
use super::*;

#[test]
#[expect(clippy::cast_precision_loss)]
fn f64_to_int32_conversion() {
assert_eq!(0.0_f64.to_int_32(), 0);
assert_eq!((-0.0_f64).to_int_32(), 0);
assert_eq!(f64::NAN.to_int_32(), 0);
assert_eq!(f64::INFINITY.to_int_32(), 0);
assert_eq!(f64::NEG_INFINITY.to_int_32(), 0);
assert_eq!(((i64::from(i32::MAX) + 1) as f64).to_int_32(), i32::MIN);
assert_eq!(((i64::from(i32::MIN) - 1) as f64).to_int_32(), i32::MAX);

// Test edge cases with maximum safe integers
assert_eq!((9_007_199_254_740_992.0_f64).to_int_32(), 0); // 2^53
assert_eq!((-9_007_199_254_740_992.0_f64).to_int_32(), 0); // -2^53
}

(significand(number) << exponent) & 0xFFFF_FFFF
};
#[test]
fn test_generic_and_arm64_consistency() {
// Test that generic implementation gives same results as ARM64 implementation
// when both are available (ARM64 with JSCVT support)
let test_values = [
0.0,
-0.0,
1.0,
-1.0,
42.7,
-42.7,
f64::from(i32::MAX),
f64::from(i32::MIN),
f64::from(i32::MAX) + 1.0,
f64::from(i32::MIN) - 1.0,
9_007_199_254_740_992.0, // 2^53
-9_007_199_254_740_992.0, // -2^53
];

for &value in &test_values {
let generic_result = f64_to_int32_generic(value);
let trait_result = value.to_int_32();
assert_eq!(
generic_result, trait_result,
"Mismatch for value {value}: generic={generic_result}, trait={trait_result}"
);
}
}

(sign(number) * (bits as i64)) as i32
#[test]
fn test_nan_handling() {
// Both implementations should handle NaN the same way
assert_eq!(f64::NAN.to_int_32(), 0);
assert_eq!(f64_to_int32_generic(f64::NAN), 0);

#[cfg(target_arch = "aarch64")]
{
// SAFETY: This is a test and we're only testing NaN handling
unsafe {
assert_eq!(f64_to_int32_arm64(f64::NAN), 0);
}
}
}
}
Loading