Skip to main content

yapi_types/
js.rs

1//! JavaScript string and number semantics that pi's texts depend on: string
2//! lengths and slices count UTF-16 code units, and `toFixed` and `Math.round`
3//! round halves up.
4
5/// `text.length`.
6pub fn len(text: &str) -> usize {
7    text.encode_utf16().count()
8}
9
10/// The UTF-16 index of byte offset `byte` in `text`, as JavaScript indexes
11/// strings; an offset inside a character counts from its start.
12pub fn utf16_index(text: &str, byte: usize) -> usize {
13    len(&text[..text.floor_char_boundary(byte)])
14}
15
16/// The byte offset of UTF-16 index `units` in `text`: the start of the
17/// first character at or after it, or the end of the text.
18pub fn byte_index(text: &str, units: usize) -> usize {
19    let mut count = 0;
20    for (index, c) in text.char_indices() {
21        if count >= units {
22            return index;
23        }
24        count += c.len_utf16();
25    }
26    text.len()
27}
28
29/// `text.slice(start, end)` with indices in UTF-16 code units, clamped to the
30/// text. A surrogate pair cut in half becomes U+FFFD.
31pub fn slice(text: &str, start: usize, end: usize) -> String {
32    let units: Vec<u16> = text.encode_utf16().collect();
33    let end = end.min(units.len());
34    let start = start.min(end);
35    String::from_utf16_lossy(&units[start..end])
36}
37
38/// `TextDecoder.decode(bytes, {stream: true})`: appends `bytes` to `pending`
39/// and decodes what is complete. A character cut off at the end stays
40/// pending; invalid bytes become U+FFFD.
41pub fn decode_utf8_stream(pending: &mut Vec<u8>, bytes: &[u8]) -> String {
42    pending.extend_from_slice(bytes);
43    let valid = match std::str::from_utf8(pending) {
44        Ok(text) => text.len(),
45        Err(error) if error.error_len().is_none() => error.valid_up_to(),
46        Err(_) => pending.len(),
47    };
48    let text = String::from_utf8_lossy(&pending[..valid]).into_owned();
49    pending.drain(..valid);
50    text
51}
52
53/// `Math.round(value)`.
54pub fn round(value: f64) -> f64 {
55    (value + 0.5).floor()
56}
57
58/// `value.toFixed(digits)`: the exact value rounded half up.
59pub fn to_fixed(value: f64, digits: usize) -> String {
60    if !value.is_finite() {
61        return value.to_string();
62    }
63    if value < 0.0 {
64        let positive = to_fixed(-value, digits);
65        return if positive.bytes().all(|byte| matches!(byte, b'0' | b'.')) {
66            positive
67        } else {
68            format!("-{positive}")
69        };
70    }
71    let wide = format!("{value:.40}");
72    let (whole, fraction) = wide.split_once('.').unwrap_or((&wide, ""));
73    let mut number: Vec<u8> = whole
74        .bytes()
75        .chain(fraction.bytes().chain(std::iter::repeat(b'0')).take(digits))
76        .collect();
77    if fraction
78        .as_bytes()
79        .get(digits)
80        .is_some_and(|digit| *digit >= b'5')
81    {
82        let mut index = number.len();
83        loop {
84            if index == 0 {
85                number.insert(0, b'1');
86                break;
87            }
88            index -= 1;
89            if number[index] == b'9' {
90                number[index] = b'0';
91            } else {
92                number[index] += 1;
93                break;
94            }
95        }
96    }
97    let text = String::from_utf8_lossy(&number).into_owned();
98    if digits == 0 {
99        return text;
100    }
101    let split = text.len() - digits;
102    format!("{}.{}", &text[..split], &text[split..])
103}
104
105/// Node's code and description for an I/O error of `kind`, such as
106/// `("ENOENT", "no such file or directory")`.
107pub fn errno(kind: std::io::ErrorKind) -> Option<(&'static str, &'static str)> {
108    use std::io::ErrorKind;
109    Some(match kind {
110        ErrorKind::NotFound => ("ENOENT", "no such file or directory"),
111        ErrorKind::PermissionDenied => ("EACCES", "permission denied"),
112        ErrorKind::AlreadyExists => ("EEXIST", "file already exists"),
113        ErrorKind::IsADirectory => ("EISDIR", "illegal operation on a directory"),
114        ErrorKind::NotADirectory => ("ENOTDIR", "not a directory"),
115        ErrorKind::DirectoryNotEmpty => ("ENOTEMPTY", "directory not empty"),
116        ErrorKind::InvalidInput => ("EINVAL", "invalid argument"),
117        ErrorKind::ReadOnlyFilesystem => ("EROFS", "read-only file system"),
118        _ => return None,
119    })
120}
121
122/// The message Node gives a failed file system call, such as
123/// `ENOENT: no such file or directory, access '/a/b'`.
124pub fn node_error(err: &std::io::Error, syscall: &str, path: &std::path::Path) -> String {
125    let path = path.display();
126    match errno(err.kind()) {
127        // Node names no path for a read, as `readFile` reports it.
128        Some(("EISDIR", text)) if syscall == "read" => format!("EISDIR: {text}, read"),
129        Some((code, text)) => format!("{code}: {text}, {syscall} '{path}'"),
130        None => format!("{err}, {syscall} '{path}'"),
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn byte_utf16_indices() {
140        let text = "é😀x";
141        assert_eq!(utf16_index(text, 2), 1);
142        assert_eq!(utf16_index(text, 6), 3);
143        assert_eq!(byte_index(text, 3), 6);
144        assert_eq!(byte_index(text, 9), text.len());
145    }
146
147    #[test]
148    fn io_errors_like_node() {
149        let error = |kind| std::io::Error::from(kind);
150        let path = std::path::Path::new("/a/b");
151        assert_eq!(
152            node_error(&error(std::io::ErrorKind::NotFound), "open", path),
153            "ENOENT: no such file or directory, open '/a/b'"
154        );
155        assert_eq!(
156            node_error(&error(std::io::ErrorKind::IsADirectory), "read", path),
157            "EISDIR: illegal operation on a directory, read"
158        );
159        assert_eq!(
160            node_error(&error(std::io::ErrorKind::AlreadyExists), "mkdir", path),
161            "EEXIST: file already exists, mkdir '/a/b'"
162        );
163        assert_eq!(
164            node_error(&error(std::io::ErrorKind::ReadOnlyFilesystem), "open", path),
165            "EROFS: read-only file system, open '/a/b'"
166        );
167        assert_eq!(
168            node_error(&error(std::io::ErrorKind::DirectoryNotEmpty), "rmdir", path),
169            "ENOTEMPTY: directory not empty, rmdir '/a/b'"
170        );
171        assert_eq!(
172            errno(std::io::ErrorKind::InvalidInput),
173            Some(("EINVAL", "invalid argument"))
174        );
175        assert_eq!(errno(std::io::ErrorKind::Other), None);
176    }
177
178    #[test]
179    fn counts_slices_utf16() {
180        assert_eq!(len("a😀"), 3);
181        assert_eq!(slice("a😀b", 0, 3), "a😀");
182        assert_eq!(slice("a😀b", 0, 2), "a\u{fffd}");
183        assert_eq!(slice("abc", 2, 10), "c");
184    }
185
186    #[test]
187    fn decodes_utf8_streams() {
188        let mut pending = Vec::new();
189        let euro = "€".as_bytes();
190        assert_eq!(decode_utf8_stream(&mut pending, &euro[..2]), "");
191        assert_eq!(decode_utf8_stream(&mut pending, &euro[2..]), "€");
192        assert_eq!(decode_utf8_stream(&mut pending, b"a\xffb"), "a\u{fffd}b");
193        assert!(pending.is_empty());
194    }
195
196    #[test]
197    fn rounds_like_javascript() {
198        assert_eq!(to_fixed(0.05, 1), "0.1");
199        assert_eq!(to_fixed(1.25, 1), "1.3");
200        assert_eq!(to_fixed(1.005, 2), "1.00");
201        assert_eq!(to_fixed(9.96, 1), "10.0");
202        assert_eq!(to_fixed(0.0, 1), "0.0");
203        assert_eq!(to_fixed(2.5, 0), "3");
204        assert_eq!(to_fixed(-1.25, 1), "-1.3");
205        assert_eq!(round(2.5), 3.0);
206        assert_eq!(round(-2.5), -2.0);
207    }
208}