use regex::Regex;
use std::sync::OnceLock;
const WS_CLASS: &str = concat!(
"\\t\\n\\x0B\\x0C\\r",
" ",
"\\u{00A0}\\u{1680}\\u{2000}-\\u{200A}",
"\\u{2028}\\u{2029}\\u{202F}\\u{205F}\\u{3000}\\u{FEFF}",
);
fn js_ws_regex(template: &str) -> Regex {
let positive = format!("[{WS_CLASS}]");
let negative = format!("[^{WS_CLASS}]");
let pattern = template.replace(r"\s", &positive).replace(r"\S", &negative);
Regex::new(&pattern).expect("lexer regex compiles")
}
struct Regexes {
urls: Regex,
ids: Regex,
time: Regex,
number: Regex,
space: Regex,
email: Regex,
punctuation: Regex,
unblank: Regex,
}
fn regexes() -> &'static Regexes {
static REGEXES: OnceLock<Regexes> = OnceLock::new();
REGEXES.get_or_init(|| {
Regexes {
urls: Regex::new(
r"(?i)(?:https?://)(?:[0-9a-z.-]+)\.(?:[a-z.]{2,6})(?:[/0-9A-Za-z_.\-?#=&%]*)*/?",
)
.expect("urls regex compiles"),
ids: js_ws_regex(r"(?i)(?:^|\s)([a-z0-9-]{8,45})"),
time: js_ws_regex(r"(?i)(?:[0-9]|0[0-9]|1[0-9]|2[0-3]):(?:[0-5][0-9])\s?(?:[ap][m])"),
number: Regex::new(r"(?i)[0-9]*\.[0-9]+|[0-9]+").expect("number regex compiles"),
space: js_ws_regex(r"\s+"),
email: Regex::new(
r#"(?i)[-!#$%&'*+/0-9=?A-Z^_a-z{|}~](?:\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](?:-?\.?[a-zA-Z0-9])*(?:\.[a-zA-Z](?:-?[a-zA-Z0-9])*)+"#,
)
.expect("email regex compiles"),
punctuation: Regex::new(r#"[/.,?!"':;$()#’`]"#).expect("punctuation regex compiles"),
unblank: js_ws_regex(r"\S"),
}
})
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Lexer;
impl Lexer {
pub fn new() -> Self {
Lexer
}
pub fn lex(&self, string: &str) -> Vec<String> {
let re = regexes();
let order = [
SplitRule::new(&re.urls),
SplitRule::id(&re.ids),
SplitRule::new(&re.time),
SplitRule::new(&re.email),
SplitRule::new(&re.number),
SplitRule::new(&re.space),
SplitRule::new(&re.punctuation),
];
let root = LexerNode::new(string, order[0], &order[1..]);
let mut out = Vec::new();
root.fill_array(&mut out, &re.unblank);
out
}
}
#[derive(Clone, Copy)]
struct SplitRule<'a> {
regex: &'a Regex,
id_capture: bool,
}
impl<'a> SplitRule<'a> {
fn new(regex: &'a Regex) -> Self {
Self {
regex,
id_capture: false,
}
}
fn id(regex: &'a Regex) -> Self {
Self {
regex,
id_capture: true,
}
}
}
enum LexerNode {
Branch {
matches: Vec<String>,
children: Vec<LexerNode>,
},
Leaf {
matches: Vec<String>,
gaps: Vec<String>,
},
}
impl LexerNode {
fn new(string: &str, rule: SplitRule<'_>, rest: &[SplitRule<'_>]) -> LexerNode {
let string = js_trim(string);
let (matches, gaps) = if string.is_empty() {
(Vec::new(), vec![string])
} else {
let (matches, gaps) = split_with_rule(rule, &string);
if matches.is_empty() {
(Vec::new(), vec![string])
} else {
(matches, gaps)
}
};
if rest.is_empty() {
LexerNode::Leaf { matches, gaps }
} else {
let next = rest[0];
let next_rest = &rest[1..];
let children = gaps
.iter()
.map(|gap| LexerNode::new(gap, next, next_rest))
.collect();
LexerNode::Branch { matches, children }
}
}
fn fill_array(&self, out: &mut Vec<String>, unblank: &Regex) {
match self {
LexerNode::Branch { matches, children } => {
for (i, child) in children.iter().enumerate() {
child.fill_array(out, unblank);
if i < matches.len() && unblank.is_match(&matches[i]) {
out.push(js_trim(&matches[i]));
}
}
}
LexerNode::Leaf { matches, gaps } => {
for (i, gap) in gaps.iter().enumerate() {
if unblank.is_match(gap) {
out.push(js_trim(gap));
}
if i < matches.len() && unblank.is_match(&matches[i]) {
out.push(js_trim(&matches[i]));
}
}
}
}
}
}
fn split_with_rule(rule: SplitRule<'_>, s: &str) -> (Vec<String>, Vec<String>) {
if rule.id_capture {
split_id_matches(rule.regex, s)
} else {
let matches: Vec<String> = rule
.regex
.find_iter(s)
.map(|m| m.as_str().to_string())
.collect();
let gaps = rule.regex.split(s).map(|piece| piece.to_string()).collect();
(matches, gaps)
}
}
fn split_id_matches(regex: &Regex, s: &str) -> (Vec<String>, Vec<String>) {
let mut matches = Vec::new();
let mut gaps = Vec::new();
let mut last_end = 0;
for capture in regex.captures_iter(s) {
let Some(body) = capture.get(1) else {
continue;
};
if !is_id_boundary(s, body.end()) {
continue;
}
gaps.push(s[last_end..body.start()].to_string());
matches.push(body.as_str().to_string());
last_end = body.end();
}
if matches.is_empty() {
(matches, Vec::new())
} else {
gaps.push(s[last_end..].to_string());
(matches, gaps)
}
}
fn is_id_boundary(s: &str, end: usize) -> bool {
let mut chars = s[end..].chars();
match chars.next() {
None => true,
Some(c) if is_js_whitespace(c) => true,
Some('.') => !matches!(chars.next(), Some(next) if next.is_ascii_digit()),
Some(c) => is_id_punctuation(c),
}
}
fn is_id_punctuation(c: char) -> bool {
matches!(
c,
'/' | ',' | '?' | '!' | '"' | '\'' | ':' | ';' | '$' | '(' | ')' | '#' | '’' | '`'
)
}
fn js_trim(s: &str) -> String {
s.trim_matches(is_js_whitespace).to_string()
}
fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\u{0009}'
| '\u{000A}'
| '\u{000B}'
| '\u{000C}'
| '\u{000D}'
| '\u{0020}'
| '\u{00A0}'
| '\u{1680}'
| '\u{2000}'
..='\u{200A}'
| '\u{2028}'
| '\u{2029}'
| '\u{202F}'
| '\u{205F}'
| '\u{3000}'
| '\u{FEFF}'
)
}