const KEPT_FRACTION_DIGITS: usize = 3;
const FIXED_POINT_SCALE: i64 = 1000;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ScriptFixedPoint(i64);
pub fn script_fixed_point(text: &str) -> Option<ScriptFixedPoint> {
let (negative, digits) = split_sign(text)?;
let (integer, fraction) = match digits.split_once('.') {
Some((integer, fraction)) => (integer, fraction),
None => (digits, ""),
};
if integer.is_empty() && fraction.is_empty() {
return None;
}
if !is_ascii_digits(integer) || !is_ascii_digits(fraction) {
return None;
}
let integer = if integer.is_empty() {
0
} else {
integer.parse::<i64>().ok()?
};
let kept = &fraction[..fraction.len().min(KEPT_FRACTION_DIGITS)];
let thousandths = match kept.len() {
0 => 0,
length => kept.parse::<i64>().ok()? * 10_i64.pow((KEPT_FRACTION_DIGITS - length) as u32),
};
let magnitude = integer
.checked_mul(FIXED_POINT_SCALE)?
.checked_add(thousandths)?;
Some(ScriptFixedPoint(if negative {
-magnitude
} else {
magnitude
}))
}
pub fn script_int(text: &str) -> Option<i64> {
let (negative, digits) = split_sign(text)?;
let integer_length = digits
.find(|character: char| !character.is_ascii_digit())
.unwrap_or(digits.len());
let (integer, rest) = digits.split_at(integer_length);
if !rest.is_empty() && !is_decimal_fraction(rest) {
return None;
}
if integer.is_empty() {
return None;
}
let magnitude = integer.parse::<i64>().ok()?;
Some(if negative { -magnitude } else { magnitude })
}
pub fn canonical_float_text(text: &str) -> Option<String> {
let ScriptFixedPoint(thousandths) = script_fixed_point(text)?;
let sign = if thousandths < 0 { "-" } else { "" };
let magnitude = thousandths.unsigned_abs();
Some(format!(
"{sign}{}.{:03}",
magnitude / FIXED_POINT_SCALE.unsigned_abs(),
magnitude % FIXED_POINT_SCALE.unsigned_abs()
))
}
pub fn canonical_int_text(text: &str) -> Option<String> {
Some(script_int(text)?.to_string())
}
fn split_sign(text: &str) -> Option<(bool, &str)> {
match text.as_bytes() {
[b'-', ..] => Some((true, &text[1..])),
[b'+', ..] => Some((false, &text[1..])),
[] => None,
_ => Some((false, text)),
}
}
fn is_ascii_digits(text: &str) -> bool {
text.bytes().all(|byte| byte.is_ascii_digit())
}
fn is_decimal_fraction(text: &str) -> bool {
text.strip_prefix('.').is_some_and(is_ascii_digits)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed(text: &str) -> Option<i64> {
script_fixed_point(text).map(|value| value.0)
}
#[test]
fn trailing_zeros_do_not_change_the_value_the_game_reads() {
assert_eq!(fixed("0.5"), Some(500));
assert_eq!(fixed("0.50"), Some(500));
assert_eq!(fixed("0.500"), Some(500));
assert_eq!(fixed("1"), Some(1000));
assert_eq!(fixed("1.0"), Some(1000));
assert_eq!(fixed("1.000"), Some(1000));
}
#[test]
fn the_fourth_decimal_is_truncated_rather_than_rounded() {
assert_eq!(fixed("0.1234"), Some(123));
assert_eq!(fixed("0.1239"), Some(123));
assert_eq!(fixed("0.1299"), Some(129));
assert_eq!(fixed("0.0025"), Some(2));
}
#[test]
fn distinct_values_stay_distinct() {
assert_ne!(fixed("0.5"), fixed("0.6"));
assert_ne!(fixed("1.001"), fixed("1.002"));
assert_ne!(fixed("0.5"), fixed("-0.5"));
assert_ne!(fixed("1"), fixed("10"));
}
#[test]
fn negative_values_truncate_toward_zero() {
assert_eq!(fixed("-0.1234"), Some(-123));
assert_eq!(fixed("-1.5"), Some(-1500));
assert_eq!(fixed("-0.50"), fixed("-0.5"));
}
#[test]
fn leading_zeros_and_a_missing_integer_part_are_read_as_written() {
assert_eq!(fixed("007"), Some(7000));
assert_eq!(fixed(".5"), Some(500));
assert_eq!(fixed("0.5"), fixed(".5"));
assert_eq!(fixed("5."), Some(5000));
}
#[test]
fn unmodelled_shapes_abstain() {
assert_eq!(fixed(""), None);
assert_eq!(fixed("."), None);
assert_eq!(fixed("-"), None);
assert_eq!(fixed("yes"), None);
assert_eq!(fixed("1e-3"), None);
assert_eq!(fixed("1444.11.11"), None);
assert_eq!(fixed("0x10"), None);
assert_eq!(fixed("123abc"), None);
}
#[test]
fn the_int_reader_takes_the_integer_prefix() {
assert_eq!(script_int("123"), Some(123));
assert_eq!(script_int("0123"), Some(123));
assert_eq!(script_int("123.9"), Some(123));
assert_eq!(script_int("-4.9"), Some(-4));
assert_eq!(script_int("+7"), Some(7));
}
#[test]
fn the_int_reader_abstains_where_the_model_was_not_checked() {
assert_eq!(script_int("123abc"), None);
assert_eq!(script_int("abc"), None);
assert_eq!(script_int(""), None);
assert_eq!(script_int("1444.11.11"), None);
}
#[test]
fn the_canonical_float_spelling_is_the_engine_representation() {
assert_eq!(canonical_float_text("0.5").as_deref(), Some("0.500"));
assert_eq!(canonical_float_text("0.50").as_deref(), Some("0.500"));
assert_eq!(canonical_float_text("0.500").as_deref(), Some("0.500"));
assert_eq!(canonical_float_text("1").as_deref(), Some("1.000"));
assert_eq!(canonical_float_text("0.1239").as_deref(), Some("0.123"));
assert_eq!(canonical_float_text(".5").as_deref(), Some("0.500"));
assert_eq!(canonical_float_text("007").as_deref(), Some("7.000"));
assert_eq!(canonical_float_text("0").as_deref(), Some("0.000"));
}
#[test]
fn the_canonical_float_spelling_keeps_the_sign_and_truncates_toward_zero() {
assert_eq!(canonical_float_text("-0.1239").as_deref(), Some("-0.123"));
assert_eq!(canonical_float_text("-1.5").as_deref(), Some("-1.500"));
assert_eq!(canonical_float_text("-0.0001").as_deref(), Some("0.000"));
}
#[test]
fn the_canonical_int_spelling_drops_what_the_reader_discards() {
assert_eq!(canonical_int_text("3").as_deref(), Some("3"));
assert_eq!(canonical_int_text("3.4").as_deref(), Some("3"));
assert_eq!(canonical_int_text("007").as_deref(), Some("7"));
assert_eq!(canonical_int_text("-4.9").as_deref(), Some("-4"));
}
#[test]
fn a_canonical_spelling_is_its_own_canonical_spelling() {
for text in ["0.5", "0.50", "1", "0.1239", "-1.5", "007", "3.4"] {
let once = canonical_float_text(text).expect("canonical float");
assert_eq!(canonical_float_text(&once).as_deref(), Some(once.as_str()));
let once = canonical_int_text(text).expect("canonical int");
assert_eq!(canonical_int_text(&once).as_deref(), Some(once.as_str()));
}
}
#[test]
fn texts_the_game_reads_alike_get_one_spelling() {
for (left, right) in [("0.5", "0.50"), ("1", "1.0"), ("0.1234", "0.1239")] {
assert_eq!(canonical_float_text(left), canonical_float_text(right));
}
assert_ne!(canonical_float_text("0.5"), canonical_float_text("0.6"));
}
#[test]
fn fixed_point_equality_is_never_coarser_than_int_equality() {
const TEXTS: &[&str] = &[
"0", "0.0", "0.5", "0.50", "0.6", "1", "1.0", "1.000", "1.5", "2", "-1", "-1.0",
"-1.5", "10", "007", ".5", "0.1234", "0.1239", "0.9999",
];
for left in TEXTS {
for right in TEXTS {
let (Some(left_fixed), Some(right_fixed)) =
(script_fixed_point(left), script_fixed_point(right))
else {
continue;
};
if left_fixed != right_fixed {
continue;
}
let (Some(left_int), Some(right_int)) = (script_int(left), script_int(right))
else {
continue;
};
assert_eq!(
left_int, right_int,
"`{left}` and `{right}` are one fixed-point value but two integers"
);
}
}
}
}