pub(crate) const VALID_FLAGS: &str = "dgimsuvy";
const REGEX_ALLOWING_KEYWORDS: [&str; 15] = [
"return",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"do",
"else",
"case",
"yield",
"await",
"throw",
"",
];
pub(crate) fn is_valid_flag_string(flags: &str) -> bool {
let mut seen = Vec::new();
for flag in flags.chars() {
if !VALID_FLAGS.contains(flag) || seen.contains(&flag) {
return false;
}
seen.push(flag);
}
true
}
struct Structure {
depth: usize,
branches: usize,
}
const DEEP_GROUP_DEPTH: usize = 32;
const DEEP_ALTERNATION_BRANCHES: usize = 256;
pub(crate) const MAX_GROUP_DEPTH: usize = 1_000;
pub(crate) const MAX_ALTERNATION_BRANCHES: usize = 5_000;
const DEEP_STACK: usize = 32 * 1024 * 1024;
fn structure(pattern: &str) -> Structure {
let mut characters = pattern.chars();
let mut open: Vec<char> = Vec::new();
let mut classes: usize = 0;
let mut deepest: usize = 0;
let mut branches: usize = 0;
while let Some(character) = characters.next() {
match character {
'\\' => {
characters.next();
}
'[' => {
open.push('[');
classes += 1;
deepest = deepest.max(open.len());
}
']' if classes > 0 => {
open.pop();
classes -= 1;
}
'(' if classes == 0 => {
open.push('(');
deepest = deepest.max(open.len());
}
')' if classes == 0 => {
if open.last() == Some(&'(') {
open.pop();
}
}
'|' if classes == 0 => branches += 1,
_ => continue,
}
if deepest > MAX_GROUP_DEPTH || branches > MAX_ALTERNATION_BRANCHES {
break;
}
}
Structure {
depth: deepest,
branches,
}
}
pub(crate) fn is_within_parser_limits(pattern: &str) -> bool {
let found = structure(pattern);
found.depth <= MAX_GROUP_DEPTH && found.branches <= MAX_ALTERNATION_BRANCHES
}
pub(crate) fn compiles(pattern: &str, flags: &str) -> bool {
if !is_valid_flag_string(flags) {
return false;
}
let found = structure(pattern);
if found.depth > MAX_GROUP_DEPTH || found.branches > MAX_ALTERNATION_BRANCHES {
return false;
}
if found.depth <= DEEP_GROUP_DEPTH && found.branches <= DEEP_ALTERNATION_BRANCHES {
return parse(pattern, flags);
}
parse_with_room(pattern, flags)
}
fn parse(pattern: &str, flags: &str) -> bool {
regress::Regex::with_flags(pattern, flags).is_ok()
}
fn parse_with_room(pattern: &str, flags: &str) -> bool {
std::thread::scope(|scope| {
std::thread::Builder::new()
.stack_size(DEEP_STACK)
.spawn_scoped(scope, || parse(pattern, flags))
.is_ok_and(|handle| handle.join().unwrap_or(false))
})
}
pub(crate) fn is_well_formed(pattern: &str, flags: &str) -> bool {
compiles(pattern, flags) || compiles(&javascript_equivalent(pattern), flags)
}
const INLINE_FLAGS: &str = "imsxuUXAJn";
fn javascript_equivalent(pattern: &str) -> String {
let characters: Vec<char> = pattern.chars().collect();
let mut out = String::with_capacity(pattern.len());
let mut in_class = false;
let mut after_quantifier = false;
let mut index = 0;
while index < characters.len() {
let character = characters[index];
if character == '\\' {
out.push(character);
if let Some(next) = characters.get(index + 1) {
out.push(*next);
}
after_quantifier = false;
index += 2;
continue;
}
if in_class {
in_class = character != ']';
out.push(character);
index += 1;
continue;
}
if character == '(' {
let (rendered, width) = rewrite_group_prefix(&characters, index);
out.push_str(&rendered);
after_quantifier = false;
index += width;
continue;
}
if character == '+' && after_quantifier {
index += 1;
continue;
}
in_class = character == '[';
after_quantifier = matches!(character, '*' | '+' | '?' | '}');
out.push(character);
index += 1;
}
out
}
fn rewrite_group_prefix(characters: &[char], index: usize) -> (String, usize) {
if starts_with(characters, index, "(?#") {
let end = find(characters, index, ')').map_or(characters.len(), |at| at + 1);
return (String::new(), end - index);
}
if starts_with(characters, index, "(?P<") {
return ("(?<".to_string(), 4);
}
if starts_with(characters, index, "(?P=")
&& let Some(end) = find(characters, index + 4, ')')
{
let name: String = characters[index + 4..end].iter().collect();
return (format!("\\k<{name}>"), end + 1 - index);
}
if starts_with(characters, index, "(?'")
&& let Some(end) = find(characters, index + 3, '\'')
{
let name: String = characters[index + 3..end].iter().collect();
return (format!("(?<{name}>"), end + 1 - index);
}
if starts_with(characters, index, "(?>") {
return ("(?:".to_string(), 3);
}
match inline_flags(characters, index) {
Some((width, ':')) => ("(?:".to_string(), width),
Some((width, _)) => (String::new(), width),
None => ("(".to_string(), 1),
}
}
fn inline_flags(characters: &[char], index: usize) -> Option<(usize, char)> {
if !starts_with(characters, index, "(?") {
return None;
}
let mut at = index + 2;
while characters
.get(at)
.is_some_and(|c| INLINE_FLAGS.contains(*c))
{
at += 1;
}
if characters.get(at) == Some(&'-') {
at += 1;
let negated = at;
while characters
.get(at)
.is_some_and(|c| INLINE_FLAGS.contains(*c))
{
at += 1;
}
if at == negated {
return None;
}
}
match characters.get(at) {
Some(terminator @ (')' | ':')) => Some((at + 1 - index, *terminator)),
_ => None,
}
}
fn starts_with(characters: &[char], index: usize, prefix: &str) -> bool {
prefix
.chars()
.enumerate()
.all(|(offset, expected)| characters.get(index + offset) == Some(&expected))
}
fn find(characters: &[char], from: usize, needle: char) -> Option<usize> {
characters
.get(from..)?
.iter()
.position(|character| *character == needle)
.map(|at| from + at)
}
fn is_word_character(character: char) -> bool {
character.is_ascii_alphanumeric() || character == '_' || character == '$'
}
pub(crate) fn is_regex_context(text: &str, offset: usize) -> bool {
let mut end = offset.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
let before = text[..end].trim_end_matches([' ', '\t']);
let Some(previous) = before.chars().next_back() else {
return true; };
if previous == '\n' || previous == '\r' {
return true; }
if is_word_character(previous) {
let word = before.trim_end_matches(is_word_character);
return REGEX_ALLOWING_KEYWORDS.contains(&&before[word.len()..]);
}
!matches!(previous, ')' | ']' | '.' | '/')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legal_flags_appear_at_most_once() {
assert!(is_valid_flag_string(""));
assert!(is_valid_flag_string("gi"));
assert!(is_valid_flag_string("dgimsuvy"));
assert!(!is_valid_flag_string("gg"), "a repeat is not valid");
assert!(!is_valid_flag_string("x"));
assert!(!is_valid_flag_string("GI"), "flags are lowercase");
}
#[test]
fn an_invalid_pattern_does_not_compile() {
assert!(compiles("a+", ""));
assert!(!compiles("(", ""));
assert!(!compiles("a{2,1}", ""));
assert!(!compiles("[z-a]", ""));
}
#[test]
fn bad_flags_fail_to_compile() {
assert!(!compiles("x", "zz"));
assert!(!compiles("x", "q"));
}
#[test]
fn another_languages_spelling_is_well_formed() {
for pattern in [
r"(?P<year>\d{4})",
r"(?P<a>x)(?P=a)",
"(?>a+)",
"a++",
r"a{2,}+",
"(?i)abc",
"(?im-sx)abc",
"(?'name'a)",
"(?#a comment)b",
] {
assert!(!compiles(pattern, ""), "{pattern} compiles as JavaScript");
assert!(is_well_formed(pattern, ""), "{pattern}");
}
}
#[test]
fn a_syntax_error_is_still_not_well_formed() {
for pattern in ["(", "a{2,1}", "[z-a]", "(?P<a>x", "(?>a+"] {
assert!(!is_well_formed(pattern, ""), "{pattern}");
}
assert!(!is_well_formed("x", "zz"), "the flags are still judged");
}
#[test]
fn a_pattern_too_deep_to_parse_is_answered_rather_than_aborted() {
let deep = format!("{}a{}", "(".repeat(20_000), ")".repeat(20_000));
assert!(!is_within_parser_limits(&deep));
assert!(!compiles(&deep, ""), "answered, not aborted");
assert!(!is_well_formed(&deep, ""));
let wide = vec!["a"; 20_000].join("|");
assert!(!is_within_parser_limits(&wide));
assert!(!compiles(&wide, ""));
}
#[test]
fn nested_character_classes_are_answered_rather_than_aborted() {
let nested = "[".repeat(20_000);
assert!(!is_within_parser_limits(&nested));
for flags in ["", "v", "dgimsuvy"] {
assert!(!compiles(&nested, flags), "{flags}");
assert!(!is_well_formed(&nested, flags), "{flags}");
}
}
#[test]
fn unclosed_groups_after_a_class_still_count_as_depth() {
let unbalanced = format!("-{}", "[(]+(]+".repeat(2_000));
assert!(!is_within_parser_limits(&unbalanced));
for flags in ["", "g", "dgimsuvy"] {
assert!(!compiles(&unbalanced, flags), "{flags}");
}
assert!(is_within_parser_limits(&"[(]+[)]+".repeat(2_000)));
}
#[test]
fn an_ordinary_pattern_is_within_the_parser_limits() {
for pattern in [
r"^\d{4}-\d{2}-\d{2}$",
"(a+)+",
"(?<name>a|b|c)*",
r"[(|]+\(\|",
"",
] {
assert!(is_within_parser_limits(pattern), "{pattern}");
assert!(compiles(pattern, ""), "{pattern}");
}
assert!(
is_within_parser_limits(&"(a)".repeat(20_000)),
"wide but never deep: 20,000 groups at depth one"
);
assert!(
is_within_parser_limits(&r"\(\[".repeat(MAX_GROUP_DEPTH + 10)),
"an escaped bracket is a literal, not a level"
);
}
#[test]
fn the_parser_limits_are_inclusive() {
let at = |depth: usize| format!("{}a{}", "(".repeat(depth), ")".repeat(depth));
assert!(is_within_parser_limits(&at(MAX_GROUP_DEPTH)));
assert!(compiles(&at(MAX_GROUP_DEPTH), ""), "judged, not refused");
assert!(!is_within_parser_limits(&at(MAX_GROUP_DEPTH + 1)));
let branches = |count: usize| vec!["a"; count + 1].join("|");
assert!(is_within_parser_limits(&branches(MAX_ALTERNATION_BRANCHES)));
assert!(compiles(&branches(MAX_ALTERNATION_BRANCHES), ""));
assert!(!is_within_parser_limits(&branches(
MAX_ALTERNATION_BRANCHES + 1
)));
}
#[test]
fn the_javascript_rendering_is_a_translation_not_a_repair() {
assert_eq!(javascript_equivalent(r"(?P<y>\d+)"), r"(?<y>\d+)");
assert_eq!(javascript_equivalent("(?P<a>x)(?P=a)"), "(?<a>x)\\k<a>");
assert_eq!(javascript_equivalent("(?>a+)b"), "(?:a+)b");
assert_eq!(javascript_equivalent("(?i)abc"), "abc");
assert_eq!(javascript_equivalent("(?s:a.b)"), "(?:a.b)");
assert_eq!(javascript_equivalent("a++b*+"), "a+b*");
assert_eq!(javascript_equivalent("(?#note)a"), "a");
assert_eq!(javascript_equivalent("(?'n'a)"), "(?<n>a)");
assert_eq!(
javascript_equivalent(r"[a+]\+(?<n>x)(?=y)"),
r"[a+]\+(?<n>x)(?=y)"
);
}
#[test]
fn a_slash_at_the_start_opens_a_regex() {
assert!(is_regex_context("/a/", 0));
assert!(is_regex_context("\n/a/", 1));
assert!(is_regex_context(" /a/", 2));
}
#[test]
fn a_slash_after_a_value_is_division() {
assert!(!is_regex_context("a / b", 2));
assert!(!is_regex_context("1 / 2", 2));
assert!(!is_regex_context("] / 2", 2));
assert!(!is_regex_context(")/a/", 1));
}
#[test]
fn a_slash_after_a_slash_is_not_a_regex() {
assert!(!is_regex_context("https://x", 7));
}
#[test]
fn a_keyword_may_be_followed_by_a_regex() {
assert!(is_regex_context("return /a/", 7));
assert!(is_regex_context("case /a/", 5));
assert!(!is_regex_context("count /a/", 6), "not a keyword");
}
#[test]
fn a_non_ascii_letter_does_not_make_an_identifier() {
assert!(is_regex_context("café /a/", 5));
}
#[test]
fn a_keyword_is_read_off_the_end_of_the_identifier() {
assert!(is_regex_context("x = return /a/", 11));
assert!(!is_regex_context("x = noreturn /a/", 13));
}
#[test]
fn an_operator_may_be_followed_by_a_regex() {
assert!(is_regex_context("x = /a/", 4));
assert!(is_regex_context("foo(/a/)", 4));
}
}