geam 0.1.1

Experimental Rust-embedded execution runtime for typed Gleam programs
use super::super::schema::{Direction, StringList};
use super::StringProvider;
use crate::gleam_stdlib::GleamStdlibHostProfile;
use crate::{HostCall, HostCallCompletion, HostCallError, HostCustom, HostFailure};
use ecow::EcoString;
use num_bigint::{BigInt, Sign};
use num_traits::ToPrimitive;
use unicode_segmentation::UnicodeSegmentation;

pub(in crate::gleam_stdlib::string) fn grapheme_slice(
    string: EcoString,
    index: BigInt,
    length: BigInt,
) -> Result<EcoString, HostFailure> {
    if index.sign() == Sign::Minus || length.sign() == Sign::Minus {
        return Err(HostFailure::new(
            "string grapheme slice requires non-negative bounds",
        ));
    }
    let Some(index) = index.to_usize() else {
        return Ok(EcoString::new());
    };
    let length = length.to_usize().unwrap_or(usize::MAX);
    Ok(string
        .graphemes(true)
        .skip(index)
        .take(length)
        .collect::<String>()
        .into())
}

pub(in crate::gleam_stdlib::string) fn unsafe_byte_slice(
    string: EcoString,
    index: BigInt,
    length: BigInt,
) -> Result<EcoString, HostFailure> {
    let index = index
        .to_usize()
        .ok_or_else(|| HostFailure::new("string byte slice index is not representable"))?;
    let length = length
        .to_usize()
        .ok_or_else(|| HostFailure::new("string byte slice length is not representable"))?;
    let end = index
        .checked_add(length)
        .ok_or_else(|| HostFailure::new("string byte slice range is not representable"))?;
    string
        .get(index..end)
        .map(EcoString::from)
        .ok_or_else(|| HostFailure::new("string byte slice is outside UTF-8 boundaries"))
}

pub(in crate::gleam_stdlib::string) fn erl_split<'call, Profile>(
    call: HostCall<'call, Profile, StringProvider<Profile>, StringList>,
    string: EcoString,
    pattern: EcoString,
) -> Result<HostCallCompletion<'call, StringList>, HostCallError>
where
    Profile: GleamStdlibHostProfile,
{
    let parts = match string.split_once(pattern.as_str()) {
        Some((first, rest)) if !pattern.is_empty() => {
            vec![EcoString::from(first), EcoString::from(rest)]
        }
        _ => vec![string],
    };
    Ok(call.return_list(parts))
}

pub(in crate::gleam_stdlib::string) fn erl_trim<'call, Profile>(
    call: HostCall<'call, Profile, StringProvider<Profile>, EcoString>,
    string: EcoString,
    direction: HostCustom<'call, Direction>,
) -> Result<HostCallCompletion<'call, EcoString>, HostCallError>
where
    Profile: GleamStdlibHostProfile,
{
    let value = if call.custom_constructor(direction) == 0 {
        string.trim_start_matches(is_pattern_whitespace)
    } else {
        string.trim_end_matches(is_pattern_whitespace)
    };
    Ok(call.return_value(value.into()))
}

fn is_pattern_whitespace(codepoint: char) -> bool {
    matches!(
        codepoint,
        '\u{0009}'
            ..='\u{000d}'
                | '\u{0020}'
                | '\u{0085}'
                | '\u{200e}'
                | '\u{200f}'
                | '\u{2028}'
                | '\u{2029}'
    )
}

#[cfg(test)]
mod tests {
    use super::{grapheme_slice, is_pattern_whitespace, unsafe_byte_slice};
    use num_bigint::BigInt;

    #[test]
    fn slices_graphemes_with_checked_unbounded_lengths() {
        assert_eq!(
            grapheme_slice("A👍🏽e\u{301}".into(), 1.into(), 1.into()),
            Ok("👍🏽".into()),
        );
        assert_eq!(
            grapheme_slice("abc".into(), BigInt::from(usize::MAX) + 1, 1.into()),
            Ok("".into()),
        );
        assert_eq!(
            grapheme_slice("abc".into(), 1.into(), BigInt::from(usize::MAX) + 1),
            Ok("bc".into()),
        );
        assert_eq!(
            grapheme_slice("abc".into(), (-1).into(), 1.into())
                .expect_err("negative index should violate the private source boundary")
                .message(),
            "string grapheme slice requires non-negative bounds",
        );
        assert_eq!(
            grapheme_slice("abc".into(), 1.into(), (-1).into())
                .expect_err("negative length should violate the private source boundary")
                .message(),
            "string grapheme slice requires non-negative bounds",
        );
    }

    #[test]
    fn checks_byte_ranges_and_utf8_boundaries() {
        assert_eq!(
            unsafe_byte_slice("a👍b".into(), 1.into(), 4.into()),
            Ok("👍".into()),
        );
        assert_eq!(
            unsafe_byte_slice("abc".into(), (-1).into(), 1.into())
                .expect_err("negative index should not be representable")
                .message(),
            "string byte slice index is not representable",
        );
        assert_eq!(
            unsafe_byte_slice("abc".into(), 0.into(), (-1).into())
                .expect_err("negative length should not be representable")
                .message(),
            "string byte slice length is not representable",
        );
        assert_eq!(
            unsafe_byte_slice("abc".into(), BigInt::from(usize::MAX), 1.into())
                .expect_err("overflowing range should not be representable")
                .message(),
            "string byte slice range is not representable",
        );
        assert_eq!(
            unsafe_byte_slice("👍".into(), 1.into(), 1.into())
                .expect_err("partial UTF-8 range should be rejected")
                .message(),
            "string byte slice is outside UTF-8 boundaries",
        );
    }

    #[test]
    fn uses_exact_pattern_whitespace() {
        for codepoint in [
            '\u{0009}', '\u{000a}', '\u{000b}', '\u{000c}', '\u{000d}', '\u{0020}', '\u{0085}',
            '\u{200e}', '\u{200f}', '\u{2028}', '\u{2029}',
        ] {
            assert!(is_pattern_whitespace(codepoint));
        }
        for codepoint in ['\u{00a0}', '\u{1680}', '\u{2000}', '\u{3000}', 'A'] {
            assert!(!is_pattern_whitespace(codepoint));
        }
    }
}