@@ -59,9 +59,10 @@ type DFA struct {
5959 prefilter prefilter.Prefilter
6060 pikevm * nfa.PikeVM
6161
62- // stateByID provides O(1) lookup of states by ID
63- // This maps StateID → *State for fast access during search
64- stateByID map [StateID ]* State
62+ // states provides O(1) lookup of states by ID via direct indexing.
63+ // StateIDs are sequential (0, 1, 2...), so slice indexing is faster than map.
64+ // This is a critical optimization - map lookups were 42% of CPU time!
65+ states []* State
6566
6667 // startTable caches start states for different look-behind contexts
6768 // This enables correct handling of assertions (^, \b, etc.) and
@@ -243,7 +244,8 @@ func (d *DFA) SearchAtAnchored(haystack []byte, at int) int {
243244 for pos := at ; pos < len (haystack ); pos ++ {
244245 b := haystack [pos ]
245246
246- if d .checkWordBoundaryMatch (currentState , b ) {
247+ // Skip expensive check for patterns without word boundaries (Issue #105)
248+ if d .hasWordBoundary && d .checkWordBoundaryMatch (currentState , b ) {
247249 return pos
248250 }
249251
@@ -322,8 +324,9 @@ func (d *DFA) isMatchWithPrefilter(haystack []byte) bool {
322324 return false
323325 }
324326
325- // Try to match at candidate - use early termination
326- if d .searchEarliestMatch (haystack , pos ) {
327+ // Try to match at candidate - use ANCHORED search to verify match starts here
328+ // Issue #105: unanchored search caused catastrophic slowdown
329+ if d .searchEarliestMatchAnchored (haystack , pos ) {
327330 return true
328331 }
329332
@@ -335,7 +338,7 @@ func (d *DFA) isMatchWithPrefilter(haystack []byte) bool {
335338 return false
336339 }
337340 pos = candidate
338- if d .searchEarliestMatch (haystack , pos ) {
341+ if d .searchEarliestMatchAnchored (haystack , pos ) {
339342 return true
340343 }
341344 }
@@ -393,7 +396,8 @@ func (d *DFA) searchEarliestMatch(haystack []byte, startPos int) bool {
393396 // This handles patterns like `test\b` where after matching "test",
394397 // the next byte '!' creates a word boundary that satisfies \b.
395398 // We need to detect this match before trying to consume '!'.
396- if d .checkWordBoundaryMatch (currentState , b ) {
399+ // Skip expensive check for patterns without word boundaries (Issue #105)
400+ if d .hasWordBoundary && d .checkWordBoundaryMatch (currentState , b ) {
397401 return true
398402 }
399403
@@ -444,6 +448,75 @@ func (d *DFA) searchEarliestMatch(haystack []byte, startPos int) bool {
444448 return d .checkEOIMatch (currentState )
445449}
446450
451+ // searchEarliestMatchAnchored performs ANCHORED DFA search with early termination.
452+ // Unlike searchEarliestMatch, this requires the match to START exactly at startPos.
453+ // This is critical for prefilter verification - we need to confirm the match
454+ // actually starts at the candidate position, not somewhere after it.
455+ //
456+ // Issue #105: Using unanchored search for prefilter verification caused
457+ // catastrophic slowdown because it would re-scan from candidate to end.
458+ func (d * DFA ) searchEarliestMatchAnchored (haystack []byte , startPos int ) bool {
459+ if startPos > len (haystack ) {
460+ return false
461+ }
462+
463+ // Get ANCHORED start state (requires match to start exactly at startPos)
464+ currentState := d .getStartState (haystack , startPos , true )
465+ if currentState == nil {
466+ // Fallback to NFA with anchored search
467+ start , end , matched := d .pikevm .SearchAt (haystack , startPos )
468+ // For anchored: match must start exactly at startPos
469+ return matched && start == startPos && end >= start
470+ }
471+
472+ // Check if start state is already a match (e.g., empty pattern)
473+ if currentState .IsMatch () {
474+ return true
475+ }
476+
477+ // Scan input byte by byte with early termination
478+ for pos := startPos ; pos < len (haystack ); pos ++ {
479+ b := haystack [pos ]
480+
481+ // Skip expensive check for patterns without word boundaries (Issue #105)
482+ if d .hasWordBoundary && d .checkWordBoundaryMatch (currentState , b ) {
483+ return true
484+ }
485+
486+ // Get next state
487+ classIdx := d .byteToClass (b )
488+ nextID , ok := currentState .Transition (classIdx )
489+ switch {
490+ case ! ok :
491+ nextState , err := d .determinize (currentState , b )
492+ if err != nil {
493+ start , end , matched := d .pikevm .SearchAt (haystack , startPos )
494+ return matched && start == startPos && end >= start
495+ }
496+ if nextState == nil {
497+ return false
498+ }
499+ currentState = nextState
500+
501+ case nextID == DeadState :
502+ return false
503+
504+ default :
505+ currentState = d .getState (nextID )
506+ if currentState == nil {
507+ start , end , matched := d .pikevm .SearchAt (haystack , startPos )
508+ return matched && start == startPos && end >= start
509+ }
510+ }
511+
512+ if currentState .IsMatch () {
513+ return true
514+ }
515+ }
516+
517+ return d .checkEOIMatch (currentState )
518+ }
519+
447520// findWithPrefilterAt searches using prefilter to accelerate unanchored search.
448521// This is used by FindAt to correctly handle anchors when searching from non-zero positions.
449522func (d * DFA ) findWithPrefilterAt (haystack []byte , startAt int ) int {
@@ -801,17 +874,23 @@ func (d *DFA) getState(id StateID) *State {
801874 return nil
802875 }
803876
804- // O(1) lookup via stateByID map
805- state , ok := d . stateByID [ id ]
806- if ! ok {
877+ // O(1) lookup via direct slice indexing (faster than map!)
878+ idx := int ( id )
879+ if idx >= len ( d . states ) {
807880 return nil
808881 }
809- return state
882+ return d . states [ idx ]
810883}
811884
812- // registerState adds a state to the ID-based lookup map
885+ // registerState adds a state to the states slice for O(1) lookup.
886+ // StateIDs are assigned sequentially, so we can use direct indexing.
813887func (d * DFA ) registerState (state * State ) {
814- d .stateByID [state .ID ()] = state
888+ id := int (state .ID ())
889+ // Grow slice if needed
890+ for len (d .states ) <= id {
891+ d .states = append (d .states , nil )
892+ }
893+ d .states [id ] = state
815894}
816895
817896// checkEOIMatch checks if the current state would match at end-of-input.
@@ -828,7 +907,8 @@ func (d *DFA) checkEOIMatch(state *State) bool {
828907 }
829908
830909 // Create a temporary builder for EOI resolution
831- builder := NewBuilder (d .nfa , d .config )
910+ // Use NewBuilderWithWordBoundary to avoid O(states) scan per call (Issue #105)
911+ builder := NewBuilderWithWordBoundary (d .nfa , d .config , d .hasWordBoundary )
832912 return builder .CheckEOIMatch (state .NFAStates (), state .IsFromWord ())
833913}
834914
@@ -853,7 +933,8 @@ func (d *DFA) checkWordBoundaryMatch(state *State, nextByte byte) bool {
853933 return false
854934 }
855935
856- builder := NewBuilder (d .nfa , d .config )
936+ // Use NewBuilderWithWordBoundary to avoid O(states) scan per call (Issue #105)
937+ builder := NewBuilderWithWordBoundary (d .nfa , d .config , d .hasWordBoundary )
857938 isFromWord := state .IsFromWord ()
858939 isNextWord := isWordByte (nextByte )
859940 wordBoundarySatisfied := isFromWord != isNextWord
@@ -1017,7 +1098,7 @@ func (d *DFA) CacheStats() (size int, capacity uint32, hits, misses uint64, hitR
10171098// Primarily useful for testing and benchmarking.
10181099func (d * DFA ) ResetCache () {
10191100 d .cache .Clear ()
1020- d .stateByID = make (map [ StateID ]* State , d .config .MaxStates )
1101+ d .states = make ([ ]* State , 0 , d .config .MaxStates )
10211102
10221103 // Reset StartTable
10231104 d .startTable = NewStartTable ()
0 commit comments