1use sim_lib_pattern::{
6 Anchor, Automaton, CaptureId, CodeUnitDomain, DomainExecutionOutcome, EnginePolicy, IrNode,
7 PatternIr, RepeatBounds, TextLimits, TextMatch, compile, execute_code_units,
8};
9use sim_text::CodeUnitString;
10use std::collections::{BTreeMap, BTreeSet};
11
12pub const JAVASCRIPT_REGEXP_SUCCESSOR: &str = "remaining ECMAScript RegExp clauses";
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum JavascriptRegExpGap {
18 Flags,
20 Backreferences,
22 Lookaround,
24 UnicodeProperties,
26 WordBoundary,
28}
29pub const fn javascript_regexp_gaps() -> &'static [JavascriptRegExpGap] {
31 &[
32 JavascriptRegExpGap::Flags,
33 JavascriptRegExpGap::Backreferences,
34 JavascriptRegExpGap::Lookaround,
35 JavascriptRegExpGap::UnicodeProperties,
36 JavascriptRegExpGap::WordBoundary,
37 ]
38}
39#[derive(Clone, Debug, Eq, PartialEq)]
41pub enum JavascriptRegExpError {
42 UnsupportedFlag(char),
44 UnsupportedSyntax {
46 offset: usize,
48 reason: &'static str,
50 },
51}
52#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct JavascriptRegExp {
55 source: String,
56 automaton: Automaton<u16, CodeUnitClass>,
57 anchored_start: bool,
58}
59impl JavascriptRegExp {
60 pub fn compile(source: &str, flags: &str) -> Result<Self, JavascriptRegExpError> {
63 if let Some(flag) = flags.chars().next() {
64 return Err(JavascriptRegExpError::UnsupportedFlag(flag));
65 }
66 let root = Parser::new(source).parse()?;
67 let policy = EnginePolicy::new(classes_in(&root));
68 let ir = PatternIr::<CodeUnitDomain, CodeUnitClass>::new(root, BTreeMap::new(), &policy)
69 .map_err(|_| syntax(0, "invalid regular expression"))?;
70 Ok(Self {
71 source: source.into(),
72 automaton: compile(&ir),
73 anchored_start: source.starts_with('^'),
74 })
75 }
76 pub fn source(&self) -> &str {
78 &self.source
79 }
80 pub fn find(&self, subject: &str, init: usize, max_steps: usize) -> Option<TextMatch> {
83 let subject = CodeUnitString::from_scalar(subject);
84 let mut starts = if self.anchored_start {
85 (init == 0).then_some(0..=0)
86 } else {
87 Some(init..=subject.len())
88 }?;
89 starts.find_map(|start| {
90 let tail = CodeUnitString::from_code_units(subject.as_code_units()[start..].to_vec());
91 match execute_code_units(
92 &self.automaton,
93 &tail,
94 TextLimits {
95 max_steps,
96 ..TextLimits::default()
97 },
98 |class, unit| class.matches(*unit),
99 ) {
100 DomainExecutionOutcome::Match { matched, .. } => Some(TextMatch {
101 start: start + matched.start.get(),
102 end: start + matched.end.get(),
103 captures: matched
104 .captures
105 .values()
106 .map(|span| (start + span.start.get(), start + span.end.get()))
107 .collect(),
108 }),
109 _ => None,
110 }
111 })
112 }
113}
114fn syntax(offset: usize, reason: &'static str) -> JavascriptRegExpError {
115 JavascriptRegExpError::UnsupportedSyntax { offset, reason }
116}
117#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
118enum CodeUnitClass {
119 Digit(bool),
120 Space(bool),
121 Word(bool),
122 LineTerminator(bool),
123 Set {
124 units: Vec<u16>,
125 ranges: Vec<(u16, u16)>,
126 classes: Vec<CodeUnitClass>,
127 negated: bool,
128 },
129}
130impl CodeUnitClass {
131 fn matches(&self, unit: u16) -> bool {
132 match self {
133 Self::Digit(negated) => (b'0' as u16..=b'9' as u16).contains(&unit) != *negated,
134 Self::Space(negated) => {
135 matches!(unit, 0x09..=0x0d | 0x20 | 0x00a0 | 0x1680 | 0x2000..=0x200a | 0x2028 | 0x2029 | 0x202f | 0x205f | 0x3000 | 0xfeff)
136 != *negated
137 }
138 Self::Word(negated) => {
139 ((b'0' as u16..=b'9' as u16).contains(&unit)
140 || (b'A' as u16..=b'Z' as u16).contains(&unit)
141 || (b'a' as u16..=b'z' as u16).contains(&unit)
142 || unit == b'_' as u16)
143 != *negated
144 }
145 Self::LineTerminator(negated) => {
146 matches!(unit, 0x0a | 0x0d | 0x2028 | 0x2029) != *negated
147 }
148 Self::Set {
149 units,
150 ranges,
151 classes,
152 negated,
153 } => {
154 (units.contains(&unit)
155 || ranges.iter().any(|(a, b)| *a <= unit && unit <= *b)
156 || classes.iter().any(|class| class.matches(unit)))
157 != *negated
158 }
159 }
160 }
161}
162fn classes_in(root: &IrNode<u16, CodeUnitClass>) -> BTreeSet<CodeUnitClass> {
163 fn visit(node: &IrNode<u16, CodeUnitClass>, found: &mut BTreeSet<CodeUnitClass>) {
164 match node {
165 IrNode::Extension(class) => {
166 found.insert(class.clone());
167 }
168 IrNode::Concat(nodes) | IrNode::Alternation(nodes) => {
169 nodes.iter().for_each(|node| visit(node, found))
170 }
171 IrNode::Repeat { node, .. } | IrNode::Group(node) | IrNode::Capture { node, .. } => {
172 visit(node, found)
173 }
174 IrNode::Symbol(_) | IrNode::Any | IrNode::Anchor(_) | IrNode::Assertion(_) => {}
175 }
176 }
177 let mut found = BTreeSet::new();
178 visit(root, &mut found);
179 found
180}
181fn escape_atom(
182 ch: char,
183 offset: usize,
184) -> Result<IrNode<u16, CodeUnitClass>, JavascriptRegExpError> {
185 Ok(match ch {
186 'd' => IrNode::Extension(CodeUnitClass::Digit(false)),
187 'D' => IrNode::Extension(CodeUnitClass::Digit(true)),
188 's' => IrNode::Extension(CodeUnitClass::Space(false)),
189 'S' => IrNode::Extension(CodeUnitClass::Space(true)),
190 'w' => IrNode::Extension(CodeUnitClass::Word(false)),
191 'W' => IrNode::Extension(CodeUnitClass::Word(true)),
192 'b' | 'B' => return Err(syntax(offset, "word-boundary assertions are unsupported")),
193 '1'..='9' => return Err(syntax(offset, "backreferences are unsupported")),
194 'p' | 'P' => return Err(syntax(offset, "Unicode property escapes are unsupported")),
195 other => literal(other),
196 })
197}
198fn literal(ch: char) -> IrNode<u16, CodeUnitClass> {
199 let nodes = ch
200 .encode_utf16(&mut [0; 2])
201 .iter()
202 .copied()
203 .map(IrNode::Symbol)
204 .collect::<Vec<_>>();
205 if nodes.len() == 1 {
206 nodes.into_iter().next().unwrap()
207 } else {
208 IrNode::Concat(nodes)
209 }
210}
211
212struct Parser {
213 chars: Vec<(usize, char)>,
214 at: usize,
215 capture: u32,
216}
217impl Parser {
218 fn new(source: &str) -> Self {
219 Self {
220 chars: source.char_indices().collect(),
221 at: 0,
222 capture: 0,
223 }
224 }
225 fn parse(mut self) -> Result<IrNode<u16, CodeUnitClass>, JavascriptRegExpError> {
226 let root = self.alternation()?;
227 if let Some((offset, _)) = self.peek() {
228 return Err(syntax(offset, "unmatched closing parenthesis"));
229 }
230 Ok(root)
231 }
232 fn alternation(&mut self) -> Result<IrNode<u16, CodeUnitClass>, JavascriptRegExpError> {
233 let mut branches = vec![self.sequence()?];
234 while self.take('|') {
235 branches.push(self.sequence()?);
236 }
237 Ok(if branches.len() == 1 {
238 branches.pop().unwrap()
239 } else {
240 IrNode::Alternation(branches)
241 })
242 }
243 fn sequence(&mut self) -> Result<IrNode<u16, CodeUnitClass>, JavascriptRegExpError> {
244 let mut nodes = Vec::new();
245 while self.peek().is_some_and(|(_, ch)| ch != ')' && ch != '|') {
246 nodes.push(self.quantified()?);
247 }
248 Ok(IrNode::Concat(nodes))
249 }
250 fn quantified(&mut self) -> Result<IrNode<u16, CodeUnitClass>, JavascriptRegExpError> {
251 let mut node = self.atom()?;
252 let Some((offset, ch)) = self.peek() else {
253 return Ok(node);
254 };
255 let bounds = match ch {
256 '*' => {
257 self.at += 1;
258 Some(RepeatBounds::new(0, None).unwrap())
259 }
260 '+' => {
261 self.at += 1;
262 Some(RepeatBounds::new(1, None).unwrap())
263 }
264 '?' => {
265 self.at += 1;
266 Some(RepeatBounds::new(0, Some(1)).unwrap())
267 }
268 '{' => Some(self.counted(offset)?),
269 _ => None,
270 };
271 if let Some(bounds) = bounds {
272 let greedy = !self.take('?');
273 node = IrNode::Repeat {
274 node: Box::new(node),
275 bounds,
276 greedy,
277 };
278 }
279 Ok(node)
280 }
281 fn counted(&mut self, offset: usize) -> Result<RepeatBounds, JavascriptRegExpError> {
282 self.at += 1;
283 let min = self
284 .number()
285 .ok_or_else(|| syntax(offset, "malformed counted quantifier"))?;
286 let max = if self.take('}') {
287 Some(min)
288 } else if self.take(',') {
289 let max = self.number();
290 if !self.take('}') {
291 return Err(syntax(offset, "unterminated counted quantifier"));
292 }
293 max
294 } else {
295 return Err(syntax(offset, "malformed counted quantifier"));
296 };
297 RepeatBounds::new(min, max)
298 .map_err(|_| syntax(offset, "counted quantifier maximum is below minimum"))
299 }
300 fn number(&mut self) -> Option<usize> {
301 let start = self.at;
302 let mut value = 0usize;
303 while let Some((_, ch)) = self.peek().filter(|(_, ch)| ch.is_ascii_digit()) {
304 value = value
305 .checked_mul(10)?
306 .checked_add(ch.to_digit(10)? as usize)?;
307 self.at += 1;
308 }
309 (self.at > start).then_some(value)
310 }
311 fn atom(&mut self) -> Result<IrNode<u16, CodeUnitClass>, JavascriptRegExpError> {
312 let (offset, ch) = self.peek().ok_or_else(|| syntax(0, "missing atom"))?;
313 self.at += 1;
314 match ch {
315 '^' => Ok(IrNode::Anchor(Anchor::SubjectStart)),
316 '$' => Ok(IrNode::Anchor(Anchor::SubjectEnd)),
317 '.' => Ok(IrNode::Extension(CodeUnitClass::LineTerminator(true))),
318 '(' => {
319 let capture = if self.take('?') {
320 if self.take(':') {
321 None
322 } else {
323 return Err(syntax(
324 offset,
325 "lookaround and special groups are unsupported",
326 ));
327 }
328 } else {
329 let id = CaptureId(self.capture);
330 self.capture += 1;
331 Some(id)
332 };
333 let node = self.alternation()?;
334 if !self.take(')') {
335 return Err(syntax(offset, "unterminated group"));
336 }
337 Ok(match capture {
338 Some(id) => IrNode::Capture {
339 id,
340 node: Box::new(node),
341 },
342 None => IrNode::Group(Box::new(node)),
343 })
344 }
345 '[' => self.class(offset),
346 '\\' => {
347 let (_, escaped) = self
348 .peek()
349 .ok_or_else(|| syntax(offset, "trailing escape"))?;
350 self.at += 1;
351 escape_atom(escaped, offset)
352 }
353 '*' | '+' | '?' | '{' => Err(syntax(offset, "quantifier has no admissible atom")),
354 ')' | '|' => unreachable!(),
355 _ => Ok(literal(ch)),
356 }
357 }
358 fn class(
359 &mut self,
360 offset: usize,
361 ) -> Result<IrNode<u16, CodeUnitClass>, JavascriptRegExpError> {
362 let negated = self.take('^');
363 let mut units = Vec::new();
364 let mut ranges = Vec::new();
365 let mut classes = Vec::new();
366 while let Some((pos, ch)) = self.peek() {
367 if ch == ']' {
368 self.at += 1;
369 return Ok(IrNode::Extension(CodeUnitClass::Set {
370 units,
371 ranges,
372 classes,
373 negated,
374 }));
375 }
376 self.at += 1;
377 let first = if ch == '\\' {
378 let (_, e) = self
379 .peek()
380 .ok_or_else(|| syntax(pos, "trailing class escape"))?;
381 self.at += 1;
382 if matches!(e, 'p' | 'P') {
383 return Err(syntax(pos, "Unicode property escapes are unsupported"));
384 }
385 if let Some(class) = match e {
386 'd' => Some(CodeUnitClass::Digit(false)),
387 'D' => Some(CodeUnitClass::Digit(true)),
388 's' => Some(CodeUnitClass::Space(false)),
389 'S' => Some(CodeUnitClass::Space(true)),
390 'w' => Some(CodeUnitClass::Word(false)),
391 'W' => Some(CodeUnitClass::Word(true)),
392 _ => None,
393 } {
394 classes.push(class);
395 continue;
396 }
397 e
398 } else {
399 ch
400 };
401 let mut first_units = [0; 2];
402 let encoded = first.encode_utf16(&mut first_units);
403 if encoded.len() != 1 {
404 return Err(syntax(
405 pos,
406 "non-BMP character classes require Unicode set support",
407 ));
408 }
409 let first = encoded[0];
410 if self.take('-') && self.peek().is_some_and(|(_, c)| c != ']') {
411 let (end_pos, end) = self.peek().unwrap();
412 self.at += 1;
413 let mut end_units = [0; 2];
414 let encoded = end.encode_utf16(&mut end_units);
415 if encoded.len() != 1 || first > encoded[0] {
416 return Err(syntax(end_pos, "invalid character-class range"));
417 }
418 ranges.push((first, encoded[0]));
419 } else {
420 units.push(first);
421 }
422 }
423 Err(syntax(offset, "unterminated character class"))
424 }
425 fn peek(&self) -> Option<(usize, char)> {
426 self.chars.get(self.at).copied()
427 }
428 fn take(&mut self, expected: char) -> bool {
429 if self.peek().is_some_and(|(_, ch)| ch == expected) {
430 self.at += 1;
431 true
432 } else {
433 false
434 }
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use std::sync::Arc;
442
443 use sim_kernel::{Cx, Datum, DefaultFactory, NoopEvalPolicy, Ref, Symbol};
444 use sim_lib_standard_core::{
445 BoundedLane, CanonicalObservation, CanonicalOutcome, CharacterizationCapture,
446 ScenarioLimits, ScenarioObservationLane, ScenarioSpec, publish_characterization_capture,
447 };
448
449 fn case(name: &str, fields: &[(&str, String)]) -> Datum {
450 Datum::Node {
451 tag: Symbol::qualified("javascript-regexp-characterization", "case/v1"),
452 fields: std::iter::once((Symbol::new("name"), Datum::String(name.to_owned())))
453 .chain(
454 fields
455 .iter()
456 .map(|(key, value)| (Symbol::new(*key), Datum::String(value.clone()))),
457 )
458 .collect(),
459 }
460 }
461
462 fn refusal(source: &str, flags: &str, clause: &str) -> Datum {
463 let error = JavascriptRegExp::compile(source, flags).unwrap_err();
464 let (class, diagnostic) = match error {
465 JavascriptRegExpError::UnsupportedFlag(flag) => {
466 ("unsupported-flag", format!("flag {flag} is unsupported"))
467 }
468 JavascriptRegExpError::UnsupportedSyntax { offset, reason } => {
469 ("unsupported-syntax", format!("byte {offset}: {reason}"))
470 }
471 };
472 for roadmap_family in ["PATTERN", "CHARACTERIZE", "ROADMAP"] {
473 assert!(!diagnostic.contains(roadmap_family), "{diagnostic}");
474 }
475 case(
476 "refusal",
477 &[
478 ("clause", clause.to_owned()),
479 ("class", class.to_owned()),
480 ("diagnostic", diagnostic),
481 ],
482 )
483 }
484
485 fn gap_name(gap: JavascriptRegExpGap) -> &'static str {
486 match gap {
487 JavascriptRegExpGap::Flags => "flags",
488 JavascriptRegExpGap::Backreferences => "backreferences",
489 JavascriptRegExpGap::Lookaround => "lookaround",
490 JavascriptRegExpGap::UnicodeProperties => "unicode-properties",
491 JavascriptRegExpGap::WordBoundary => "word-boundary",
492 }
493 }
494 #[test]
495 fn admitted_subset_executes_in_bounded_organ() {
496 let r = JavascriptRegExp::compile(r"^[A-Z]+\d?$", "").unwrap();
497 assert!(r.find("SIM4", 0, 1000).is_some());
498 assert!(r.find("sim", 0, 1000).is_none());
499 }
500 #[test]
501 fn shared_regular_features_report_code_unit_spans() {
502 let regexp = JavascriptRegExp::compile("^(?:ab|\u{1f600}){2,3}(c+?)$", "").unwrap();
503 let matched = regexp.find("ab\u{1f600}cc", 0, 10_000).unwrap();
504 assert_eq!((matched.start, matched.end), (0, 6));
505 assert_eq!(matched.captures, [(4, 6)]);
506 assert!(regexp.find("xababcc", 0, 10_000).is_none());
507 assert!(
508 JavascriptRegExp::compile(r"^[\d]+$", "")
509 .unwrap()
510 .find("42", 0, 1_000)
511 .is_some()
512 );
513 assert!(
514 JavascriptRegExp::compile("^.$", "")
515 .unwrap()
516 .find("\n", 0, 1_000)
517 .is_none()
518 );
519 }
520 #[test]
521 fn refused_clause_keeps_its_typed_diagnostic() {
522 assert_eq!(
523 JavascriptRegExp::compile(r"\bword", ""),
524 Err(JavascriptRegExpError::UnsupportedSyntax {
525 offset: 0,
526 reason: "word-boundary assertions are unsupported",
527 })
528 );
529 }
530 #[test]
531 fn unsupported_features_fail_closed() {
532 for p in [r"(a)\1", r"\p{Letter}", r"\bword", "(?=a)"] {
533 assert!(JavascriptRegExp::compile(p, "").is_err(), "{p}");
534 }
535 assert_eq!(
536 JavascriptRegExp::compile("a", "g"),
537 Err(JavascriptRegExpError::UnsupportedFlag('g'))
538 );
539 }
540 #[test]
541 fn gaps_and_successor_are_blunt() {
542 assert_eq!(javascript_regexp_gaps().len(), 5);
543 assert_eq!(
544 JAVASCRIPT_REGEXP_SUCCESSOR,
545 "remaining ECMAScript RegExp clauses"
546 );
547 }
548
549 #[test]
550 fn current_regexp_behavior_is_a_stable_characterization_capture() {
551 let unicode = JavascriptRegExp::compile("\u{1f600}+", "").unwrap();
552 let unicode_match = unicode.find("x\u{1f600}\u{1f600}y", 0, 1_000).unwrap();
553 let greedy = JavascriptRegExp::compile("a*a", "")
554 .unwrap()
555 .find("aaa", 0, 1_000);
556 let lazy = JavascriptRegExp::compile("a*?a", "")
557 .unwrap()
558 .find("aaa", 0, 1_000);
559 let empty = JavascriptRegExp::compile("a*", "")
560 .unwrap()
561 .find("bbb", 0, 1_000);
562 let limited = JavascriptRegExp::compile("a*b", "")
563 .unwrap()
564 .find("aaab", 0, 1);
565 let cases = vec![
566 case(
567 "unicode-byte-offsets",
568 &[(
569 "span",
570 format!("{}..{}", unicode_match.start, unicode_match.end),
571 )],
572 ),
573 case("greedy-repetition", &[("match", format!("{greedy:?}"))]),
574 case("lazy-repetition", &[("match", format!("{lazy:?}"))]),
575 case("empty-match", &[("match", format!("{empty:?}"))]),
576 case(
577 "limit-exhaustion",
578 &[
579 ("clause", "maximum VM steps".to_owned()),
580 (
581 "outcome",
582 if limited.is_none() {
583 "refused"
584 } else {
585 "matched"
586 }
587 .to_owned(),
588 ),
589 ],
590 ),
591 refusal("a", "g", "flags"),
592 refusal(r"\bword", "", "word-boundary"),
593 refusal("\\", "", "trailing-escape"),
594 refusal("*", "", "quantifier-without-atom"),
595 ];
596 let scenario = ScenarioSpec::new(
597 Symbol::qualified("javascript-regexp-characterization", "current/v1"),
598 Symbol::qualified("javascript-regexp-characterization", "shared-text-vm/v1"),
599 )
600 .with_limits(ScenarioLimits::new(0, cases.len()))
601 .observing(ScenarioObservationLane::ValueOrFailure);
602 let capture = CharacterizationCapture::new(
603 Symbol::qualified("javascript-regexp-characterization", "dialect-cases/v1"),
604 CanonicalObservation {
605 outcome: Some(CanonicalOutcome::Success(Datum::Vector(cases))),
606 events: BoundedLane::Absent,
607 receipts: BoundedLane::Absent,
608 browse: BoundedLane::Absent,
609 },
610 );
611 let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
612 let first = publish_characterization_capture(&mut cx, &scenario, &capture).unwrap();
613 let replay = publish_characterization_capture(&mut cx, &scenario, &capture).unwrap();
614 assert!(matches!(first, Ref::Content(_)));
615 assert_eq!(first, replay);
616 }
617
618 #[test]
619 fn public_gap_data_is_frozen_clause_for_clause() {
620 let clauses = javascript_regexp_gaps()
621 .iter()
622 .copied()
623 .map(gap_name)
624 .collect::<Vec<_>>();
625 assert_eq!(
626 clauses,
627 [
628 "flags",
629 "backreferences",
630 "lookaround",
631 "unicode-properties",
632 "word-boundary",
633 ]
634 );
635 }
636}