Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **`(?s).*` prefix clipped to the suffix's line** ([#191](https://github.com/coregx/coregex/issues/191)):
with the `s` flag, `(?s).*\.txt` on `"a\nb.txt"` returned `[2 7]` instead of
`[0 7]` (regression in v0.12.15). The line-bounded shortcut of `ReverseSuffix`
and `ReverseSuffixSet` now applies only to a non-dotall `.*`; `(?s).*` takes the
reverse DFA path and gets its greedy end from a forward search, so it ends after
the last suffix in the input, as in `regexp`
- **`Find` returned the rightmost match for `ReverseSuffixSet` patterns**: `\w+\.(txt|log)`
on `"a.txt b.log"` returned `[6 11]` instead of `[0 5]`, and `.*\.txt` on
`"q.txt\nz\nb.txt"` returned `[8 13]` instead of `[0 5]`. `Find` now uses the
same leftmost search as `FindAll`. `ReverseSuffixSet` also takes the match end
from a forward search instead of ending at the first suffix candidate
- **Reverse scan stopped by the anti-quadratic guard returned a clipped start**:
when the reverse DFA of `ReverseSuffix`, `ReverseSuffixSet` or `ReverseInner`
reached the guard position (the end of the previous suffix candidate) still
alive, it returned the match start found so far instead of asking the caller
to retry: `\w+\.(txt|log)` on `"11@.logx.txtbar.txtt"` gave `[7 12]` instead
of `[4 12]`. It now signals the retry in that case and when it reaches the
search start alive with a match that begins later, as Rust's
`meta/limited.rs` does; callers fall back to the PikeVM
- Performance on 1 MB of log text (local, noisy laptop): `Find` for
`.*\.(txt|log|md)` and `\w+\.(txt|log)` drops from ~4 ms to under 1 µs, since it
no longer scans every candidate; `(?s).*\.txt` `Find` now reads up to the last
suffix (~9 ms, previously a wrong early result). `FindAll` on non-dotall `.*`
patterns takes the same code path as before. `ReverseSuffixSet` patterns
without a `.*` prefix (e.g. `\w+\.(txt|log)`) now pay for the forward search in
`FindAll`: local median +15%, equal minimums

### Planned
- Look-around assertions
- ARM NEON SIMD support ([#120](https://github.com/coregx/coregex/issues/120))
Expand Down
11 changes: 10 additions & 1 deletion dfa/lazy/lazy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2111,13 +2111,22 @@ func (d *DFA) SearchReverseLimited(cache *DFACache, haystack []byte, start, end,
}
}

// The loop ended without a dead state. If minStart stopped it before start,
// the match could begin further left: any match recorded so far is clipped,
// so signal Quadratic even when one was found (Rust meta/limited.rs).
if lowerBound > start {
return SearchReverseLimitedQuadratic
}

// EOI for reverse: check delayed match at region start
eoi := cache.getState(sid)
if eoi != nil && containsNFAMatch(d.nfa, eoi.NFAStates()) {
lastMatch = lowerBound
}

if lowerBound > start && lastMatch < 0 {
// Reached start alive with a match that begins after start: Rust treats
// this as a potential false positive and retries with another engine.
if lastMatch > start {
return SearchReverseLimitedQuadratic
}

Expand Down
85 changes: 85 additions & 0 deletions issue191_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package coregex

import (
"fmt"
"reflect"
"regexp"
"testing"
)

// TestIssue191_DotAllPrefixReverseSuffix verifies that a (?s).* prefix is not
// clipped to the suffix's line by the ReverseSuffix and ReverseSuffixSet
// strategies. Without (?s), .* cannot cross \n (#146); with it, it can.
// See https://github.com/coregx/coregex/issues/191
func TestIssue191_DotAllPrefixReverseSuffix(t *testing.T) {
patterns := []string{
// Reported cases (ReverseSuffix)
`(?s).*\.txt`,
`(?s).*@example\.com`,
`(?s)(.*)@example\.com`,
`(?s).*(foo|bar)baz`,
`(?s:.*)\.txt`,
// ReverseSuffixSet
`(?s).*\.(txt|log)`,
// Without (?s): .* must stop at \n (#146), must stay correct
`.*\.txt`,
`.*@example\.com`,
`(.*)@example\.com`,
`.*\.(txt|log)`,
// ReverseSuffixSet Find returned the rightmost match for any prefix
`\w+\.(txt|log)`,
`.+\.(txt|log)`,
// Reverse scan stopped by the anti-quadratic guard must not return a
// clipped start (SearchReverseLimited)
`(?s).*x.*\.(txt|log)`,
`(?i)\w+\.(txt|log)`,
}
inputs := []string{
"a\nb.txt",
"a\nb.log",
"q.txt\nz\nb.txt",
"x\ny@example.com",
"x\ny@example.com\nz@example.com",
"x\nybarbaz",
"a.txt\n",
"\n.txt",
"a.txt\nb.log\nc",
"a.txt b.log",
" bar.txtx.logexample.coma",
"bazafoobaz",
"11@.logx.txtbar.txtt",
".txtl.txt.txtaXbbl",
"no match here",
"",
}

for _, p := range patterns {
re := MustCompile(p)
std := regexp.MustCompile(p)
for _, in := range inputs {
t.Run(fmt.Sprintf("%q/%q", p, in), func(t *testing.T) {
if got, want := re.MatchString(in), std.MatchString(in); got != want {
t.Errorf("MatchString: coregex=%v, stdlib=%v", got, want)
}
if got, want := re.FindStringIndex(in), std.FindStringIndex(in); !reflect.DeepEqual(got, want) {
t.Errorf("FindStringIndex: coregex=%v, stdlib=%v", got, want)
}
if got, want := re.FindAllStringIndex(in, -1), std.FindAllStringIndex(in, -1); !reflect.DeepEqual(got, want) {
t.Errorf("FindAllStringIndex: coregex=%v, stdlib=%v", got, want)
}
if got, want := re.FindStringSubmatchIndex(in), std.FindStringSubmatchIndex(in); !reflect.DeepEqual(got, want) {
t.Errorf("FindStringSubmatchIndex: coregex=%v, stdlib=%v", got, want)
}
if got, want := re.FindAllStringSubmatchIndex(in, -1), std.FindAllStringSubmatchIndex(in, -1); !reflect.DeepEqual(got, want) {
t.Errorf("FindAllStringSubmatchIndex: coregex=%v, stdlib=%v", got, want)
}
if got, want := re.ReplaceAllString(in, "<$0>"), std.ReplaceAllString(in, "<$0>"); got != want {
t.Errorf("ReplaceAllString: coregex=%q, stdlib=%q", got, want)
}
if got, want := re.Split(in, -1), std.Split(in, -1); !reflect.DeepEqual(got, want) {
t.Errorf("Split: coregex=%q, stdlib=%q", got, want)
}
})
}
}
}
15 changes: 3 additions & 12 deletions meta/reverse_suffix.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,10 @@ func (s *ReverseSuffixSearcher) Find(haystack []byte) *Match {
return nil
}

// For matchStartZero (unanchored .* prefix), match starts at the beginning
// of the line containing the LAST suffix — .* (AnyCharNotNL) cannot cross \n.
// For matchStartZero (a non-dotall .* prefix), the leftmost match is on the
// line of the FIRST suffix, not the last one (#191); FindAt implements that.
if s.matchStartZero {
lastPos := bytes.LastIndex(haystack, s.suffixBytes)
if lastPos == -1 {
return nil
}
revEnd := lastPos + s.suffixLen
if revEnd > len(haystack) {
revEnd = len(haystack)
}
matchStart := lineStartBefore(haystack, 0, lastPos)
return NewMatch(matchStart, revEnd, haystack)
return s.FindAt(haystack, 0)
}

// For bounded wildcards (e.g., \d+\.\d+\.35), find the FIRST suffix
Expand Down
100 changes: 28 additions & 72 deletions meta/reverse_suffix_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type ReverseSuffixSetSearcher struct {
suffixLiterals *literal.Seq // All suffix literals
matchStartZero bool // True if pattern starts with .* (match always starts at 0)
revCachePool sync.Pool
fwdCachePool sync.Pool
}

// NewReverseSuffixSetSearcher creates a reverse suffix set searcher.
Expand Down Expand Up @@ -121,81 +122,32 @@ func NewReverseSuffixSetSearcher(
s.revCachePool = sync.Pool{
New: func() any { return s.reverseDFA.NewCache() },
}
s.fwdCachePool = sync.Pool{
New: func() any { return s.forwardDFA.NewCache() },
}
return s, nil
}

// Find searches using Teddy suffix prefilter + reverse DFA.
//
// For greedy matching (like `.*`), we need to find the LAST matching suffix.
// However, with multiple suffix lengths, we iterate through all candidates
// and track the best (rightmost) match.
// Includes anti-quadratic guard to prevent O(n^2) behavior with many suffix false positives.
func (s *ReverseSuffixSetSearcher) Find(haystack []byte) *Match {
if len(haystack) == 0 {
return nil
}

// Acquire cache once for the entire candidate loop
revCache := s.revCachePool.Get().(*lazy.DFACache)
defer s.revCachePool.Put(revCache)

// For greedy matching, find the LAST suffix candidate
// We scan forward and keep track of the last valid match
var lastMatch *Match
start := 0
minStart := 0 // Anti-quadratic guard for reverse scans

for {
// Find next suffix candidate
pos := s.prefilter.Find(haystack, start)
if pos == -1 {
break
}

// Get the length of the matched suffix literal
suffixLen := s.getSuffixLen(haystack, pos)
if suffixLen == 0 {
start = pos + 1
continue
}

suffixEnd := pos + suffixLen
if suffixEnd > len(haystack) {
suffixEnd = len(haystack)
}

// For unanchored patterns, .* cannot cross \n boundaries.
// Match starts at the beginning of the line containing the suffix.
if s.matchStartZero {
matchStart := lineStartBefore(haystack, 0, pos)
lastMatch = NewMatch(matchStart, suffixEnd, haystack)
} else {
// Use reverse DFA with anti-quadratic guard to find match start
matchStart := s.reverseDFA.SearchReverseLimited(revCache, haystack, 0, suffixEnd, minStart)
if matchStart == lazy.SearchReverseLimitedQuadratic {
// Quadratic behavior detected - fall back to PikeVM
pStart, pEnd, found := s.pikevms.searchAt(haystack, 0)
if found {
return NewMatch(pStart, pEnd, haystack)
}
return lastMatch
}
if matchStart >= 0 {
lastMatch = NewMatch(matchStart, suffixEnd, haystack)
}
// Update anti-quadratic guard
if suffixEnd > minStart {
minStart = suffixEnd
}
}

start = pos + 1
if start >= len(haystack) {
break
}
// forwardEnd returns the leftmost-first match that starts at start. The reverse
// DFA only finds where the match starts; the end can extend past the suffix
// candidate (e.g. `(?s).*\.(txt|log)` ends at the LAST suffix), so it comes
// from a forward search, as in ReverseSuffixSearcher.
func (s *ReverseSuffixSetSearcher) forwardEnd(haystack []byte, start int) (int, int, bool) {
fwdCache := s.fwdCachePool.Get().(*lazy.DFACache)
end := s.forwardDFA.SearchAt(fwdCache, haystack, start)
s.fwdCachePool.Put(fwdCache)
if end >= 0 {
return start, end, true
}
return s.pikevms.searchAt(haystack, start)
}

return lastMatch
// Find returns the leftmost-first match, the same as FindAt(haystack, 0).
//
// It must not pick the rightmost suffix candidate: for `\w+\.(txt|log)` on
// "a.txt b.log" the match is [0 5], not [6 11] (#191).
func (s *ReverseSuffixSetSearcher) Find(haystack []byte) *Match {
return s.FindAt(haystack, 0)
}

// FindAt searches for a match starting from position 'at'.
Expand Down Expand Up @@ -269,7 +221,11 @@ func (s *ReverseSuffixSetSearcher) FindAt(haystack []byte, at int) *Match {
// Use reverse DFA with anti-quadratic guard to find match start
matchStart := s.reverseDFA.SearchReverseLimited(revCache, haystack, at, suffixEnd, minStart)
if matchStart >= 0 {
return NewMatch(matchStart, suffixEnd, haystack)
start, end, found := s.forwardEnd(haystack, matchStart)
if found {
return NewMatch(start, end, haystack)
}
return nil
}
if matchStart == lazy.SearchReverseLimitedQuadratic {
// Quadratic behavior detected - fall back to PikeVM
Expand Down Expand Up @@ -374,7 +330,7 @@ func (s *ReverseSuffixSetSearcher) findIndicesAtImpl(haystack []byte, at int, re
// Use reverse DFA with anti-quadratic guard to find match start
matchStart := s.reverseDFA.SearchReverseLimited(revCache, haystack, at, suffixEnd, minStart)
if matchStart >= 0 {
return matchStart, suffixEnd, true
return s.forwardEnd(haystack, matchStart)
}
if matchStart == lazy.SearchReverseLimitedQuadratic {
// Quadratic behavior detected - fall back to PikeVM
Expand Down
15 changes: 9 additions & 6 deletions meta/strategy.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,12 +558,16 @@ func isDigitRunSkipSafe(re *syntax.Regexp) bool {
// - Internal anchors: `0?^0`, `a$b` - position constraints don't reverse
// - Star of CharClass: `[^\s]*suffix` - zero-width match edge cases

// hasDotStarPrefix checks if a pattern has `.*` as its first subexpression.
// Only `.*` (OpStar of AnyChar/AnyCharNotNL) guarantees matching from position 0
// to any position without fail. Used to determine if reverse DFA scan can be skipped.
// hasDotStarPrefix checks if a pattern starts with a non-dotall `.*`
// (OpStar of AnyCharNotNL). Such a prefix matches anything up to the start of
// the suffix's line, so the reverse suffix searchers can bound the match by
// lines instead of running the reverse DFA.
//
// A dotall `(?s).*` (OpStar of AnyChar) is excluded: it crosses \n, so the
// line bound would clip the match (#191). It takes the reverse DFA path.
//
// Returns true for: `.*\.txt`, `(.*)\.txt`
// Returns false for: `.+\.txt`, `[^\s]+\.txt`, `\w{2,8}\.txt`, `foo|bar`
// Returns false for: `(?s).*\.txt`, `.+\.txt`, `[^\s]+\.txt`, `\w{2,8}\.txt`, `foo|bar`
func hasDotStarPrefix(re *syntax.Regexp) bool {
// Unwrap captures
for re.Op == syntax.OpCapture && len(re.Sub) > 0 {
Expand All @@ -577,9 +581,8 @@ func hasDotStarPrefix(re *syntax.Regexp) bool {
for first.Op == syntax.OpCapture && len(first.Sub) > 0 {
first = first.Sub[0]
}
// .* = OpStar(OpAnyChar or OpAnyCharNotNL)
return first.Op == syntax.OpStar && len(first.Sub) > 0 &&
(first.Sub[0].Op == syntax.OpAnyChar || first.Sub[0].Op == syntax.OpAnyCharNotNL)
first.Sub[0].Op == syntax.OpAnyCharNotNL
}

// isWildcardSubexpression checks if a subexpression acts as a "wildcard" that can
Expand Down
Loading