Files
usenet-indexer/edge/src/js.rs
T
Daan MeijerandClaude Opus 5.5 5109481d67
Test and deploy / test (push) Successful in 46s
Test and deploy / edge (push) Successful in 1m54s
Test and deploy / deploy (push) Successful in 2m56s
The Rust worker is the product: edge/, built and checked in CI
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>
2026-10-11 20:01:18 +02:00

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);
}
}