User: Rust is the product (option a). tools/edge-bench-rs moves to edge/
(crate edge; binaries edge-node, the worker, and edge-bench, the replay).
CI's new job edge runs cargo test and edge/check-fixtures.sh: both
RangeIndexers on three committed captures (edge/fixtures, 25k overview
lines each of a.b.models, a.b.ath and a.b.boneless in 5 chunks, 3.5 MB),
compared field by field with check.ts; deploy waits for it. The
TypeScript RangeIndexer stays as check.ts's reference. README: what it
is now; the benchmark findings as history.
Random names: looksRandom missed long tokens of capitals and digits
("51294P1CUKR2R35X", release 74539068, readable and a Newznab title).
12+ capitals and digits switching 6+ times between them are random
(COD4MW2REMASTERED switches 4 times, BEATLES1964REMASTER twice); both
implementations, identical on the fixtures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
8.1 KiB
Rust
258 lines
8.1 KiB
Rust
//! JavaScript semantics the TypeScript worker relies on: `\s` and `trim()`,
|
|
//! UTF-16 lengths, `parseInt`, `Date.parse` (for Usenet dates) and sha1 hex.
|
|
|
|
use sha1::{Digest, Sha1};
|
|
|
|
/// JS `\s` / `trim()` whitespace (WhiteSpace and LineTerminator).
|
|
pub fn is_space(c: char) -> bool {
|
|
matches!(
|
|
c,
|
|
'\t' | '\n' | '\u{0B}' | '\u{0C}' | '\r' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}'..='\u{200A}' | '\u{2028}' | '\u{2029}' | '\u{202F}' | '\u{205F}' | '\u{3000}' | '\u{FEFF}'
|
|
)
|
|
}
|
|
|
|
pub fn trim(s: &str) -> &str {
|
|
s.trim_matches(is_space)
|
|
}
|
|
|
|
/// `s.replace(/\s+/g, ' ')`.
|
|
pub fn collapse_spaces(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut in_space = false;
|
|
for c in s.chars() {
|
|
if is_space(c) {
|
|
if !in_space {
|
|
out.push(' ');
|
|
in_space = true;
|
|
}
|
|
} else {
|
|
out.push(c);
|
|
in_space = false;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `s.split(/\s+/)`: like JS, a leading or trailing run gives an empty word.
|
|
pub fn split_spaces(s: &str) -> Vec<&str> {
|
|
let mut out = Vec::new();
|
|
let mut start = 0;
|
|
let mut in_space = false;
|
|
let mut run_start = 0;
|
|
for (i, c) in s.char_indices() {
|
|
if is_space(c) {
|
|
if !in_space {
|
|
run_start = i;
|
|
in_space = true;
|
|
}
|
|
} else if in_space {
|
|
out.push(&s[start..run_start]);
|
|
start = i;
|
|
in_space = false;
|
|
}
|
|
}
|
|
if in_space {
|
|
out.push(&s[start..run_start]);
|
|
out.push("");
|
|
} else {
|
|
out.push(&s[start..]);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A string's `.length` in JavaScript (UTF-16 code units).
|
|
pub fn len16(s: &str) -> usize {
|
|
if s.is_ascii() {
|
|
s.len()
|
|
} else {
|
|
s.encode_utf16().count()
|
|
}
|
|
}
|
|
|
|
/// `Number.parseInt(s, 10)`, None for NaN.
|
|
pub fn parse_int(s: &str) -> Option<i64> {
|
|
let s = s.trim_start_matches(is_space);
|
|
let (neg, digits) = match s.as_bytes().first() {
|
|
Some(b'-') => (true, &s[1..]),
|
|
Some(b'+') => (false, &s[1..]),
|
|
_ => (false, s),
|
|
};
|
|
let mut n: i64 = 0;
|
|
let mut any = false;
|
|
for b in digits.bytes() {
|
|
if !b.is_ascii_digit() {
|
|
break;
|
|
}
|
|
n = n.wrapping_mul(10).wrapping_add((b - b'0') as i64);
|
|
any = true;
|
|
}
|
|
if any {
|
|
Some(if neg { -n } else { n })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Days since 1970-01-01 of a proleptic Gregorian date (month 1..=12; a day past the month's end runs on, as in JS).
|
|
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
|
let y = if m <= 2 { y - 1 } else { y };
|
|
let era = y.div_euclid(400);
|
|
let yoe = y - era * 400;
|
|
let mp = (m + 9) % 12;
|
|
let doy = (153 * mp + 2) / 5 + d - 1;
|
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
era * 146_097 + doe - 719_468
|
|
}
|
|
|
|
/// The usual Date headers: [Day, ]D[D] Mon YY[YY] HH:MM:SS (GMT|UTC|UT|±hhmm). None for anything else.
|
|
fn fast_date(s: &[u8]) -> Option<i64> {
|
|
let mut i = 0;
|
|
if s.len() > 4 && s[3] == b',' && s[..3].iter().all(u8::is_ascii_alphabetic) {
|
|
i = 4;
|
|
while s.get(i) == Some(&b' ') {
|
|
i += 1;
|
|
}
|
|
}
|
|
let num = |i: &mut usize, min: usize, max: usize| -> Option<i64> {
|
|
let start = *i;
|
|
while *i < s.len() && s[*i].is_ascii_digit() && *i - start < max {
|
|
*i += 1;
|
|
}
|
|
if *i - start < min || s.get(*i).is_some_and(u8::is_ascii_digit) {
|
|
return None;
|
|
}
|
|
Some(std::str::from_utf8(&s[start..*i]).ok()?.parse().ok()?)
|
|
};
|
|
let expect = |i: &mut usize, c: u8| -> Option<()> {
|
|
(s.get(*i) == Some(&c)).then(|| *i += 1)
|
|
};
|
|
let day = num(&mut i, 1, 2)?;
|
|
expect(&mut i, b' ')?;
|
|
const MONTHS: [&[u8]; 12] = [b"jan", b"feb", b"mar", b"apr", b"may", b"jun", b"jul", b"aug", b"sep", b"oct", b"nov", b"dec"];
|
|
let mon = s.get(i..i + 3)?;
|
|
let month = MONTHS.iter().position(|m| mon.iter().zip(m.iter()).all(|(a, b)| a.to_ascii_lowercase() == *b))? as i64 + 1;
|
|
i += 3;
|
|
expect(&mut i, b' ')?;
|
|
let year_start = i;
|
|
let mut year = num(&mut i, 2, 4)?;
|
|
match i - year_start {
|
|
2 => year += if year < 50 { 2000 } else { 1900 },
|
|
4 => {}
|
|
_ => return None,
|
|
}
|
|
expect(&mut i, b' ')?;
|
|
let hour = num(&mut i, 2, 2)?;
|
|
expect(&mut i, b':')?;
|
|
let minute = num(&mut i, 2, 2)?;
|
|
expect(&mut i, b':')?;
|
|
let second = num(&mut i, 2, 2)?;
|
|
expect(&mut i, b' ')?;
|
|
if day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59 {
|
|
return None;
|
|
}
|
|
let offset_minutes = match &s[i..] {
|
|
b"GMT" | b"UTC" | b"UT" => 0,
|
|
[sign @ (b'+' | b'-'), h1, h2, m1, m2] if [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit()) => {
|
|
let minutes = ((h1 - b'0') as i64 * 10 + (h2 - b'0') as i64) * 60 + (m1 - b'0') as i64 * 10 + (m2 - b'0') as i64;
|
|
if *sign == b'+' { minutes } else { -minutes }
|
|
}
|
|
_ => return None,
|
|
};
|
|
let days = days_from_civil(year, month, day);
|
|
Some((((days * 24 + hour) * 60 + minute - offset_minutes) * 60 + second) * 1000)
|
|
}
|
|
|
|
/// `Date.parse` for the Date header (RFC 5322 and the obsolete forms); None for NaN.
|
|
pub fn parse_date(s: &str) -> Option<i64> {
|
|
let s = trim(s);
|
|
if let Some(t) = fast_date(s.as_bytes()) {
|
|
return Some(t);
|
|
}
|
|
parse_date_chrono(s)
|
|
}
|
|
|
|
fn parse_date_chrono(s: &str) -> Option<i64> {
|
|
// "UTC" isn't an RFC 5322 zone (UT and GMT are), but Date.parse takes it (~a quarter of a.b.boneless).
|
|
let owned;
|
|
let s = match s.strip_suffix(" UTC") {
|
|
Some(rest) => {
|
|
owned = format!("{rest} GMT");
|
|
owned.as_str()
|
|
}
|
|
None => s,
|
|
};
|
|
if let Ok(d) = chrono::DateTime::parse_from_rfc2822(s) {
|
|
return Some(d.timestamp_millis());
|
|
}
|
|
// "Mon, 5 Oct 2026 10:00:00 +0000 (UTC)" and other trailing comments.
|
|
if let Some(i) = s.find(" (") {
|
|
if let Ok(d) = chrono::DateTime::parse_from_rfc2822(&s[..i]) {
|
|
return Some(d.timestamp_millis());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// sha1 of the UTF-8 text, as lowercase hex (node's `hash('sha1', text)`).
|
|
pub fn sha1_hex(text: &str) -> String {
|
|
let digest = Sha1::digest(text.as_bytes());
|
|
let mut out = String::with_capacity(40);
|
|
for b in digest {
|
|
out.push(char::from_digit((b >> 4) as u32, 16).unwrap());
|
|
out.push(char::from_digit((b & 15) as u32, 16).unwrap());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// JSON.stringify of a string.
|
|
pub fn json_str(s: &str, out: &mut String) {
|
|
out.push('"');
|
|
for c in s.chars() {
|
|
match c {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
'\u{08}' => out.push_str("\\b"),
|
|
'\u{0C}' => out.push_str("\\f"),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
out.push('"');
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn split_like_js() {
|
|
assert_eq!(split_spaces("a b"), vec!["a", "b"]);
|
|
assert_eq!(split_spaces(" a b "), vec!["", "a", "b", ""]);
|
|
assert_eq!(split_spaces(""), vec![""]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_int_like_js() {
|
|
assert_eq!(parse_int("123abc"), Some(123));
|
|
assert_eq!(parse_int(" -5"), Some(-5));
|
|
assert_eq!(parse_int("x"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn dates() {
|
|
assert_eq!(parse_date("Mon, 05 Oct 2026 10:00:00 +0000"), Some(1_791_194_400_000));
|
|
assert_eq!(parse_date("5 Oct 2026 10:00:00 GMT"), Some(1_791_194_400_000));
|
|
assert_eq!(parse_date("Mon, 05 Oct 26 10:00:00 UTC"), Some(1_791_194_400_000));
|
|
for d in [
|
|
"Mon, 05 Oct 2026 10:00:00 +0000", "Mon, 5 Oct 2026 12:00:00 +0200", "05 Oct 2026 10:00:00 GMT", "Thu, 01 Jan 1970 00:00:00 GMT",
|
|
"Sat, 29 Feb 2020 23:59:59 -0130", "Fri, 31 Dec 99 00:00:00 UTC", "Mon, 05 Oct 2026 10:00:00 UT",
|
|
] {
|
|
assert_eq!(fast_date(d.as_bytes()), parse_date_chrono(d), "{d}");
|
|
}
|
|
assert_eq!(fast_date(b"Mon, 05 Oct 2026 10:00:00 EST"), None);
|
|
}
|
|
}
|