diff --git a/regex-automata/src/dfa/dense.rs b/regex-automata/src/dfa/dense.rs index 8b41d0ae5..43973a92a 100644 --- a/regex-automata/src/dfa/dense.rs +++ b/regex-automata/src/dfa/dense.rs @@ -5084,7 +5084,7 @@ impl BuildError { } pub(crate) fn unsupported_lookaround() -> BuildError { - let msg = "cannot build DFAs for regexes with look-around\ + let msg = "cannot build DFAs for regexes with look-around \ sub-expressions; use a different regex engine"; BuildError { kind: BuildErrorKind::Unsupported(msg) } } diff --git a/regex-automata/src/dfa/onepass.rs b/regex-automata/src/dfa/onepass.rs index 30e4daf06..b75feac45 100644 --- a/regex-automata/src/dfa/onepass.rs +++ b/regex-automata/src/dfa/onepass.rs @@ -602,6 +602,9 @@ impl<'a> InternalBuilder<'a> { )); } assert_eq!(DEAD, self.add_empty_state()?); + if self.nfa.lookaround_count() > 0 { + return Err(BuildError::unsupported_lookaround()); + } // This is where the explicit slots start. We care about this because // we only need to track explicit slots. The implicit slots---two for @@ -640,7 +643,7 @@ impl<'a> InternalBuilder<'a> { match *self.nfa.state(id) { thompson::State::WriteLookAround { .. } | thompson::State::CheckLookAround { .. } => { - todo!("check how to handle") + return Err(BuildError::unsupported_lookaround()); } thompson::State::ByteRange { ref trans } => { self.compile_transition(dfa_id, trans, epsilons)?; @@ -3000,6 +3003,7 @@ enum BuildErrorKind { UnsupportedLook { look: Look }, ExceededSizeLimit { limit: usize }, NotOnePass { msg: &'static str }, + UnsupportedLookAround, } impl BuildError { @@ -3030,6 +3034,10 @@ impl BuildError { fn not_one_pass(msg: &'static str) -> BuildError { BuildError { kind: BuildErrorKind::NotOnePass { msg } } } + + fn unsupported_lookaround() -> BuildError { + BuildError { kind: BuildErrorKind::UnsupportedLookAround } + } } #[cfg(feature = "std")] @@ -3078,6 +3086,9 @@ impl core::fmt::Display for BuildError { pattern is not one-pass: {}", msg, ), + UnsupportedLookAround => { + write!(f, "one-pass DFA does not support look-arounds") + } } } } diff --git a/regex-automata/src/hybrid/error.rs b/regex-automata/src/hybrid/error.rs index ae3ae6c53..062b9ac62 100644 --- a/regex-automata/src/hybrid/error.rs +++ b/regex-automata/src/hybrid/error.rs @@ -63,7 +63,7 @@ impl BuildError { } pub(crate) fn unsupported_lookaround() -> BuildError { - let msg = "cannot build DFAs for regexes with look-around\ + let msg = "cannot build DFAs for regexes with look-around \ sub-expressions; use a different regex engine"; BuildError { kind: BuildErrorKind::Unsupported(msg) } } diff --git a/regex-automata/src/meta/strategy.rs b/regex-automata/src/meta/strategy.rs index 04f2ba3c3..0ac830b9d 100644 --- a/regex-automata/src/meta/strategy.rs +++ b/regex-automata/src/meta/strategy.rs @@ -490,49 +490,52 @@ impl Core { // we know we aren't going to use the lazy DFA. So we do a config check // up front, which is in practice the only way we won't try to use the // DFA. - let (nfarev, hybrid, dfa) = - if !info.config().get_hybrid() && !info.config().get_dfa() { - (None, wrappers::Hybrid::none(), wrappers::DFA::none()) + let (nfarev, hybrid, dfa) = if !info.config().get_hybrid() + && !info.config().get_dfa() + // With look-arounds, the lazy DFA and dense DFA would fail to build + || nfa.lookaround_count() > 0 + { + (None, wrappers::Hybrid::none(), wrappers::DFA::none()) + } else { + // FIXME: Technically, we don't quite yet KNOW that we need + // a reverse NFA. It's possible for the DFAs below to both + // fail to build just based on the forward NFA. In which case, + // building the reverse NFA was totally wasted work. But... + // fixing this requires breaking DFA construction apart into + // two pieces: one for the forward part and another for the + // reverse part. Quite annoying. Making it worse, when building + // both DFAs fails, it's quite likely that the NFA is large and + // that it will take quite some time to build the reverse NFA + // too. So... it's really probably worth it to do this! + let nfarev = thompson::Compiler::new() + // Currently, reverse NFAs don't support capturing groups, + // so we MUST disable them. But even if we didn't have to, + // we would, because nothing in this crate does anything + // useful with capturing groups in reverse. And of course, + // the lazy DFA ignores capturing groups in all cases. + .configure( + thompson_config + .clone() + .which_captures(WhichCaptures::None) + .reverse(true), + ) + .build_many_from_hir(hirs) + .map_err(BuildError::nfa)?; + let dfa = if !info.config().get_dfa() { + wrappers::DFA::none() } else { - // FIXME: Technically, we don't quite yet KNOW that we need - // a reverse NFA. It's possible for the DFAs below to both - // fail to build just based on the forward NFA. In which case, - // building the reverse NFA was totally wasted work. But... - // fixing this requires breaking DFA construction apart into - // two pieces: one for the forward part and another for the - // reverse part. Quite annoying. Making it worse, when building - // both DFAs fails, it's quite likely that the NFA is large and - // that it will take quite some time to build the reverse NFA - // too. So... it's really probably worth it to do this! - let nfarev = thompson::Compiler::new() - // Currently, reverse NFAs don't support capturing groups, - // so we MUST disable them. But even if we didn't have to, - // we would, because nothing in this crate does anything - // useful with capturing groups in reverse. And of course, - // the lazy DFA ignores capturing groups in all cases. - .configure( - thompson_config - .clone() - .which_captures(WhichCaptures::None) - .reverse(true), - ) - .build_many_from_hir(hirs) - .map_err(BuildError::nfa)?; - let dfa = if !info.config().get_dfa() { - wrappers::DFA::none() - } else { - wrappers::DFA::new(&info, pre.clone(), &nfa, &nfarev) - }; - let hybrid = if !info.config().get_hybrid() { - wrappers::Hybrid::none() - } else if dfa.is_some() { - debug!("skipping lazy DFA because we have a full DFA"); - wrappers::Hybrid::none() - } else { - wrappers::Hybrid::new(&info, pre.clone(), &nfa, &nfarev) - }; - (Some(nfarev), hybrid, dfa) + wrappers::DFA::new(&info, pre.clone(), &nfa, &nfarev) }; + let hybrid = if !info.config().get_hybrid() { + wrappers::Hybrid::none() + } else if dfa.is_some() { + debug!("skipping lazy DFA because we have a full DFA"); + wrappers::Hybrid::none() + } else { + wrappers::Hybrid::new(&info, pre.clone(), &nfa, &nfarev) + }; + (Some(nfarev), hybrid, dfa) + }; Ok(Core { info, pre, diff --git a/regex-automata/src/nfa/thompson/backtrack.rs b/regex-automata/src/nfa/thompson/backtrack.rs index be0cbcfbd..eb36d1829 100644 --- a/regex-automata/src/nfa/thompson/backtrack.rs +++ b/regex-automata/src/nfa/thompson/backtrack.rs @@ -301,6 +301,9 @@ impl Builder { nfa: NFA, ) -> Result { nfa.look_set_any().available().map_err(BuildError::word)?; + if nfa.lookaround_count() > 0 { + return Err(BuildError::unsupported_lookarounds()); + } Ok(BoundedBacktracker { config: self.config.clone(), nfa }) } @@ -1453,7 +1456,7 @@ impl BoundedBacktracker { /// Execute a "step" in the backtracing algorithm. /// /// A "step" is somewhat of a misnomer, because this routine keeps going - /// until it either runs out of things to try or fins a match. In the + /// until it either runs out of things to try or finds a match. In the /// former case, it may have pushed some things on to the backtracking /// stack, in which case, those will be tried next as part of the /// 'backtrack' routine above. @@ -1521,7 +1524,9 @@ impl BoundedBacktracker { } State::WriteLookAround { .. } | State::CheckLookAround { .. } => { - todo!("check how to handle") + unimplemented!( + "backtracking engine does not support look-arounds" + ); } State::Union { ref alternates } => { sid = match alternates.get(0) { diff --git a/regex-automata/src/nfa/thompson/builder.rs b/regex-automata/src/nfa/thompson/builder.rs index 748d1d01c..c769fda23 100644 --- a/regex-automata/src/nfa/thompson/builder.rs +++ b/regex-automata/src/nfa/thompson/builder.rs @@ -92,7 +92,7 @@ enum State { next: StateID, }, /// An empty state that behaves analogously to a `Match` state but for - /// the look-around sub-expression with the given index. + /// the look-around sub-expression with the given look-around index. WriteLookAround { lookaround_index: SmallIndex }, /// A conditional epsilon transition that will only be taken if the /// look-around sub-expression with the given index evaluates to `positive` @@ -484,9 +484,8 @@ impl Builder { remap[sid] = nfa.add(nfa::State::Look { look, next }); } State::WriteLookAround { lookaround_index } => { - remap[sid] = nfa.add(nfa::State::WriteLookAround { - lookaround_idx: lookaround_index, - }); + remap[sid] = nfa + .add(nfa::State::WriteLookAround { lookaround_index }); } State::CheckLookAround { lookaround_index, @@ -494,7 +493,7 @@ impl Builder { next, } => { remap[sid] = nfa.add(nfa::State::CheckLookAround { - lookaround_idx: lookaround_index, + lookaround_index, positive, next, }); @@ -722,7 +721,7 @@ impl Builder { self.add(State::Empty { next: StateID::ZERO }) } - /// Add a state which will record that the lookaround with the given index + /// Add a state which will record that the look-around with the given index /// is satisfied at the current position. pub fn add_write_lookaround( &mut self, @@ -731,7 +730,7 @@ impl Builder { self.add(State::WriteLookAround { lookaround_index: index }) } - /// Add a state which will check whether the lookaround with the given + /// Add a state which will check whether the look-around with the given /// index is satisfied at the current position. pub fn add_check_lookaround( &mut self, diff --git a/regex-automata/src/nfa/thompson/compiler.rs b/regex-automata/src/nfa/thompson/compiler.rs index 7848699ed..7a9393d1e 100644 --- a/regex-automata/src/nfa/thompson/compiler.rs +++ b/regex-automata/src/nfa/thompson/compiler.rs @@ -954,6 +954,13 @@ impl Compiler { { return Err(BuildError::unsupported_captures()); } + if self.config.get_reverse() + && exprs.iter().any(|e| { + (e.borrow() as &Hir).properties().contains_lookaround_expr() + }) + { + return Err(BuildError::unsupported_lookarounds()); + } self.builder.borrow_mut().clear(); self.builder.borrow_mut().set_utf8(self.config.get_utf8()); @@ -2036,14 +2043,14 @@ mod tests { fn s_write_lookaround(id: usize) -> State { State::WriteLookAround { - lookaround_idx: SmallIndex::new(id) + lookaround_index: SmallIndex::new(id) .expect("look-around index too large"), } } fn s_check_lookaround(id: usize, positive: bool, next: usize) -> State { State::CheckLookAround { - lookaround_idx: SmallIndex::new(id) + lookaround_index: SmallIndex::new(id) .expect("look-around index too large"), positive, next: sid(next), @@ -2151,6 +2158,29 @@ mod tests { ); } + #[test] + fn compile_yes_unanchored_prefix_with_start_anchor_in_lookaround() { + let nfa = NFA::compiler() + .configure(NFA::config().which_captures(WhichCaptures::None)) + .build(r"(?<=^)a") + .unwrap(); + assert_eq!( + nfa.states(), + &[ + s_bin_union(2, 1), + s_range(0, 255, 0), + s_bin_union(3, 6), + s_bin_union(5, 4), + s_range(0, 255, 3), + s_look(Look::Start, 7), + s_check_lookaround(0, true, 8), + s_write_lookaround(0), + s_byte(b'a', 9), + s_match(0) + ] + ); + } + #[test] fn compile_empty() { assert_eq!(build("").states(), &[s_match(0),]); diff --git a/regex-automata/src/nfa/thompson/error.rs b/regex-automata/src/nfa/thompson/error.rs index a1f5aed5c..d2b8c796c 100644 --- a/regex-automata/src/nfa/thompson/error.rs +++ b/regex-automata/src/nfa/thompson/error.rs @@ -81,6 +81,13 @@ enum BuildErrorKind { /// should support it at some point. #[cfg(feature = "syntax")] UnsupportedCaptures, + /// An error that occurs when one tries to build a reverse NFA with + /// look-around sub-expressions. Currently, this isn't supported, but we + /// probably should support it at some point. + /// + /// This is also emmitted by the backtracking engine which does not + /// support look-around sub-expressions. + UnsupportedLookArounds, } impl BuildError { @@ -142,6 +149,10 @@ impl BuildError { pub(crate) fn unsupported_captures() -> BuildError { BuildError { kind: BuildErrorKind::UnsupportedCaptures } } + + pub(crate) fn unsupported_lookarounds() -> BuildError { + BuildError { kind: BuildErrorKind::UnsupportedLookArounds } + } } #[cfg(feature = "std")] @@ -201,6 +212,11 @@ impl core::fmt::Display for BuildError { "currently captures must be disabled when compiling \ a reverse NFA", ), + BuildErrorKind::UnsupportedLookArounds => write!( + f, + "currently look-around sub-expressions cannot be in the pattern \ + when compiling a reverse NFA or using the backtracking engine", + ), } } } diff --git a/regex-automata/src/nfa/thompson/nfa.rs b/regex-automata/src/nfa/thompson/nfa.rs index 4e499466f..2657540cb 100644 --- a/regex-automata/src/nfa/thompson/nfa.rs +++ b/regex-automata/src/nfa/thompson/nfa.rs @@ -1100,7 +1100,7 @@ impl NFA { self.0.look_set_prefix_any } - /// Returns how many look-around sub-expressions this nfa contains + /// Returns how many look-around sub-expressions this nfa contains. #[inline] pub fn lookaround_count(&self) -> usize { self.0.lookaround_count @@ -1268,7 +1268,7 @@ pub(super) struct Inner { */ /// How many look-around expression this NFA contains. /// This is needed to initialize the table for storing the result of - /// look-around evaluation + /// look-around evaluation. lookaround_count: usize, /// Heap memory used indirectly by NFA states and other things (like the /// various capturing group representations above). Since each state @@ -1385,10 +1385,10 @@ impl Inner { State::Capture { .. } => { self.has_capture = true; } - State::CheckLookAround { lookaround_idx: look_idx, .. } - | State::WriteLookAround { lookaround_idx: look_idx } => { + State::CheckLookAround { lookaround_index, .. } + | State::WriteLookAround { lookaround_index } => { self.lookaround_count = - self.lookaround_count.max(look_idx.as_usize() + 1); + self.lookaround_count.max(lookaround_index.as_usize() + 1); } State::Union { .. } | State::BinaryUnion { .. } @@ -1566,19 +1566,19 @@ pub enum State { }, /// This is like a match state but for a look-around expression. /// Executing this state will write the current haystack offset into the - /// look-around oracle at index `lookaround_idx`. + /// look-around oracle at index `lookaround_index`. WriteLookAround { /// The index of the look-around expression that matches. - lookaround_idx: SmallIndex, + lookaround_index: SmallIndex, }, - /// This indicates that we need to check whether lookaround expression with - /// index `lookaround_idx` holds at the current position in the haystack - /// If `positive` is false, then the lookaround expression is negative and + /// This indicates that we need to check whether look-around expression with + /// index `lookaround_index` holds at the current position in the haystack. + /// If `positive` is false, then the look-around expression is negative and /// hence must NOT hold. CheckLookAround { /// The index of the look-around expression that must be satisfied. - lookaround_idx: SmallIndex, - /// Whether this is a positive lookaround expression. + lookaround_index: SmallIndex, + /// Whether this is a positive look-around expression. positive: bool, /// The next state to transition if the look-around assertion is /// satisfied. @@ -1795,18 +1795,14 @@ impl fmt::Debug for State { State::Look { ref look, next } => { write!(f, "{:?} => {:?}", look, next.as_usize()) } - State::WriteLookAround { lookaround_idx: look_idx } => { - write!(f, "write-look-around({})", look_idx.as_u32()) + State::WriteLookAround { lookaround_index } => { + write!(f, "write-look-around({})", lookaround_index.as_u32()) } - State::CheckLookAround { - lookaround_idx: look_idx, - positive, - next, - } => { + State::CheckLookAround { lookaround_index, positive, next } => { write!( f, "check-look-around({} is {}) => {}", - look_idx.as_u32(), + lookaround_index.as_u32(), if positive { "matched" } else { "not matched" }, next.as_usize() ) diff --git a/regex-automata/src/nfa/thompson/pikevm.rs b/regex-automata/src/nfa/thompson/pikevm.rs index cf667940e..eb40bf1a9 100644 --- a/regex-automata/src/nfa/thompson/pikevm.rs +++ b/regex-automata/src/nfa/thompson/pikevm.rs @@ -1772,17 +1772,17 @@ impl PikeVM { } sid = next; } - State::WriteLookAround { lookaround_idx: look_idx } => { + State::WriteLookAround { lookaround_index } => { // This is ok since `at` is always less than `usize::MAX`. - lookarounds[look_idx] = NonMaxUsize::new(at); + lookarounds[lookaround_index] = NonMaxUsize::new(at); return; } State::CheckLookAround { - lookaround_idx: look_idx, + lookaround_index, positive, next, } => { - let state = match lookarounds[look_idx] { + let state = match lookarounds[lookaround_index] { None => usize::MAX, Some(pos) => pos.get(), }; @@ -1973,8 +1973,8 @@ pub struct Cache { /// next byte in the haystack. next: ActiveStates, /// This answers the question: "What is the maximum position in the - /// haystack at which lookaround assertion x holds and which is <= to the - /// current position" + /// haystack at which look-around indexed x holds and which is <= to the + /// current position". lookaround: Vec>, } diff --git a/regex-automata/src/util/determinize/mod.rs b/regex-automata/src/util/determinize/mod.rs index 80f57bbe6..bdcb4e025 100644 --- a/regex-automata/src/util/determinize/mod.rs +++ b/regex-automata/src/util/determinize/mod.rs @@ -253,7 +253,7 @@ pub(crate) fn next( | thompson::State::Capture { .. } => {} thompson::State::CheckLookAround { .. } | thompson::State::WriteLookAround { .. } => { - todo!("check how to handle") + unimplemented!("look-around support in DFA") } thompson::State::Match { pattern_id } => { // Notice here that we are calling the NEW state a match @@ -405,7 +405,7 @@ pub(crate) fn epsilon_closure( | thompson::State::Match { .. } => break, thompson::State::WriteLookAround { .. } | thompson::State::CheckLookAround { .. } => { - todo!("check how to handle") + unimplemented!("look-around support in DFA") } thompson::State::Look { look, next } => { if !look_have.contains(look) { @@ -475,7 +475,7 @@ pub(crate) fn add_nfa_states( } thompson::State::CheckLookAround { .. } | thompson::State::WriteLookAround { .. } => { - todo!("check how to handle") + unimplemented!("look-around support in DFA") } thompson::State::Union { .. } | thompson::State::BinaryUnion { .. } => { diff --git a/regex-automata/tests/dfa/suite.rs b/regex-automata/tests/dfa/suite.rs index febded611..aa43cc7e6 100644 --- a/regex-automata/tests/dfa/suite.rs +++ b/regex-automata/tests/dfa/suite.rs @@ -289,20 +289,16 @@ fn compiler( } } } + // Or look-around expressions. + for hir in hirs.iter() { + if hir.properties().contains_lookaround_expr() { + return Ok(CompiledRegex::skip()); + } + } if !configure_regex_builder(test, &mut builder) { return Ok(CompiledRegex::skip()); } - let re = match builder.build_many(regexes) { - Ok(re) => re, - Err(err) - if test.compiles() - && format!("{err}").contains("look-around") => - { - return Ok(CompiledRegex::skip()); - } - Err(err) => return Err(err.into()), - }; - create_matcher(&builder, pre, re) + create_matcher(&builder, pre, builder.build_many(regexes)?) } } diff --git a/regex-automata/tests/hybrid/suite.rs b/regex-automata/tests/hybrid/suite.rs index ee81aca8d..65769f001 100644 --- a/regex-automata/tests/hybrid/suite.rs +++ b/regex-automata/tests/hybrid/suite.rs @@ -180,19 +180,16 @@ fn compiler( } } } + // Or look-around expressions. + for hir in hirs.iter() { + if hir.properties().contains_lookaround_expr() { + return Ok(CompiledRegex::skip()); + } + } if !configure_regex_builder(test, &mut builder) { return Ok(CompiledRegex::skip()); } - let re = match builder.build_many(regexes) { - Ok(re) => re, - Err(err) - if test.compiles() - && format!("{err}").contains("look-around") => - { - return Ok(CompiledRegex::skip()); - } - Err(err) => return Err(err.into()), - }; + let re = builder.build_many(®exes)?; let mut cache = re.create_cache(); Ok(CompiledRegex::compiled(move |test| -> TestResult { run_test(&re, &mut cache, test) diff --git a/regex-automata/tests/nfa/thompson/backtrack/suite.rs b/regex-automata/tests/nfa/thompson/backtrack/suite.rs index 674ce5039..b0aa0fc6c 100644 --- a/regex-automata/tests/nfa/thompson/backtrack/suite.rs +++ b/regex-automata/tests/nfa/thompson/backtrack/suite.rs @@ -74,7 +74,7 @@ fn min_visited_capacity() -> Result<()> { .configure(config_thompson(test)) .syntax(config_syntax(test)) .build_many(®exes)?; - // TODO: remove once look-around is supported. + // The backtracker doesn't support lookarounds, so skip if there are any. if nfa.lookaround_count() > 0 { return Ok(CompiledRegex::skip()); } @@ -108,11 +108,17 @@ fn compiler( if !configure_backtrack_builder(test, &mut builder) { return Ok(CompiledRegex::skip()); } - let re = builder.build_many(®exes)?; - // TODO: remove once look-around is supported. - if re.get_nfa().lookaround_count() > 0 { - return Ok(CompiledRegex::skip()); - } + let re = match builder.build_many(®exes) { + Ok(re) => re, + // Due to errors being opaque, we need to check the error message to skip tests with look-arounds + Err(err) => { + if test.compiles() && err.to_string().contains("look-around") { + return Ok(CompiledRegex::skip()); + } + + return Err(err.into()); + } + }; let mut cache = re.create_cache(); Ok(CompiledRegex::compiled(move |test| -> TestResult { run_test(&re, &mut cache, test) diff --git a/regex-syntax/src/hir/literal.rs b/regex-syntax/src/hir/literal.rs index 584c2893b..f419dd70e 100644 --- a/regex-syntax/src/hir/literal.rs +++ b/regex-syntax/src/hir/literal.rs @@ -2456,16 +2456,16 @@ mod tests { #[test] fn lookaround() { - assert_eq!(exact(["ab"]), e(r"a(?<=qwa)b")); - assert_eq!(exact(["ab"]), e(r"a(? &mut Hir { match self { Self::PositiveLookBehind(sub) | Self::NegativeLookBehind(sub) => { @@ -2138,7 +2138,7 @@ impl Properties { /// Returns whether there are any look-around expressions in this HIR value. /// /// Only returns true for [`HirKind::LookAround`] and not for - /// [`HirKind::Look`], which can be queried by [`look_set`] instead. + /// [`HirKind::Look`], which can be queried by [`look_set`](Properties::look_set) instead. #[inline] pub fn contains_lookaround_expr(&self) -> bool { self.0.contains_lookaround_expr @@ -2556,7 +2556,7 @@ impl Properties { look_set_prefix_any: LookSet::singleton(look), look_set_suffix_any: LookSet::singleton(look), // Note, this field represents _general_ lookarounds (ones using - // LookAround) and not simple ones (using Look). + // LookAround) and not assertions (using Look). contains_lookaround_expr: false, // This requires a little explanation. Basically, we don't consider // matching an empty string to be equivalent to matching invalid @@ -2589,6 +2589,10 @@ impl Properties { literal: false, alternation_literal: false, contains_lookaround_expr: true, + // We do not want look-around subexpressions to influence matching + // of the main expression when they contain anchors, so we clear the set. + look_set_prefix: LookSet::empty(), + look_set_suffix: LookSet::empty(), ..*sub_p.0.clone() }; Properties(Box::new(inner)) diff --git a/testdata/lookaround.toml b/testdata/lookaround.toml index ecbd76d48..8818a8f1a 100644 --- a/testdata/lookaround.toml +++ b/testdata/lookaround.toml @@ -26,34 +26,49 @@ matches = [] name = "lookbehind in quantifier non-repeating" regex = "(?:(?<=c)a)+" haystack = "badacacaea" -matches = [[5,6], [7,8]] +matches = [[5, 6], [7, 8]] [[test]] name = "lookbehind in quantifier repeating" regex = "(?:(?<=a)a)+" haystack = "babaabaaabaaaac" -matches = [[4,5], [7,9], [11,14]] +matches = [[4, 5], [7, 9], [11, 14]] [[test]] name = "lookbehind with quantifier" regex = "(?<=cb+)a" haystack = "acabacbacbbaea" -matches = [[7,8], [11,12]] +matches = [[7, 8], [11, 12]] [[test]] name = "nested lookbehind" regex = "(?<=c[def]+(?