use regex::Regex;
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
#[derive(Clone, Copy)]
pub struct Timestamp {
#[cfg(not(target_arch = "wasm32"))]
instant: std::time::Instant,
#[cfg(target_arch = "wasm32")]
epoch_ms: f64,
}
pub fn now() -> Timestamp {
#[cfg(not(target_arch = "wasm32"))]
{
Timestamp {
instant: std::time::Instant::now(),
}
}
#[cfg(target_arch = "wasm32")]
{
Timestamp {
epoch_ms: js_sys::Date::now(),
}
}
}
pub fn elapsed_ms(ts: &Timestamp) -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
ts.instant.elapsed().as_secs_f64() * 1000.0
}
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now() - ts.epoch_ms).max(0.0)
}
}
pub fn now_epoch_ms() -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(target_arch = "wasm32")]
{
js_sys::Date::now() as u64
}
}
pub fn is_zero_width_or_formatting(c: char) -> bool {
matches!(
c,
'\u{00AD}' | '\u{180E}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{200E}' | '\u{200F}' | '\u{202A}' | '\u{202B}' | '\u{202C}' | '\u{202D}' | '\u{202E}' | '\u{2060}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' | '\u{FEFF}' )
}
pub fn truncate_to_char_boundary(text: &str, max_bytes: usize) -> (&str, bool) {
if text.len() <= max_bytes {
return (text, false);
}
let mut end = max_bytes.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end = end.saturating_sub(1);
}
(&text[..end], end < text.len())
}
pub fn compile_hardcoded_regex(pattern: &'static str) -> Regex {
Regex::new(pattern).unwrap_or_else(|err| {
tracing::error!(error = %err, %pattern, "failed to compile hardcoded regex");
Regex::new("a^").unwrap_or_else(|_| unreachable!())
})
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CanonicalizationStats {
pub scanned_bytes: usize,
pub truncated: bool,
pub nfkc_changed: bool,
pub casefold_changed: bool,
pub zero_width_stripped: usize,
pub whitespace_collapsed: bool,
pub canonical_bytes: usize,
}
pub fn canonicalize_for_detection(text: &str) -> (String, CanonicalizationStats) {
let mut stats = CanonicalizationStats {
scanned_bytes: text.len(),
..Default::default()
};
let nfkc: String = text.nfkc().collect();
stats.nfkc_changed = nfkc != text;
let folded: String = nfkc.chars().flat_map(|c| c.to_lowercase()).collect();
stats.casefold_changed = folded != nfkc;
let mut stripped = String::with_capacity(folded.len());
for c in folded.chars() {
if is_zero_width_or_formatting(c) {
stats.zero_width_stripped = stats.zero_width_stripped.saturating_add(1);
continue;
}
stripped.push(c);
}
let collapsed = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
stats.whitespace_collapsed = collapsed != stripped;
stats.canonical_bytes = collapsed.len();
(collapsed, stats)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_width_detection() {
assert!(is_zero_width_or_formatting('\u{200B}'));
assert!(is_zero_width_or_formatting('\u{FEFF}'));
assert!(!is_zero_width_or_formatting('a'));
assert!(!is_zero_width_or_formatting(' '));
}
#[test]
fn truncate_within_bounds() {
let (s, trunc) = truncate_to_char_boundary("hello", 10);
assert_eq!(s, "hello");
assert!(!trunc);
}
#[test]
fn truncate_respects_char_boundary() {
let text = "héllo";
let (s, trunc) = truncate_to_char_boundary(text, 2);
assert!(trunc);
assert!(s.is_char_boundary(s.len()));
}
#[test]
fn canonicalize_strips_zero_width() {
let input = "he\u{200B}llo";
let (canon, stats) = canonicalize_for_detection(input);
assert_eq!(canon, "hello");
assert_eq!(stats.zero_width_stripped, 1);
}
#[test]
fn canonicalize_collapses_whitespace() {
let input = "hello world";
let (canon, stats) = canonicalize_for_detection(input);
assert_eq!(canon, "hello world");
assert!(stats.whitespace_collapsed);
}
#[test]
fn compile_hardcoded_regex_works() {
let re = compile_hardcoded_regex(r"\bhello\b");
assert!(re.is_match("hello world"));
}
#[test]
fn compile_hardcoded_regex_invalid_returns_never_match() {
let re = compile_hardcoded_regex(r"(invalid[");
assert!(!re.is_match("anything"));
}
}