// Python number semantics on top of core `Json`.
//
// Core `Json::Number(Double, repr~)` has no int/float distinction. This port
// uses the `repr` field as a marker: a number is a Python *float* when its
// `repr` looks like a float literal (contains `.`, `e`, `E`, `Infinity` or
// `NaN`) or when its value is not integral; otherwise it is a Python *int*.
// `loads` and the builtin functions that produce floats (`avg`,
// `to_number('1.5')`, ...) attach a `repr` equal to Python's `repr(float)`,
// so `to_string`/`json.dumps` output matches upstream. Numbers coming from
// `@json.parse` carry no repr, so an integral `1.0` there is seen as `1`.
///|
fn repr_is_float(repr : String) -> Bool {
for c in repr {
if c is ('.' | 'e' | 'E' | 'I' | 'N') {
return true
}
}
false
}
///|
/// Whether a number is a Python `float` (see the module comment).
fn number_is_float(d : Double, repr : String?) -> Bool {
match repr {
Some(r) if repr_is_float(r) => true
_ => d.is_nan() || d.is_inf() || d.floor() != d
}
}
///|
/// A Python float as a `Json` number (with the repr marker).
fn make_float(d : Double) -> Json {
let repr = if d.is_nan() {
"NaN"
} else if d.is_pos_inf() {
"Infinity"
} else if d.is_neg_inf() {
"-Infinity"
} else {
py_float_repr(d)
}
Json::number(d, repr~)
}
///|
/// A Python int as a `Json` number.
fn make_int(d : Double) -> Json {
Json::number(d)
}
///|
/// Shortest round-trip decimal digits of a finite, non-zero, positive double
/// and the position of the decimal point (`value = 0.DIGITS * 10^decpt`).
fn shortest_digits(d : Double) -> (String, Int) {
let s = d.to_string()
let (mantissa, exp) = match s.find("e") {
Some(i) => {
let m = s.view(end_offset=i).to_owned()
let e = s.view(start_offset=i + 1).to_owned()
let e = if e.has_prefix("+") {
e.view(start_offset=1).to_owned()
} else {
e
}
(m, @string.parse_int(e) catch { _ => 0 })
}
None => (s, 0)
}
let (int_part, frac_part) = match mantissa.find(".") {
Some(i) =>
(
mantissa.view(end_offset=i).to_owned(),
mantissa.view(start_offset=i + 1).to_owned(),
)
None => (mantissa, "")
}
let all = int_part + frac_part
let mut decpt = int_part.length() + exp
let chars = all.to_array()
let mut start = 0
while start < chars.length() && chars[start] == '0' {
start += 1
decpt -= 1
}
let mut end = chars.length()
while end > start && chars[end - 1] == '0' {
end -= 1
}
(String::from_array(chars[start:end]), decpt)
}
///|
/// Python's `repr(float)` (`float_repr_style == 'short'`).
fn py_float_repr(d : Double) -> String {
if d.is_nan() {
return "nan"
}
if d.is_pos_inf() {
return "inf"
}
if d.is_neg_inf() {
return "-inf"
}
if d == 0.0 {
return if (1.0 / d).is_neg_inf() { "-0.0" } else { "0.0" }
}
let sign = if d < 0.0 { "-" } else { "" }
let (digits, decpt) = shortest_digits(d.abs())
let n = digits.length()
let body = if decpt <= -4 || decpt > 16 {
// Exponential notation.
let exp = decpt - 1
let mant = if n == 1 {
digits
} else {
digits.view(end_offset=1).to_owned() +
"." +
digits.view(start_offset=1).to_owned()
}
let exp_sign = if exp < 0 { "-" } else { "+" }
let exp_abs = exp.abs()
let exp_str = if exp_abs < 10 { "0\{exp_abs}" } else { "\{exp_abs}" }
"\{mant}e\{exp_sign}\{exp_str}"
} else if decpt <= 0 {
"0." + "0".repeat(-decpt) + digits
} else if decpt >= n {
digits + "0".repeat(decpt - n) + ".0"
} else {
digits.view(end_offset=decpt).to_owned() +
"." +
digits.view(start_offset=decpt).to_owned()
}
sign + body
}
///|
/// `str(int)` of a Python int stored as a double.
fn py_int_str(d : Double, repr : String?) -> String {
match repr {
Some(r) => return r
None => ()
}
if d.abs() < 1.0e18 {
return d.to_int64().to_string()
}
let sign = if d < 0.0 { "-" } else { "" }
let (digits, decpt) = shortest_digits(d.abs())
sign + digits + "0".repeat(decpt - digits.length())
}
///|
/// `str()` / `repr()` of a number (`inf`, `nan` for non-finite floats).
fn py_number_str(d : Double, repr : String?) -> String {
if number_is_float(d, repr) {
py_float_repr(d)
} else {
py_int_str(d, repr)
}
}
///|
/// `json.dumps` of a number.
fn py_number_dumps(d : Double, repr : String?) -> String {
if d.is_nan() {
"NaN"
} else if d.is_pos_inf() {
"Infinity"
} else if d.is_neg_inf() {
"-Infinity"
} else {
py_number_str(d, repr)
}
}
///|
/// Python's `str.isspace()` for a single character.
fn py_isspace(c : Char) -> Bool {
match c.to_int() {
0x09..=0x0d | 0x1c..=0x20 | 0x85 | 0xa0 | 0x1680 => true
0x2000..=0x200a | 0x2028 | 0x2029 | 0x202f | 0x205f | 0x3000 => true
_ => false
}
}
///|
/// The whitespace `int()` and `float()` strip. CPython first maps
/// non-ASCII whitespace to ' ' (`_PyUnicode_TransformDecimalAndSpaceToASCII`)
/// and then strips ASCII whitespace only (`Py_ISSPACE`), so e.g. U+001C,
/// which `str.isspace()` accepts, is rejected.
fn py_numeric_isspace(c : Char) -> Bool {
match c.to_int() {
0x09..=0x0d | 0x20 => true
code if code >= 127 => py_isspace(c)
_ => false
}
}
///|
/// Strips whitespace like `int()` / `float()` (see `py_numeric_isspace`).
fn py_numeric_strip(s : String) -> String {
let chars = s.to_array()
let mut start = 0
while start < chars.length() && py_numeric_isspace(chars[start]) {
start += 1
}
let mut end = chars.length()
while end > start && py_numeric_isspace(chars[end - 1]) {
end -= 1
}
String::from_array(chars[start:end])
}
///|
/// Python's `str.lstrip()`.
fn py_lstrip(s : String) -> String {
let chars = s.to_array()
let mut start = 0
while start < chars.length() && py_isspace(chars[start]) {
start += 1
}
String::from_array(chars[start:])
}
///|
/// Checks `digit (_? digit)*` starting at `i`; returns the end index, or
/// `None` if there is no valid digit group at `i`.
fn scan_py_digits(chars : Array[Char], i : Int) -> Int? {
if i >= chars.length() || !(chars[i] is ('0'..='9')) {
return None
}
let mut j = i + 1
while j < chars.length() {
if chars[j] is ('0'..='9') {
j += 1
} else if chars[j] == '_' &&
j + 1 < chars.length() &&
chars[j + 1] is ('0'..='9') {
j += 2
} else {
break
}
}
Some(j)
}
///|
/// Python's `int(s)` for strings (base 10). Returns `None` on `ValueError`.
fn py_int_parse(s : String) -> Json? {
let chars = py_numeric_strip(s).to_array()
let mut i = 0
let mut negative = false
if i < chars.length() && chars[i] is ('+' | '-') {
negative = chars[i] == '-'
i += 1
}
guard scan_py_digits(chars, i) is Some(end) && end == chars.length() else {
return None
}
let digits = StringBuilder()
for c in chars[i:end] {
if c != '_' {
digits.write_char(c)
}
}
// Normalize leading zeros like Python's int().
let mut text = digits.to_string()
while text.length() > 1 && text.has_prefix("0") {
text = text.view(start_offset=1).to_owned()
}
let d = @string.parse_double(text) catch { _ => @double.infinity }
let d = if negative { -d } else { d }
let text = if negative && text != "0" { "-" + text } else { text }
// Keep the exact digits for ints that a double cannot represent exactly.
if text.length() > 15 {
Some(Json::number(d, repr=text))
} else {
Some(make_int(d))
}
}
///|
/// Python's `float(s)` for strings. Returns `None` on `ValueError`.
fn py_float_parse(s : String) -> Json? {
let stripped = py_numeric_strip(s)
let chars = stripped.to_array()
let mut i = 0
let mut negative = false
if i < chars.length() && chars[i] is ('+' | '-') {
negative = chars[i] == '-'
i += 1
}
let rest = String::from_array(chars[i:]).to_lower()
if rest is ("inf" | "infinity") {
return Some(
make_float(if negative { @double.neg_infinity } else { @double.infinity }),
)
}
if rest == "nan" {
return Some(make_float(@double.not_a_number))
}
// [digits] [. [digits]] [e [sign] digits], at least one mantissa digit.
let buf = StringBuilder()
let mut has_digits = false
let mut j = i
match scan_py_digits(chars, j) {
Some(end) => {
has_digits = true
for c in chars[j:end] {
if c != '_' {
buf.write_char(c)
}
}
j = end
}
None => buf.write_char('0')
}
if j < chars.length() && chars[j] == '.' {
j += 1
buf.write_char('.')
match scan_py_digits(chars, j) {
Some(end) => {
has_digits = true
for c in chars[j:end] {
if c != '_' {
buf.write_char(c)
}
}
j = end
}
None => buf.write_char('0')
}
}
if !has_digits {
return None
}
let mut exp_positive = true
if j < chars.length() && chars[j] is ('e' | 'E') {
buf.write_char('e')
j += 1
if j < chars.length() && chars[j] is ('+' | '-') {
exp_positive = chars[j] == '+'
buf.write_char(chars[j])
j += 1
}
match scan_py_digits(chars, j) {
Some(end) => {
for c in chars[j:end] {
if c != '_' {
buf.write_char(c)
}
}
j = end
}
None => return None
}
}
if j != chars.length() {
return None
}
let d = @string.parse_double(buf.to_string()) catch {
// Only overflow/underflow can fail after the syntax check above.
_ => if exp_positive { @double.infinity } else { 0.0 }
}
Some(make_float(if negative { -d } else { d }))
}