use sim_lib_pattern::{TextClass, TextLimits, TextMatch, TextOp, run_text_pattern};
pub const JAVASCRIPT_REGEXP_SUCCESSOR: &str = "JAVA_SCRIPT_6 pattern-engine work";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum JavascriptRegExpGap {
Flags,
Alternation,
Groups,
Backreferences,
Lookaround,
UnicodeProperties,
WordBoundary,
CountedQuantifiers,
}
pub const fn javascript_regexp_gaps() -> &'static [JavascriptRegExpGap] {
&[
JavascriptRegExpGap::Flags,
JavascriptRegExpGap::Alternation,
JavascriptRegExpGap::Groups,
JavascriptRegExpGap::Backreferences,
JavascriptRegExpGap::Lookaround,
JavascriptRegExpGap::UnicodeProperties,
JavascriptRegExpGap::WordBoundary,
JavascriptRegExpGap::CountedQuantifiers,
]
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum JavascriptRegExpError {
UnsupportedFlag(char),
UnsupportedSyntax {
offset: usize,
reason: &'static str,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JavascriptRegExp {
source: String,
ops: Vec<TextOp>,
}
impl JavascriptRegExp {
pub fn compile(source: &str, flags: &str) -> Result<Self, JavascriptRegExpError> {
if let Some(flag) = flags.chars().next() {
return Err(JavascriptRegExpError::UnsupportedFlag(flag));
}
let chars: Vec<(usize, char)> = source.char_indices().collect();
let mut at = 0;
let mut ops = Vec::new();
while at < chars.len() {
let (offset, ch) = chars[at];
match ch {
'^' if at == 0 => ops.push(TextOp::AnchorStart),
'$' if at + 1 == chars.len() => ops.push(TextOp::AnchorEnd),
'.' => ops.push(TextOp::Any),
'[' => {
let (class, next) = compile_class(&chars, at)?;
ops.push(TextOp::Class(class));
at = next - 1;
}
'\\' => {
at += 1;
let Some((_, escaped)) = chars.get(at).copied() else {
return Err(syntax(offset, "trailing escape"));
};
ops.push(escape_atom(escaped, offset)?);
}
'*' | '+' | '?' => {
let (min, max) = match ch {
'*' => (0, None),
'+' => (1, None),
'?' => (0, Some(1)),
_ => unreachable!(),
};
if !matches!(
ops.last(),
Some(TextOp::Literal(_) | TextOp::Any | TextOp::Class(_))
) {
return Err(syntax(offset, "quantifier has no admissible atom"));
}
let lazy = chars.get(at + 1).is_some_and(|(_, c)| *c == '?');
ops.push(TextOp::Repeat {
min,
max,
greedy: !lazy,
});
if lazy {
at += 1;
}
}
'|' | '(' | ')' | '{' | '}' => {
return Err(syntax(
offset,
"syntax requires JAVA_SCRIPT_6 pattern-engine work",
));
}
_ => ops.push(TextOp::Literal(ch)),
}
at += 1;
}
Ok(Self {
source: source.into(),
ops,
})
}
pub fn source(&self) -> &str {
&self.source
}
pub fn ops(&self) -> &[TextOp] {
&self.ops
}
pub fn find(&self, subject: &str, init: usize, max_steps: usize) -> Option<TextMatch> {
run_text_pattern(&self.ops, subject, init, TextLimits { max_steps })
}
}
fn syntax(offset: usize, reason: &'static str) -> JavascriptRegExpError {
JavascriptRegExpError::UnsupportedSyntax { offset, reason }
}
fn escape_atom(ch: char, offset: usize) -> Result<TextOp, JavascriptRegExpError> {
Ok(match ch {
'd' => TextOp::Class(TextClass::Digit),
'D' => TextOp::Class(TextClass::Not(Box::new(TextClass::Digit))),
's' => TextOp::Class(TextClass::Space),
'S' => TextOp::Class(TextClass::Not(Box::new(TextClass::Space))),
'w' => TextOp::Class(TextClass::Alnum),
'W' => TextOp::Class(TextClass::Not(Box::new(TextClass::Alnum))),
'b' | 'B' => return Err(syntax(offset, "word-boundary assertions are unsupported")),
'1'..='9' => return Err(syntax(offset, "backreferences are unsupported")),
'p' | 'P' => return Err(syntax(offset, "Unicode property escapes are unsupported")),
other => TextOp::Literal(other),
})
}
fn compile_class(
chars: &[(usize, char)],
start: usize,
) -> Result<(TextClass, usize), JavascriptRegExpError> {
let offset = chars[start].0;
let mut at = start + 1;
let negated = chars.get(at).is_some_and(|(_, c)| *c == '^');
if negated {
at += 1;
}
let mut literals = Vec::new();
let mut ranges = Vec::new();
let mut classes = Vec::new();
while let Some((pos, ch)) = chars.get(at).copied() {
if ch == ']' && at > start + 1 {
return Ok((
TextClass::Set {
chars: literals,
ranges,
classes,
negated,
},
at + 1,
));
}
let atom = if ch == '\\' {
at += 1;
let Some((_, e)) = chars.get(at).copied() else {
return Err(syntax(pos, "trailing class escape"));
};
match escape_atom(e, pos)? {
TextOp::Class(c) => {
classes.push(c);
None
}
TextOp::Literal(c) => Some(c),
_ => None,
}
} else {
Some(ch)
};
if let Some(first) = atom {
if chars.get(at + 1).is_some_and(|(_, c)| *c == '-')
&& chars.get(at + 2).is_some_and(|(_, c)| *c != ']')
{
let end = chars[at + 2].1;
if first > end {
return Err(syntax(pos, "descending character-class range"));
}
ranges.push((first, end));
at += 2;
} else {
literals.push(first);
}
}
at += 1;
}
Err(syntax(offset, "unterminated character class"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn admitted_subset_executes_in_bounded_organ() {
let r = JavascriptRegExp::compile(r"^[A-Z]+\d?$", "").unwrap();
assert!(r.find("SIM4", 0, 1000).is_some());
assert!(r.find("sim", 0, 1000).is_none());
}
#[test]
fn unsupported_features_fail_closed() {
for p in ["a|b", "(a)", r"(a)\1", r"\p{Letter}", r"\bword"] {
assert!(JavascriptRegExp::compile(p, "").is_err(), "{p}");
}
assert_eq!(
JavascriptRegExp::compile("a", "g"),
Err(JavascriptRegExpError::UnsupportedFlag('g'))
);
}
#[test]
fn gaps_and_successor_are_blunt() {
assert_eq!(javascript_regexp_gaps().len(), 8);
assert_eq!(
JAVASCRIPT_REGEXP_SUCCESSOR,
"JAVA_SCRIPT_6 pattern-engine work"
);
}
}