Programming Language

Nocter

A self-contained systems language built around simplicity, encapsulation, and foolproof design.

/development/std/json/number.nct

number.nct

//! Strict JSON number scanning, exact storage, and integer projection.

see ./index.nct
see ./cursor.nct
see ./errors.nct
see ./failure.nct

use /internal/mem.allocation_abort
use /mem.{TryAllocator, current_try_allocator}
use /string.String

copy struct NumberShape {
    negative: bool
    total_digits: usize
    trailing_zeroes: usize
    scale_negative: bool
    scale: usize
}

struct Number {
    source: String
    shape: NumberShape
}

copy struct DigitSummary {
    total: usize
    trailing_zeroes: usize
}

copy struct Scale {
    negative: bool
    amount: usize
}

func capped_scale_add(left: usize, right: usize): usize {
    if left >= 21 || right >= 21 || left > 21 - right {
        return 21
    }
    return left + right
}

func normalized_scale(
    fraction_digits: usize,
    trailing_zeroes: usize,
    exponent_negative: bool,
    exponent: usize,
): Scale {
    if exponent_negative {
        if trailing_zeroes < fraction_digits {
            return Scale { negative: true, amount: 0 }
        }
        let available = trailing_zeroes - fraction_digits
        if available < exponent {
            return Scale { negative: true, amount: 0 }
        }
        return Scale { negative: false, amount: available - exponent }
    }

    if trailing_zeroes >= fraction_digits {
        return Scale {
            negative: false,
            amount: capped_scale_add(exponent, trailing_zeroes - fraction_digits),
        }
    }
    let deficit = fraction_digits - trailing_zeroes
    if exponent < deficit {
        return Scale { negative: true, amount: 0 }
    }
    return Scale { negative: false, amount: exponent - deficit }
}

func record_digit(
    digit: u8,
    summary: &+DigitSummary,
): void {
    summary.total += 1
    if digit == 48 {
        summary.trailing_zeroes += 1
    } else {
        summary.trailing_zeroes = 0
    }
    return
}

func is_digit(byte: u8): bool {
    return byte >= 48 && byte <= 57
}

func scan_number(cursor: &+Cursor): NumberShape? {
    var negative = false
    if cursor.next_is(45) {
        negative = true
        let _minus = cursor.advance() otherwise { return none }
    }

    var summary = DigitSummary { total: 0, trailing_zeroes: 0 }
    let first = cursor.peek() otherwise { return none }
    if first == 48 {
        let digit = cursor.advance() otherwise { return none }
        record_digit(digit, &+summary)
    } else if first >= 49 && first <= 57 {
        while true {
            let digit = cursor.peek() otherwise { break }
            if !is_digit(digit) { break }
            let consumed = cursor.advance() otherwise { return none }
            record_digit(consumed, &+summary)
        }
    } else {
        return none
    }

    var fraction_digits: usize = 0
    if cursor.next_is(46) {
        let _point = cursor.advance() otherwise { return none }
        let first_fraction = cursor.peek() otherwise { return none }
        if !is_digit(first_fraction) { return none }
        while true {
            let digit = cursor.peek() otherwise { break }
            if !is_digit(digit) { break }
            let consumed = cursor.advance() otherwise { return none }
            record_digit(consumed, &+summary)
            fraction_digits += 1
        }
    }

    var exponent_negative = false
    var exponent: usize = 0
    if cursor.next_is(101) || cursor.next_is(69) {
        let _marker = cursor.advance() otherwise { return none }
        if cursor.next_is(43) || cursor.next_is(45) {
            exponent_negative = cursor.next_is(45)
            let _sign = cursor.advance() otherwise { return none }
        }
        let first_exponent = cursor.peek() otherwise { return none }
        if !is_digit(first_exponent) { return none }
        var exponent_cap: usize = 21
        if cursor.len() > exponent_cap {
            exponent_cap = cursor.len()
        }
        while true {
            let digit = cursor.peek() otherwise { break }
            if !is_digit(digit) { break }
            let consumed = cursor.advance() otherwise { return none }
            let value = (consumed - 48) as usize
            if exponent <= (exponent_cap - value) / 10 {
                exponent = exponent * 10 + value
            } else {
                exponent = exponent_cap
            }
        }
    }

    if summary.trailing_zeroes == summary.total {
        return NumberShape {
            negative: negative,
            total_digits: summary.total,
            trailing_zeroes: summary.trailing_zeroes,
            scale_negative: false,
            scale: 0,
        }
    }
    let scale = normalized_scale(
        fraction_digits,
        summary.trailing_zeroes,
        exponent_negative,
        exponent,
    )
    return NumberShape {
        negative: negative,
        total_digits: summary.total,
        trailing_zeroes: summary.trailing_zeroes,
        scale_negative: scale.negative,
        scale: scale.amount,
    }
}

func parse_number_from_cursor(
    allocator: &+TryAllocator,
    cursor: &+Cursor,
): Attempt<Number> {
    let start = cursor.offset()
    let shape = scan_number(cursor) otherwise {
        return invalid_syntax(allocator, cursor.offset())
    }
    let token = cursor.range(start, cursor.offset()) otherwise {
        return invalid_syntax(allocator, cursor.offset())
    }
    let owned = String.try_copy(allocator, token) catch failure {
        return Attempt.allocation(move failure)
    }
    return Attempt.success(Number { source: move owned, shape: shape })
}

func try_parse_number(
    allocator: &+TryAllocator,
    text: &str,
): Attempt<Number> {
    var cursor = Cursor.new(text)
    let attempt = parse_number_from_cursor(allocator, &+cursor)
    match move attempt {
        Attempt.success(value) {
            if !cursor.is_finished() {
                return invalid_syntax(allocator, cursor.offset())
            }
            return Attempt.success(move value)
        }
        Attempt.input(failure) { return Attempt.input(move failure) }
        Attempt.allocation(failure) { return Attempt.allocation(move failure) }
    }
}

func parse_number(text: &str): Number! {
    var allocator = current_try_allocator()
    let attempt = try_parse_number(&+allocator, text)
    match move attempt {
        Attempt.success(value) { return move value }
        Attempt.input(failure) { return move failure }
        Attempt.allocation(_) { return allocation_abort() }
    }
}

func parse_number_with(allocator: &+TryAllocator, text: &str): Number! from allocator {
    let attempt = try_parse_number(allocator, text)
    match move attempt {
        Attempt.success(value) { return move value }
        Attempt.input(failure) { return move failure }
        Attempt.allocation(failure) { return move failure }
    }
}

func magnitude(number: &Number, limit: u64): u64? {
    if number.shape.total_digits == number.shape.trailing_zeroes {
        return 0
    }
    if number.shape.scale_negative {
        return none
    }
    let retained_digits = number.shape.total_digits - number.shape.trailing_zeroes
    let bytes = number.source.bytes()
    var digit_index: usize = 0
    var byte_index: usize = 0
    var value: u64 = 0
    while byte_index < bytes.len() {
        let byte = bytes[byte_index]
        if byte == 101 || byte == 69 {
            break
        }
        if is_digit(byte) {
            if digit_index < retained_digits {
                let digit = (byte - 48) as u64
                if value > (limit - digit) / 10 {
                    return none
                }
                value = value * 10 + digit
            }
            digit_index += 1
        }
        byte_index += 1
    }
    var scale: usize = 0
    while scale < number.shape.scale {
        if value > limit / 10 {
            return none
        }
        value *= 10
        scale += 1
    }
    return value
}

func number_as_u64(number: &Number): u64? {
    if number.shape.total_digits == number.shape.trailing_zeroes {
        return 0
    }
    if number.shape.negative {
        return none
    }
    return magnitude(number, 18446744073709551615)?
}

func number_as_i64(number: &Number): i64? {
    if number.shape.total_digits == number.shape.trailing_zeroes {
        return 0
    }
    if number.shape.scale_negative {
        return none
    }
    let retained_digits = number.shape.total_digits - number.shape.trailing_zeroes
    let bytes = number.source.bytes()
    var digit_index: usize = 0
    var byte_index: usize = 0
    var value: i64 = 0
    while byte_index < bytes.len() {
        let byte = bytes[byte_index]
        if byte == 101 || byte == 69 { break }
        if is_digit(byte) {
            if digit_index < retained_digits {
                let digit = (byte - 48) as i64
                if value < (-9223372036854775808 + digit) / 10 { return none }
                value = value * 10 - digit
            }
            digit_index += 1
        }
        byte_index += 1
    }
    var scale: usize = 0
    while scale < number.shape.scale {
        if value < -9223372036854775808 / 10 { return none }
        value *= 10
        scale += 1
    }
    if number.shape.negative { return value }
    if value == -9223372036854775808 { return none }
    return 0 - value
}

construct Number {
    func parse(text: &str): Self! {
        return parse_number(text)?
    }

    func try_parse(allocator: &+TryAllocator, text: &str): Self! from allocator {
        return parse_number_with(allocator, text)?
    }
}

instance Number {
    method &self.text(): &str {
        return &self.source
    }

    method &self.as_i64(): i64? {
        return number_as_i64(self)?
    }

    method &self.as_u64(): u64? {
        return number_as_u64(self)?
    }
}

func number_is_invalid(text: &str): bool {
    let _number = Number.parse(text) catch failure {
        return failure.has_code("std.json.invalid_syntax")
    }
    return false
}

func number_failure_message_matches(text: &str, expected: &str): bool {
    let _number = Number.parse(text) catch failure {
        return failure.message() == expected
    }
    return false
}

func number_projects_i64(text: &str): bool {
    let number = Number.parse(text) catch _ { return false }
    let _value = number.as_i64() otherwise { return false }
    return true
}

func number_projects_u64(text: &str): bool {
    let number = Number.parse(text) catch _ { return false }
    let _value = number.as_u64() otherwise { return false }
    return true
}

test number_preserves_exact_text_and_projects_integer_boundaries {
    let decimal = Number.parse("-12.3400e2")?
    let decimal_value = decimal.as_i64() otherwise {
        return error.new("std.json.number", "integral decimal did not project to i64")
    }
    if decimal.text() != "-12.3400e2" || decimal_value != -1234 {
        return error.new("std.json.number", "number text or normalized value changed")
    }

    let unsigned = Number.parse("18446744073709551615")?
    let unsigned_value = unsigned.as_u64() otherwise {
        return error.new("std.json.number", "u64 maximum was rejected")
    }
    if unsigned_value != 18446744073709551615 {
        return error.new("std.json.number", "u64 maximum changed")
    }

    let signed_minimum = Number.parse("-9223372036854775808")?
    let minimum_value = signed_minimum.as_i64() otherwise {
        return error.new("std.json.number", "i64 minimum was rejected")
    }
    if minimum_value != -9223372036854775808 {
        return error.new("std.json.number", "i64 minimum changed")
    }

    let signed_maximum = Number.parse("9223372036854775807")?
    let maximum_value = signed_maximum.as_i64() otherwise {
        return error.new("std.json.number", "i64 maximum was rejected")
    }
    if maximum_value != 9223372036854775807 {
        return error.new("std.json.number", "i64 maximum changed")
    }

    let exponent = Number.parse("184467440737095516150e-1")?
    let exponent_value = exponent.as_u64() otherwise {
        return error.new("std.json.number", "exact exponent projection was rejected")
    }
    if exponent_value != 18446744073709551615 {
        return error.new("std.json.number", "exact exponent projection changed")
    }
    return
}

test number_rejects_non_json_spelling_and_non_integral_projection {
    if !number_is_invalid("") || !number_is_invalid("01") || !number_is_invalid("+1") {
        return error.new("std.json.number", "non-JSON number spelling was accepted")
    }
    if !number_failure_message_matches("01", "invalid JSON syntax at byte 1") {
        return error.new("std.json.number", "number failure byte offset changed")
    }
    if !number_is_invalid("1.") || !number_is_invalid("1e") || !number_is_invalid("1e+") {
        return error.new("std.json.number", "incomplete JSON number was accepted")
    }
    if number_projects_u64("18446744073709551616") {
        return error.new("std.json.number", "out-of-range unsigned number projected to u64")
    }
    if number_projects_i64("9223372036854775808") {
        return error.new("std.json.number", "out-of-range signed number projected to i64")
    }
    if number_projects_i64("1e-999999999999999999999999999999") {
        return error.new("std.json.number", "tiny non-integral exponent projected to i64")
    }
    if number_projects_i64("1.25") {
        return error.new("std.json.number", "non-integral number projected to i64")
    }
    return
}