From 6352036d4232f16a5cff793a25a08c0bb4ce3e2f Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 10:26:25 +0300 Subject: [PATCH 01/12] =?UTF-8?q?chore:=20upgrade=20golangci-lint=20v2.13?= =?UTF-8?q?=20=E2=86=92=20v2.14=20(Go=201.27.2=20compat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go 1.27.2 uses export data version 5, golangci-lint v2.13 supports only version 4. CI failed on v0.12.27 merge commit. --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aaea6f4..4c53504 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -108,7 +108,7 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.13 + version: v2.14 args: --timeout=5m # Formatting check From b4e347b66266e24f543e58d614447b888fae1cc3 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 15:59:57 +0300 Subject: [PATCH 02/12] feat: add tagQuit to DFA StateID, LookInvalidUTF8 to NFA Look (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1, steps 1+3: foundation for invalid UTF-8 handling. - dfa/lazy/state.go: rename tagReserved (bit 29) to tagQuit, add QuitState constant and IsQuitTag() method. Signals DFA cannot handle the current byte — search must fall back to NFA. - nfa/nfa.go: add LookInvalidUTF8 look-around assertion. Two-part semantics: position is at rune boundary AND utf8.DecodeRune returns (RuneError, 1). Fix lookNames array to include all 7 look kinds. No behavioral changes — constants and types only. --- dfa/lazy/state.go | 19 ++++++++++++++++--- nfa/nfa.go | 13 ++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/dfa/lazy/state.go b/dfa/lazy/state.go index abdee8a..f9b5b92 100644 --- a/dfa/lazy/state.go +++ b/dfa/lazy/state.go @@ -15,8 +15,8 @@ import ( // // Layout (Rust LazyStateID approach, hybrid/id.rs:169): // -// [invalid|dead|reserved|start|match| 27 bits: offset into flatTrans ] -// bit 31 30 29 28 27 bits 0-26 +// [invalid|dead|quit|start|match| 27 bits: offset into flatTrans ] +// bit 31 30 29 28 27 bits 0-26 // // Hot loop: nextSID = flatTrans[sid & TagMask + classIdx] // @@ -29,7 +29,7 @@ type StateID uint32 const ( tagInvalid StateID = 1 << 31 // Unknown/not yet computed transition tagDead StateID = 1 << 30 // Dead state — no match possible - tagReserved StateID = 1 << 29 // Reserved for quit + tagQuit StateID = 1 << 29 // DFA cannot handle this byte — fall back to NFA tagStart StateID = 1 << 28 // Start state tagMatch StateID = 1 << 27 // Match/accepting state @@ -50,6 +50,11 @@ const ( // DeadState represents a dead/failure state — no match possible. DeadState StateID = tagDead // 0x40000000 + // QuitState signals that the DFA cannot handle the current byte and + // the search must fall back to NFA (PikeVM). Used for invalid UTF-8 + // sequences that require look-ahead the DFA cannot express. + QuitState StateID = tagQuit // 0x20000000 + // StartState is the initial state. Offset 0, untagged. StartState StateID = 0 ) @@ -91,6 +96,14 @@ func (sid StateID) IsInvalidTag() bool { return sid&tagInvalid != 0 } +// IsQuitTag returns true if this state has the quit tag. +// Quit means the DFA cannot handle this byte and must fall back to NFA. +// +//go:nosplit +func (sid StateID) IsQuitTag() bool { + return sid&tagQuit != 0 +} + // IsStartTag returns true if this state has the start tag. // Start-tagged states always enter the slow path in the DFA search loop, // enabling prefilter skip-ahead only at start states (Rust LazyStateID approach). diff --git a/nfa/nfa.go b/nfa/nfa.go index d668617..5b7c11a 100644 --- a/nfa/nfa.go +++ b/nfa/nfa.go @@ -113,6 +113,14 @@ const ( // LookNoWordBoundary matches a non-word boundary (\B) // Matches where is_word_char(prev) == is_word_char(curr) LookNoWordBoundary + + // LookInvalidUTF8 matches a position where an invalid UTF-8 byte starts. + // Two conditions must hold: + // 1. Position is at a rune boundary (not inside a valid multi-byte sequence) + // 2. utf8.DecodeRune(haystack[pos:]) returns (RuneError, 1) + // Gates a single-byte 0x80-0xFF transition to match invalid bytes as U+FFFD. + // See design doc: docs/dev/research/design-179-invalid-utf8.md + LookInvalidUTF8 ) // State represents a single NFA state with its transitions. @@ -267,7 +275,10 @@ func (s *State) String() string { case StateFail: return fmt.Sprintf("State(%d, Fail)", s.id) case StateLook: - lookNames := []string{"StartText", "EndText", "StartLine", "EndLine"} + lookNames := []string{ + "StartText", "EndText", "StartLine", "EndLine", + "WordBoundary", "NoWordBoundary", "InvalidUTF8", + } lookName := "Unknown" if int(s.look) < len(lookNames) { lookName = lookNames[s.look] From 81088052ea0b3cb10707a1f58355f26b295c1c80 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 18:00:53 +0300 Subject: [PATCH 03/12] feat: handle QuitState in all DFA search loops (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1, step 2: add QuitState fallback to all 9 DFA search switch statements. On QuitState, the DFA falls back to PikeVM for the current find call (find-level fallback, not byte-level). Forward searches: nfaFallback(haystack, startPos) or pikevm.SearchAt Reverse searches: nfaFallbackReverse(haystack, start, end) or pikevm.Search No behavioral changes yet — nothing produces QuitState transitions. That comes in step 4 (temporary 3c quit on bytes >= 0x80). --- dfa/lazy/lazy.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/dfa/lazy/lazy.go b/dfa/lazy/lazy.go index 0820da6..de0b9a3 100644 --- a/dfa/lazy/lazy.go +++ b/dfa/lazy/lazy.go @@ -298,6 +298,9 @@ func (d *DFA) SearchAtAnchored(cache *DFACache, haystack []byte, at int) int { case DeadState: return lastMatch + case QuitState: + return d.nfaFallback(haystack, at) + default: sid = nextID } @@ -493,6 +496,8 @@ func (d *DFA) searchFirstAt(cache *DFACache, haystack []byte, startPos int) int ftLen = len(ft) case DeadState: return lastMatch + case QuitState: + return d.nfaFallback(haystack, startPos) default: sid = nextID } @@ -774,6 +779,10 @@ func (d *DFA) searchEarliestMatch(cache *DFACache, haystack []byte, startPos int case DeadState: goto earliestPreSkip + case QuitState: + start, end, matched := d.pikevm.SearchAt(haystack, startPos) + return matched && start >= 0 && end >= start + default: sid = nextID } @@ -901,6 +910,10 @@ func (d *DFA) searchEarliestMatchAnchored(cache *DFACache, haystack []byte, star case DeadState: return false + case QuitState: + start, end, matched := d.pikevm.SearchAt(haystack, startPos) + return matched && start == startPos && end >= start + default: sid = nextID } @@ -1045,6 +1058,9 @@ func (d *DFA) findWithPrefilterAt(cache *DFACache, haystack []byte, startAt int) lastMatch = -1 continue + case QuitState: + return d.nfaFallback(haystack, 0) + default: sid = nextID } @@ -1289,6 +1305,8 @@ func (d *DFA) searchAt(cache *DFACache, haystack []byte, startPos int) int { //n ftLen = len(ft) case DeadState: return lastMatch + case QuitState: + return d.nfaFallback(haystack, startPos) default: sid = nextID } @@ -1894,6 +1912,9 @@ func (d *DFA) SearchReverse(cache *DFACache, haystack []byte, start, end int) in case DeadState: return lastMatch + case QuitState: + return d.nfaFallbackReverse(haystack, start, end) + default: sid = nextID } @@ -2011,6 +2032,9 @@ func (d *DFA) SearchReverseLimited(cache *DFACache, haystack []byte, start, end, case DeadState: return lastMatch + case QuitState: + return d.nfaFallbackReverse(haystack, start, end) + default: sid = nextID } @@ -2103,6 +2127,10 @@ func (d *DFA) IsMatchReverse(cache *DFACache, haystack []byte, start, end int) b case DeadState: return false + case QuitState: + _, _, matched := d.pikevm.Search(haystack[start:end]) + return matched + default: sid = nextID } From aa8655faa16a3a93bd310de5ccd9c0e2253dd528 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 18:06:35 +0300 Subject: [PATCH 04/12] feat: DFA quits on bytes >= 0x80 when NFA has LookInvalidUTF8 (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1, step 4: temporary 3c fallback for invalid UTF-8 handling. - DFA struct: add hasInvalidUTF8Look flag, scanned at build time - Builder: add checkHasInvalidUTF8Look() (same pattern as word boundary) - determinize: if hasInvalidUTF8Look && b >= 0x80, set QuitState in flatTrans and return NFAFallback error. Search loop falls back to PikeVM. No behavioral changes yet — no NFA currently emits LookInvalidUTF8. Activates when NFA compiler inserts Look-gated fallback (phase 2, step 6). This is the temporary 3c approach: correct immediately on all inputs, slow on non-ASCII text. Phase 3 replaces with UTF-8 validator product. --- dfa/lazy/builder.go | 43 +++++++++++++++++++++++++++++++++---------- dfa/lazy/lazy.go | 15 +++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/dfa/lazy/builder.go b/dfa/lazy/builder.go index dd73ef8..08a7769 100644 --- a/dfa/lazy/builder.go +++ b/dfa/lazy/builder.go @@ -70,22 +70,26 @@ func (b *Builder) Build() (*DFA, error) { // Check if the pattern is always anchored (has ^ prefix) isAlwaysAnchored := b.nfa.IsAlwaysAnchored() + // Check if the NFA contains LookInvalidUTF8 assertions + hasInvalidUTF8Look := b.checkHasInvalidUTF8Look() + // Build the immutable start byte map var startByteMap [256]StartKind initByteMap(&startByteMap) // Create DFA — fully immutable after this point dfa := &DFA{ - nfa: b.nfa, - config: b.config, - prefilter: pf, - pikevm: nfa.NewPikeVM(b.nfa), - byteClasses: b.nfa.ByteClasses(), - unanchoredStart: b.nfa.StartUnanchored(), - hasWordBoundary: hasWordBoundary, - hasEndLine: hasEndLine, - isAlwaysAnchored: isAlwaysAnchored, - startByteMap: startByteMap, + nfa: b.nfa, + config: b.config, + prefilter: pf, + pikevm: nfa.NewPikeVM(b.nfa), + byteClasses: b.nfa.ByteClasses(), + unanchoredStart: b.nfa.StartUnanchored(), + hasWordBoundary: hasWordBoundary, + hasEndLine: hasEndLine, + isAlwaysAnchored: isAlwaysAnchored, + hasInvalidUTF8Look: hasInvalidUTF8Look, + startByteMap: startByteMap, } return dfa, nil @@ -748,3 +752,22 @@ func (b *Builder) checkHasEndLine() bool { } return false } + +// checkHasInvalidUTF8Look checks if the NFA contains LookInvalidUTF8 assertions. +// When true, determinize quits on bytes >= 0x80 (temporary 3c fallback). +func (b *Builder) checkHasInvalidUTF8Look() bool { + numStates := b.nfa.States() + for i := nfa.StateID(0); int(i) < numStates; i++ { + state := b.nfa.State(i) + if state == nil { + continue + } + if state.Kind() == nfa.StateLook { + look, _ := state.Look() + if look == nfa.LookInvalidUTF8 { + return true + } + } + } + return false +} diff --git a/dfa/lazy/lazy.go b/dfa/lazy/lazy.go index de0b9a3..9b0f3d7 100644 --- a/dfa/lazy/lazy.go +++ b/dfa/lazy/lazy.go @@ -87,6 +87,11 @@ type DFA struct { // When true, we only need to try matching from position 0. isAlwaysAnchored bool + // hasInvalidUTF8Look is true if the NFA contains LookInvalidUTF8 assertions. + // When true, determinize sets QuitState for all bytes >= 0x80 (temporary 3c). + // Phase 3 replaces this with a UTF-8 validator product. + hasInvalidUTF8Look bool + // startByteMap is the immutable byte-to-StartKind mapping used to initialize // DFACache.startTable. Computed once during compilation. startByteMap [256]StartKind @@ -1352,6 +1357,16 @@ func (d *DFA) searchAt(cache *DFACache, haystack []byte, startPos int) int { //n // // or if determinization limit exceeded. func (d *DFA) determinize(cache *DFACache, current *State, b byte) (*State, error) { + // Temporary 3c: when the NFA has LookInvalidUTF8 assertions, the DFA + // cannot evaluate them (requires look-ahead/look-behind). Quit on any + // byte >= 0x80 and let PikeVM handle it. Phase 3 replaces this with a + // UTF-8 validator product that quits only on structurally invalid bytes. + if d.hasInvalidUTF8Look && b >= 0x80 { + classIdx := d.byteToClass(b) + cache.SetFlatTransition(current.id, int(classIdx), QuitState) + return nil, &DFAError{Kind: NFAFallback, Message: "quit: byte >= 0x80 with LookInvalidUTF8"} + } + // Need builder for move operations. // Use NewBuilderWithWordBoundary to pass pre-computed flag and avoid O(states) scan. builder := NewBuilderWithWordBoundary(d.nfa, d.config, d.hasWordBoundary) From eea1bb2f422fda71fba1d9b627cb1285c0e58050 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 18:21:40 +0300 Subject: [PATCH 05/12] fix: handle QuitState in 4x-unrolled fast paths, cache guards, accel (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 hardening after self-review: - lazy.go: 4x-unrolled fast paths (searchEarliestMatch, searchAt) used `nextID != InvalidState && nextID != DeadState` which would treat QuitState as valid transition. Replaced with `!nextID.IsTagged()` — catches all tagged states (Invalid/Dead/Quit/future) in one check. - cache.go: safeOffset() and getState() now guard against QuitState alongside DeadState/InvalidState. Without this, QuitState would extract garbage offset from tag bits. - builder.go: DetectAccelerationFromFlat skips QuitState transitions (same as DeadState — not a real transition to another state). - quit_test.go: unit tests that inject QuitState into flatTrans and verify DFA falls back to PikeVM with correct results. Tests cover Find (forward), IsMatch, and tag bit semantics. --- dfa/lazy/builder.go | 2 +- dfa/lazy/cache.go | 10 ++-- dfa/lazy/lazy.go | 8 +-- dfa/lazy/quit_test.go | 122 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 dfa/lazy/quit_test.go diff --git a/dfa/lazy/builder.go b/dfa/lazy/builder.go index 08a7769..98bb2e8 100644 --- a/dfa/lazy/builder.go +++ b/dfa/lazy/builder.go @@ -658,7 +658,7 @@ func detectAccelFromTransitions(selfID StateID, stride int, transitionFn func(in continue } - if nextID == selfID || nextID == DeadState { + if nextID == selfID || nextID == DeadState || nextID == QuitState { continue } diff --git a/dfa/lazy/cache.go b/dfa/lazy/cache.go index 9c94387..5d1a3ad 100644 --- a/dfa/lazy/cache.go +++ b/dfa/lazy/cache.go @@ -126,10 +126,10 @@ func (c *DFACache) Insert(key StateKey, state *State) (StateID, error) { } // safeOffset computes flat table offset from premultiplied StateID. -// For tagged states (dead/invalid), returns MaxInt so bounds check always -// fails safely. For normal and match-tagged states, returns sid.Offset() + classIdx. +// For special tagged states (dead/invalid/quit), returns MaxInt so bounds +// check always fails safely. Match and start tags pass through. func safeOffset(sid StateID, classIdx int) int { - if sid.IsDeadTag() || sid.IsInvalidTag() { + if sid.IsDeadTag() || sid.IsInvalidTag() || sid.IsQuitTag() { return int(^uint(0) >> 1) // MaxInt } return sid.Offset() + classIdx @@ -307,8 +307,8 @@ func (c *DFACache) ResetClearCount() { // getState retrieves a state from the stateList by premultiplied ID. // Converts premultiplied offset to state index for stateList lookup. func (c *DFACache) getState(id StateID) *State { - // Guard against tagged special states - if id.IsTagged() && (id.IsDeadTag() || id.IsInvalidTag()) { + // Guard against tagged special states (dead/invalid/quit) + if id.IsTagged() && (id.IsDeadTag() || id.IsInvalidTag() || id.IsQuitTag()) { return nil } if c.stride == 0 { diff --git a/dfa/lazy/lazy.go b/dfa/lazy/lazy.go index 9b0f3d7..f989f0a 100644 --- a/dfa/lazy/lazy.go +++ b/dfa/lazy/lazy.go @@ -715,7 +715,7 @@ func (d *DFA) searchEarliestMatch(cache *DFACache, haystack []byte, startPos int offset := sid.Offset() + classIdx if offset < ftLen { nextID := ft[offset] - if nextID != InvalidState && nextID != DeadState { + if !nextID.IsTagged() { sid = nextID pos++ if cache.IsMatchState(sid) { @@ -724,7 +724,7 @@ func (d *DFA) searchEarliestMatch(cache *DFACache, haystack []byte, startPos int continue } } - // InvalidState/DeadState: fall through to full slow path + // Tagged (Invalid/Dead/Quit): fall through to full slow path } // Try lazy acceleration detection if not yet checked @@ -1252,7 +1252,7 @@ func (d *DFA) searchAt(cache *DFACache, haystack []byte, startPos int) int { //n offset := sid.Offset() + classIdx if offset < ftLen { nextID := ft[offset] - if nextID != InvalidState && nextID != DeadState { + if !nextID.IsTagged() { sid = nextID if cache.IsMatchState(sid) { lastMatch = pos @@ -1261,7 +1261,7 @@ func (d *DFA) searchAt(cache *DFACache, haystack []byte, startPos int) int { //n continue } } - // InvalidState/DeadState: fall through to full slow path + // Tagged (Invalid/Dead/Quit): fall through to full slow path } // Resolve State for slow path (acceleration, word boundary, determinize). diff --git a/dfa/lazy/quit_test.go b/dfa/lazy/quit_test.go new file mode 100644 index 0000000..0c21501 --- /dev/null +++ b/dfa/lazy/quit_test.go @@ -0,0 +1,122 @@ +package lazy + +import "testing" + +// TestQuitStateFallback verifies that QuitState transitions cause the DFA to +// fall back to PikeVM and produce correct results. QuitState is currently dead +// code (no NFA emits LookInvalidUTF8 yet), so we exercise it by manually +// injecting QuitState transitions into the cache. +func TestQuitStateFallback(t *testing.T) { + tests := []struct { + name string + pattern string + haystack string + quitByte byte + wantFind int + }{ + { + name: "literal match with quit on unrelated byte", + pattern: "foo", + haystack: "xxfooxx", + quitByte: 'x', + wantFind: 5, + }, + { + name: "alternation with quit on first byte", + pattern: "bar", + haystack: "bazbar", + quitByte: 'b', + wantFind: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dfa, err := CompilePattern(tt.pattern) + if err != nil { + t.Fatalf("CompilePattern(%q): %v", tt.pattern, err) + } + + // Baseline: DFA without quit gives correct result + cache := dfa.NewCache() + baseline := dfa.Find(cache, []byte(tt.haystack)) + if baseline != tt.wantFind { + t.Fatalf("baseline Find = %d, want %d", baseline, tt.wantFind) + } + + // Now inject QuitState: compile fresh, do a search to populate + // the start state, then overwrite the quitByte transition. + cache2 := dfa.NewCache() + hay := []byte(tt.haystack) + + // Warm up: trigger determinize for start state + startState := dfa.getStartStateForUnanchored(cache2, hay, 0) + if startState == nil { + t.Fatal("failed to get start state") + } + + // Overwrite the transition for quitByte from start state + classIdx := int(dfa.byteToClass(tt.quitByte)) + cache2.SetFlatTransition(startState.id, classIdx, QuitState) + + // Search should fall back to PikeVM via QuitState and still + // produce correct result + got := dfa.Find(cache2, hay) + if got != tt.wantFind { + t.Errorf("Find with QuitState on %q = %d, want %d (PikeVM fallback)", + string(tt.quitByte), got, tt.wantFind) + } + }) + } +} + +// TestQuitStateIsMatch verifies QuitState fallback in IsMatch path. +func TestQuitStateIsMatch(t *testing.T) { + dfa, err := CompilePattern("world") + if err != nil { + t.Fatalf("CompilePattern: %v", err) + } + + hay := []byte("hello world") + + // Baseline + cache := dfa.NewCache() + if !dfa.IsMatch(cache, hay) { + t.Fatal("baseline IsMatch = false, want true") + } + + // Inject QuitState on 'h' from start state + cache2 := dfa.NewCache() + startState := dfa.getStartStateForUnanchored(cache2, hay, 0) + if startState == nil { + t.Fatal("failed to get start state") + } + classIdx := int(dfa.byteToClass('h')) + cache2.SetFlatTransition(startState.id, classIdx, QuitState) + + if !dfa.IsMatch(cache2, hay) { + t.Error("IsMatch with QuitState = false, want true (PikeVM fallback)") + } +} + +// TestQuitStateConstants verifies tag bit semantics. +func TestQuitStateConstants(t *testing.T) { + if !QuitState.IsTagged() { + t.Error("QuitState.IsTagged() = false, want true") + } + if !QuitState.IsQuitTag() { + t.Error("QuitState.IsQuitTag() = false, want true") + } + if QuitState.IsDeadTag() { + t.Error("QuitState.IsDeadTag() = true, want false") + } + if QuitState.IsInvalidTag() { + t.Error("QuitState.IsInvalidTag() = true, want false") + } + if QuitState == DeadState { + t.Error("QuitState == DeadState, must be distinct") + } + if QuitState == InvalidState { + t.Error("QuitState == InvalidState, must be distinct") + } +} From 1a73cc0722263077ba17c3f724ec0b295c77e8fa Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 18:34:19 +0300 Subject: [PATCH 06/12] fix: anchored quit fallback, reverse context preservation, gofmt (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 review fixes (3 must-fix): 1. SearchAtAnchored QuitState used unanchored nfaFallback — would return matches at wrong positions. Added nfaFallbackAnchored that verifies start == startPos. Test: `b+` at position 0 of "ab" with quit on 'a' must return -1, not 2. 2. nfaFallbackReverse sliced haystack (pikevm.Search(haystack[start:end])) which loses left context needed for \b, (?m)^, and LookInvalidUTF8 rune-boundary check. Replaced with pikevm.SearchBetween(haystack, start, end) which preserves full context. IsMatchReverse QuitState handler also fixed. 3. gofmt alignment on state.go constants (tagQuit shorter than tagReserved). --- dfa/lazy/lazy.go | 22 +++++++++++++++++----- dfa/lazy/quit_test.go | 36 ++++++++++++++++++++++++++++++++++++ dfa/lazy/state.go | 10 +++++----- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/dfa/lazy/lazy.go b/dfa/lazy/lazy.go index f989f0a..5e834aa 100644 --- a/dfa/lazy/lazy.go +++ b/dfa/lazy/lazy.go @@ -304,7 +304,7 @@ func (d *DFA) SearchAtAnchored(cache *DFACache, haystack []byte, at int) int { return lastMatch case QuitState: - return d.nfaFallback(haystack, at) + return d.nfaFallbackAnchored(haystack, at) default: sid = nextID @@ -1665,6 +1665,17 @@ func (d *DFA) nfaFallback(haystack []byte, startPos int) int { return end } +// nfaFallbackAnchored is like nfaFallback but requires the match to start +// exactly at startPos. Used by SearchAtAnchored where unanchored fallback +// would produce false positives from matches after the expected position. +func (d *DFA) nfaFallbackAnchored(haystack []byte, startPos int) int { + start, end, matched := d.pikevm.SearchAt(haystack, startPos) + if !matched || start != startPos { + return -1 + } + return end +} + // matchesEmpty checks if the pattern matches an empty string func (d *DFA) matchesEmpty(cache *DFACache) bool { // With 1-byte match delay, the start state is never tagged as match. @@ -2143,7 +2154,7 @@ func (d *DFA) IsMatchReverse(cache *DFACache, haystack []byte, start, end int) b return false case QuitState: - _, _, matched := d.pikevm.Search(haystack[start:end]) + _, _, matched := d.pikevm.SearchBetween(haystack, start, end) return matched default: @@ -2201,11 +2212,12 @@ func (d *DFA) getStartStateForReverse(cache *DFACache, haystack []byte, end int) } // nfaFallbackReverse handles NFA fallback for reverse search. +// Uses SearchBetween to preserve full haystack context (word boundaries, +// LookInvalidUTF8 rune-boundary check need bytes before start). func (d *DFA) nfaFallbackReverse(haystack []byte, start, end int) int { - // For reverse fallback, we need to search the region and find match start - matchStart, _, matched := d.pikevm.Search(haystack[start:end]) + matchStart, _, matched := d.pikevm.SearchBetween(haystack, start, end) if !matched { return -1 } - return start + matchStart + return matchStart } diff --git a/dfa/lazy/quit_test.go b/dfa/lazy/quit_test.go index 0c21501..629989d 100644 --- a/dfa/lazy/quit_test.go +++ b/dfa/lazy/quit_test.go @@ -99,6 +99,42 @@ func TestQuitStateIsMatch(t *testing.T) { } } +// TestQuitStateAnchored verifies that QuitState in SearchAtAnchored does NOT +// produce false positives from unanchored matches at other positions. +// Pattern "b+" at position 0 of "ab": anchored answer is -1 (no 'b' at 0), +// but unanchored would return 2 (match at position 1). QuitState must use +// the anchored fallback. +func TestQuitStateAnchored(t *testing.T) { + dfa, err := CompilePattern("b+") + if err != nil { + t.Fatalf("CompilePattern: %v", err) + } + + hay := []byte("ab") + + // Baseline: anchored search at 0 should find no match (first byte is 'a') + cache := dfa.NewCache() + baseline := dfa.SearchAtAnchored(cache, hay, 0) + if baseline != -1 { + t.Fatalf("baseline SearchAtAnchored = %d, want -1", baseline) + } + + // Inject QuitState on 'a' from anchored start state at position 0 + cache2 := dfa.NewCache() + startState := dfa.getStartState(cache2, hay, 0, true) + if startState == nil { + t.Fatal("failed to get anchored start state") + } + classIdx := int(dfa.byteToClass('a')) + cache2.SetFlatTransition(startState.id, classIdx, QuitState) + + got := dfa.SearchAtAnchored(cache2, hay, 0) + if got != -1 { + t.Errorf("SearchAtAnchored with QuitState on 'a' = %d, want -1 "+ + "(anchored fallback must not return unanchored match)", got) + } +} + // TestQuitStateConstants verifies tag bit semantics. func TestQuitStateConstants(t *testing.T) { if !QuitState.IsTagged() { diff --git a/dfa/lazy/state.go b/dfa/lazy/state.go index f9b5b92..6a66e75 100644 --- a/dfa/lazy/state.go +++ b/dfa/lazy/state.go @@ -27,11 +27,11 @@ type StateID uint32 // Tag bit masks for StateID high bits. const ( - tagInvalid StateID = 1 << 31 // Unknown/not yet computed transition - tagDead StateID = 1 << 30 // Dead state — no match possible - tagQuit StateID = 1 << 29 // DFA cannot handle this byte — fall back to NFA - tagStart StateID = 1 << 28 // Start state - tagMatch StateID = 1 << 27 // Match/accepting state + tagInvalid StateID = 1 << 31 // Unknown/not yet computed transition + tagDead StateID = 1 << 30 // Dead state — no match possible + tagQuit StateID = 1 << 29 // DFA cannot handle this byte — fall back to NFA + tagStart StateID = 1 << 28 // Start state + tagMatch StateID = 1 << 27 // Match/accepting state // TagMask extracts the offset (lower 27 bits). // Any bit above this = special state requiring slow path. From 16347b39ac9174c93c91ab6eff676f4eecdf3a4e Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 19:01:48 +0300 Subject: [PATCH 07/12] feat: insert Look-gated U+FFFD fallback in NFA compiler (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2, step 6: three insertion points for LookInvalidUTF8. - compileUnicodeClassLarge: coversAllNonASCII branch always gets fallback (U+FFFD is in any negated ASCII class). Non-covering branch checks containsRuneFFFD(ranges) before adding. - compileUTF8Any: replaced direct 0x80-0xFF/0xC0-0xC1/0xF5-0xFF transitions with buildInvalidUTF8Fallback (Look-gated). - compileUTF8AnySparse: removed inline invalid byte transitions from sparse state. Added separate Look-gated branch via split chain. New helpers: - buildInvalidUTF8Fallback: LookInvalidUTF8 -> ByteRange(0x80,0xFF) -> end - containsRuneFFFD: checks if U+FFFD is in rune ranges NFA now emits LookInvalidUTF8 for '.', '(?s).', '\S', '\D', '\W', '[^x]', '\P{Han}' — any class containing U+FFFD. The DFA's temporary 3c quit (phase 1) will activate for these patterns. PikeVM/BT evaluation of the Look assertion is next (tasks #7, #8). --- nfa/compile.go | 90 +++++++++++++++++++---------------------- nfa/utf8_suffix_test.go | 4 +- 2 files changed, 43 insertions(+), 51 deletions(-) diff --git a/nfa/compile.go b/nfa/compile.go index df34fe5..1c1a775 100644 --- a/nfa/compile.go +++ b/nfa/compile.go @@ -568,24 +568,17 @@ func (c *Compiler) compileUnicodeClassLarge(ranges []rune) (start, end StateID, } // Build non-ASCII part - if len(nonASCIIRanges) > 0 { - if coversAllNonASCII { - // Optimization: use efficient "any valid UTF-8 multi-byte" approach - // This is correct because we're matching ALL non-ASCII codepoints - multiByteStarts := c.buildUTF8NonASCIIBranches(target) - altStarts = append(altStarts, multiByteStarts...) - // Invalid UTF-8 single-byte matching (0x80-0xFF) removed: it caused - // negated classes to match individual bytes of valid multi-byte runes - // (e.g. \S{2} matched "К" by seeing two bytes). Invalid UTF-8 handling - // requires look-ahead design — tracked as #179. - } else { - // Precise: build UTF-8 automata for specific ranges (Issue #91 fix) - // For partial Unicode classes like \P{Han}, we DON'T add invalid UTF-8 - // handling because it would incorrectly match bytes of valid UTF-8. - for _, rng := range nonASCIIRanges { - rangeStarts := c.compileUTF8Range(rng[0], rng[1], target) - altStarts = append(altStarts, rangeStarts...) - } + if len(nonASCIIRanges) > 0 && coversAllNonASCII { + multiByteStarts := c.buildUTF8NonASCIIBranches(target) + altStarts = append(altStarts, multiByteStarts...) + altStarts = append(altStarts, c.buildInvalidUTF8Fallback(target)) + } else if len(nonASCIIRanges) > 0 { + for _, rng := range nonASCIIRanges { + rangeStarts := c.compileUTF8Range(rng[0], rng[1], target) + altStarts = append(altStarts, rangeStarts...) + } + if containsRuneFFFD(nonASCIIRanges) { + altStarts = append(altStarts, c.buildInvalidUTF8Fallback(target)) } } @@ -607,6 +600,25 @@ func (c *Compiler) compileUnicodeClassLarge(ranges []rune) (start, end StateID, // // UTF-8 encoding: // - 1-byte: U+0000-U+007F → 0x00-0x7F +// containsRuneFFFD checks if U+FFFD is within any of the given rune ranges. +func containsRuneFFFD(ranges [][2]rune) bool { + for _, rng := range ranges { + if rng[0] <= 0xFFFD && rng[1] >= 0xFFFD { + return true + } + } + return false +} + +// buildInvalidUTF8Fallback creates the Look-gated fallback for invalid UTF-8 bytes. +// Returns the start StateID of: LookInvalidUTF8 → ByteRange(0x80, 0xFF) → endState. +// The Look assertion ensures the transition fires only at positions where +// utf8.DecodeRune returns (RuneError, 1) AND the position is at a rune boundary. +func (c *Compiler) buildInvalidUTF8Fallback(endState StateID) StateID { + invalidByte := c.builder.AddByteRange(0x80, 0xFF, endState) + return c.builder.AddLook(LookInvalidUTF8, invalidByte) +} + // - 2-byte: U+0080-U+07FF → 0xC2-0xDF, 0x80-0xBF // - 3-byte: U+0800-U+FFFF → 0xE0-0xEF, 0x80-0xBF, 0x80-0xBF // - 4-byte: U+10000-U+10FFFF → 0xF0-0xF4, 0x80-0xBF, 0x80-0xBF, 0x80-0xBF @@ -1105,31 +1117,24 @@ func (c *Compiler) compileUTF8AnySparse(includeNL bool) (start, end StateID, err ) } - // Invalid standalone bytes → endState (match as single byte for stdlib compat) - transitions = append(transitions, - Transition{Lo: 0x80, Hi: 0xBF, Next: endState}, // standalone continuation - Transition{Lo: 0xC0, Hi: 0xC1, Next: endState}, // overlong 2-byte - ) - // Multi-byte sequences: leading byte → first continuation state for _, seq := range sequences { - // Build continuation chain in REVERSE for suffix sharing target := endState for i := len(seq) - 1; i >= 1; i-- { br := seq[i] target = cache.getOrCreate(c.builder, target, br.lo, br.hi) } - // seq[0] is the leading byte range, target is the first continuation state transitions = append(transitions, Transition{Lo: seq[0].lo, Hi: seq[0].hi, Next: target}) } - // Invalid high bytes → endState - transitions = append(transitions, - Transition{Lo: 0xF5, Hi: 0xFF, Next: endState}, - ) + // Sparse state handles ASCII + valid multi-byte (no invalid bytes here) + validState := c.builder.AddSparse(transitions) - // Single sparse state replaces the entire split chain - startState := c.builder.AddSparse(transitions) + // Invalid UTF-8 bytes handled via Look-gated fallback (separate branch) + invalidState := c.buildInvalidUTF8Fallback(endState) + + // Combine: split between valid sparse state and Look-gated invalid fallback + startState := c.buildSplitChain([]StateID{validState, invalidState}) return startState, endState, nil } @@ -1210,23 +1215,10 @@ func (c *Compiler) compileUTF8Any(includeNL bool) (start, end StateID, err error branches = append(branches, target) } - // Invalid UTF-8 bytes - match as single bytes for stdlib compatibility. - // Go regexp's . matches invalid UTF-8 bytes as single characters. - // Invalid bytes: 0x80-0xBF (standalone continuation), 0xC0-0xC1 (overlong), - // 0xF5-0xFF (out of range for Unicode). - // - // NOTE: We don't add 0xC2-0xF4 (valid lead bytes) here because: - // 1. Adding them causes capture group bugs with zero-width matches ((.*)on "") - // 2. The multi-byte paths already handle these bytes in valid sequences - // 3. When these bytes appear standalone, the NFA won't match them (correct behavior - // differs from stdlib, but preserves capture group correctness which is more important) - invalidTrans := []Transition{ - {Lo: 0x80, Hi: 0xBF, Next: endState}, // standalone continuation bytes - {Lo: 0xC0, Hi: 0xC1, Next: endState}, // overlong 2-byte encodings - {Lo: 0xF5, Hi: 0xFF, Next: endState}, // out of Unicode range - } - invalidUTF8 := c.builder.AddSparse(invalidTrans) - branches = append(branches, invalidUTF8) + // Invalid UTF-8 bytes: match as U+FFFD width 1, gated by LookInvalidUTF8. + // The Look assertion ensures this fires only at rune boundaries where + // utf8.DecodeRune returns (RuneError, 1) — not inside valid multi-byte sequences. + branches = append(branches, c.buildInvalidUTF8Fallback(endState)) // Create split state for alternation startState := c.buildSplitChain(branches) diff --git a/nfa/utf8_suffix_test.go b/nfa/utf8_suffix_test.go index a212ca7..a8bdd1d 100644 --- a/nfa/utf8_suffix_test.go +++ b/nfa/utf8_suffix_test.go @@ -86,8 +86,8 @@ func TestCompileUTF8Any_StateCount(t *testing.T) { maxStates int // Upper bound after optimization description string }{ - {".", 30, "dot should have suffix sharing"}, - {".*", 32, "dot-star should have suffix sharing"}, + {".", 33, "dot should have suffix sharing (+3 for LookInvalidUTF8 + split)"}, + {".*", 35, "dot-star should have suffix sharing (+3 for LookInvalidUTF8 + split)"}, } for _, tt := range tests { From 0cc3e5ef2c89d2db617e35c4eb5d505ae568f238 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 19:05:14 +0300 Subject: [PATCH 08/12] feat: evaluate LookInvalidUTF8 in PikeVM and BoundedBacktracker (#179) Phase 2, steps 7+8: add LookInvalidUTF8 case to checkLookAssertion. Both PikeVM and BoundedBacktracker use checkLookAssertion, so one change covers both engines. Two-part semantics: 1. Rune boundary: isInsideValidSequence() looks back up to 3 bytes for a valid UTF-8 lead byte covering this position. If found, the assertion fails (byte is inside a valid multi-byte sequence). Cost: only on continuation bytes (0x80-0xBF). 2. Invalid sequence: utf8.DecodeRune(haystack[pos:]) returns size==1 (RuneError). This catches orphan continuations, overlong leads (C0-C1), out-of-range bytes (F5-FF), and truncated sequences. New helpers: isInvalidUTF8Position, isInsideValidSequence, utf8LeadByteLen. Smoke test: all 7 D-cases now match stdlib. #174 and rune-boundary regression gates hold. --- nfa/pikevm.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/nfa/pikevm.go b/nfa/pikevm.go index 24b819f..3c4160b 100644 --- a/nfa/pikevm.go +++ b/nfa/pikevm.go @@ -1669,10 +1669,81 @@ func checkLookAssertion(look Look, haystack []byte, pos int) bool { wordBefore := pos > 0 && isWordByte(haystack[pos-1]) wordAfter := pos < len(haystack) && isWordByte(haystack[pos]) return wordBefore == wordAfter + case LookInvalidUTF8: + return isInvalidUTF8Position(haystack, pos) } return false } +// isInvalidUTF8Position checks if position pos in haystack is an invalid UTF-8 +// byte that should be treated as U+FFFD width 1. Two conditions must hold: +// 1. The position is at a rune boundary (not inside a valid multi-byte sequence) +// 2. utf8.DecodeRune(haystack[pos:]) returns (RuneError, 1) +func isInvalidUTF8Position(haystack []byte, pos int) bool { + if pos >= len(haystack) { + return false + } + b := haystack[pos] + if b < 0x80 { + return false + } + // Check rune boundary: if this is a continuation byte (0x80-0xBF), + // verify it's not inside a valid multi-byte sequence by looking back + // for a lead byte whose sequence covers this position. + if b >= 0x80 && b <= 0xBF { + if isInsideValidSequence(haystack, pos) { + return false + } + } + _, size := utf8.DecodeRune(haystack[pos:]) + return size == 1 +} + +// isInsideValidSequence checks whether position pos is a continuation byte +// inside a valid UTF-8 multi-byte sequence. Looks back up to 3 bytes for +// a lead byte whose valid sequence length covers pos. +func isInsideValidSequence(haystack []byte, pos int) bool { + for back := 1; back <= 3 && pos-back >= 0; back++ { + lead := haystack[pos-back] + seqLen := utf8LeadByteLen(lead) + if seqLen >= 2 && back < seqLen { + // lead byte at pos-back starts a sequence of seqLen bytes. + // Verify the full sequence is valid UTF-8. + end := pos - back + seqLen + if end <= len(haystack) { + _, n := utf8.DecodeRune(haystack[pos-back : end]) + if n == seqLen { + return true + } + } + return false + } + if seqLen >= 2 { + return false + } + } + return false +} + +// utf8LeadByteLen returns the expected sequence length for a UTF-8 lead byte. +// Returns 0 for continuation bytes and invalid bytes. +func utf8LeadByteLen(b byte) int { + switch { + case b < 0x80: + return 1 + case b < 0xC0: + return 0 // continuation byte + case b < 0xE0: + return 2 + case b < 0xF0: + return 3 + case b < 0xF8: + return 4 + default: + return 0 + } +} + // ============================================================================= // SlotTable-based Search Methods (New Architecture) // ============================================================================= From f2b5669325165faec949d1bb0c02be5dadf40116 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 19:59:19 +0300 Subject: [PATCH 09/12] feat: evaluate LookInvalidUTF8, acceptance tests, literal U+FFFD (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2, steps 7-9: complete invalid UTF-8 handling. NFA engines: - checkLookAssertion: add LookInvalidUTF8 case with two-part semantics: rune boundary check (isInsideValidSequence) + utf8.DecodeRune == RuneError - New helpers: isInvalidUTF8Position, isInsideValidSequence, utf8LeadByteLen - Both PikeVM and BoundedBacktracker covered (shared checkLookAssertion) NFA compiler: - compileLiteral: U+FFFD compiles as (valid EF BF BD) OR (Look-gated 0x80-0xFF) - compileRuneFFFD helper for the alternation Acceptance tests (all 10 criteria from design doc): - Criteria 1-4: negated classes match invalid bytes - Criterion 5: literal U+FFFD matches invalid byte - Criterion 6: literal U+FFFD matches valid encoding - Criterion 7: \pL does NOT match invalid byte - Criterion 9: dot matches each invalid byte as width 1 - Criterion 10: rune-boundary invariant ([^К]b on Кb = no match) Removed t.Skip("#179") from fuzz_stdlib_test.go and regex_unicode_test.go. --- fuzz_stdlib_test.go | 6 ++-- nfa/compile.go | 38 ++++++++++++++++++-- regex_unicode_test.go | 82 +++++++++++++++++++++++++++++++------------ 3 files changed, 97 insertions(+), 29 deletions(-) diff --git a/fuzz_stdlib_test.go b/fuzz_stdlib_test.go index 351da89..5a74976 100644 --- a/fuzz_stdlib_test.go +++ b/fuzz_stdlib_test.go @@ -839,11 +839,9 @@ func TestZeroWidthMatchCharClass(t *testing.T) { } // TestInvalidUTF8NegatedCharClass verifies that negated character classes -// like \D, \S, \W correctly match invalid UTF-8 bytes. -// Skipped: invalid UTF-8 byte matching removed in #174 to fix false positives -// on valid multi-byte runes. Proper handling requires #179 (look-ahead design). +// like \D, \S, \W correctly match invalid UTF-8 bytes as U+FFFD width 1. +// Re-enabled after #179 LookInvalidUTF8 implementation. func TestInvalidUTF8NegatedCharClass(t *testing.T) { - t.Skip("#179: invalid UTF-8 single-byte matching requires look-ahead design") tests := []struct { pattern string input []byte diff --git a/nfa/compile.go b/nfa/compile.go index 1c1a775..81e244f 100644 --- a/nfa/compile.go +++ b/nfa/compile.go @@ -247,13 +247,20 @@ func (c *Compiler) compileLiteral(re *syntax.Regexp) (start, end StateID, err er var first = InvalidState for _, r := range runes { - if foldCase && hasFoldEquivalent(r) { + switch { + case r == '\uFFFD' && !foldCase: + nextState, err := c.compileRuneFFFD(prev, &first) + if err != nil { + return InvalidState, InvalidState, err + } + prev = nextState + case foldCase && hasFoldEquivalent(r): nextState, err := c.compileFoldCaseRune(r, prev, &first) if err != nil { return InvalidState, InvalidState, err } prev = nextState - } else { + default: prev, err = c.compileCaseSensitiveRune(r, prev, &first) if err != nil { return InvalidState, InvalidState, err @@ -331,6 +338,33 @@ func foldOrbit(r rune) []rune { return orbit } +// compileRuneFFFD compiles literal U+FFFD as an alternation: +// (1) valid 3-byte encoding EF BF BD, OR +// (2) LookInvalidUTF8 → any byte 0x80-0xFF (invalid byte = U+FFFD). +func (c *Compiler) compileRuneFFFD(prev StateID, first *StateID) (StateID, error) { + end := c.builder.AddEpsilon(InvalidState) + + // Branch 1: valid encoding EF BF BD + bd := c.builder.AddByteRange(0xBD, 0xBD, end) + bf := c.builder.AddByteRange(0xBF, 0xBF, bd) + ef := c.builder.AddByteRange(0xEF, 0xEF, bf) + + // Branch 2: Look-gated invalid byte + invalid := c.buildInvalidUTF8Fallback(end) + + split := c.buildSplitChain([]StateID{ef, invalid}) + + if *first == InvalidState { + *first = split + } + if prev != InvalidState { + if err := c.builder.Patch(prev, split); err != nil { + return InvalidState, err + } + } + return end, nil +} + // compileCaseSensitiveRune compiles a single rune in case-sensitive mode // by converting it to UTF-8 bytes and chaining ByteRange states func (c *Compiler) compileCaseSensitiveRune(r rune, prev StateID, first *StateID) (StateID, error) { diff --git a/regex_unicode_test.go b/regex_unicode_test.go index a792dc5..771e26e 100644 --- a/regex_unicode_test.go +++ b/regex_unicode_test.go @@ -332,30 +332,66 @@ func TestNegatedClassCountsRunes(t *testing.T) { }) } - // Part 4: invalid UTF-8 — D territory (#179). Documents desired behavior. - t.Run("invalid_utf8", func(t *testing.T) { - t.Skip("#179: invalid UTF-8 handling requires look-ahead design") - invalid := []struct { - name string - pattern string - input string - }{ - {"not_a_0xff", `[^a]`, "\xff"}, - {"D_embedded_0xff", `\D+`, "a\xffb"}, - {"S_continuation", `\S`, "\x80"}, - {"not_x_overlong", `[^x]`, "\xc0"}, + // Part 4: invalid UTF-8 as U+FFFD width 1 (#179 acceptance criteria). + invalidMatch := []struct { + name string + pattern string + input string + }{ + // Criteria 1-4: negated classes match invalid bytes + {"criterion1_not_a_0xff", `[^a]`, "\xff"}, + {"criterion2_D_embedded", `\D+`, "a\xffb"}, + {"criterion3_S_continuation", `\S`, "\x80"}, + {"criterion4_not_x_overlong", `[^x]`, "\xc0"}, + // Criterion 5: literal \x{FFFD} matches invalid byte + {"criterion5_fffd_literal", "\uFFFD", "\xff"}, + // Criterion 6: literal \x{FFFD} matches valid encoding + {"criterion6_fffd_valid", "\uFFFD", "\xef\xbf\xbd"}, + } + for _, tt := range invalidMatch { + t.Run(tt.name, func(t *testing.T) { + re := MustCompile(tt.pattern) + got := re.MatchString(tt.input) + std := regexp.MustCompile(tt.pattern) + want := std.MatchString(tt.input) + if got != want { + t.Errorf("MatchString(%q, %q) = %v, want %v (stdlib)", + tt.pattern, tt.input, got, want) + } + }) + } + + // Criterion 7: \pL does NOT match invalid byte (U+FFFD is not a letter) + t.Run("criterion7_pL_no_match", func(t *testing.T) { + re := MustCompile(`\pL`) + got := re.MatchString("\xff") + std := regexp.MustCompile(`\pL`) + want := std.MatchString("\xff") + if got != want { + t.Errorf("MatchString(\\pL, \"\\xff\") = %v, want %v", got, want) } - for _, tt := range invalid { - t.Run(tt.name, func(t *testing.T) { - re := MustCompile(tt.pattern) - got := re.MatchString(tt.input) - std := regexp.MustCompile(tt.pattern) - want := std.MatchString(tt.input) - if got != want { - t.Errorf("MatchString(%q, %q) = %v, want %v (stdlib)", - tt.pattern, tt.input, got, want) - } - }) + }) + + // Criterion 9: dot matches each invalid byte as width 1 + t.Run("criterion9_dot_two_invalid", func(t *testing.T) { + re := MustCompile(`.`) + got := re.FindAllString("\xff\xfe", -1) + std := regexp.MustCompile(`.`) + want := std.FindAllString("\xff\xfe", -1) + if len(got) != len(want) { + t.Errorf("FindAllString(., \"\\xff\\xfe\"): got %d matches, want %d", + len(got), len(want)) + } + }) + + // Criterion 10: rune-boundary invariant — no match inside valid rune + t.Run("criterion10_rune_boundary", func(t *testing.T) { + re := MustCompile(`[^К]b`) + got := re.MatchString("Кb") + std := regexp.MustCompile(`[^К]b`) + want := std.MatchString("Кb") + if got != want { + t.Errorf("MatchString([^К]b, \"Кb\") = %v, want %v", got, want) } }) } From c05d0ce50ea470e738e97b9a0d962f7ae6267654 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 20:04:09 +0300 Subject: [PATCH 10/12] test: remove D-class fuzz whitelists, add invalid UTF-8 seeds (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2, step 10: fuzz hardening. - hasUTF8CodepointDifference: removed D-class patterns (dot, \D, \W, \S, [^x], .+, (.)+) from whitelist — all fixed by #174/#179. Only empty-match stepping patterns remain (class B/C: #175, #176). - seedInputs: added 7 invalid UTF-8 inputs (\xff, \x80, \xc0, embedded invalid, valid U+FFFD encoding, mixed valid+invalid). - TestInvalidUTF8NegatedCharClass: removed t.Skip, re-enabled. All 9 fuzz targets pass on seed corpus with invalid UTF-8 inputs. --- fuzz_stdlib_test.go | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/fuzz_stdlib_test.go b/fuzz_stdlib_test.go index 5a74976..af14efd 100644 --- a/fuzz_stdlib_test.go +++ b/fuzz_stdlib_test.go @@ -138,6 +138,14 @@ var seedInputs = []string{ "\n\n\n", "555-1234", "test@test.com", + // Invalid UTF-8 bytes (#179) + "\xff", + "\x80", + "\xc0", + "a\xffb", + "\xef\xbf\xbd", // valid U+FFFD encoding + "hello\xff\xfeworld", + "К\x80b", // valid rune + orphan continuation } // =========================================================================== @@ -145,11 +153,10 @@ var seedInputs = []string{ // =========================================================================== // hasUTF8CodepointDifference returns true if this pattern+input combination -// has known differences due to coregex matching bytes vs stdlib matching codepoints. -// This affects `.`, `\D`, `\W`, `\S`, negated character classes, and -// patterns that can match empty strings on multibyte input. +// has known differences due to empty-match stepping (class B/C: #175, #176). +// Empty-match patterns step by byte, not by codepoint on multibyte input. +// D-class patterns (dot, negated classes) were fixed by #174/#179. func hasUTF8CodepointDifference(pattern, input string) bool { - // Check if input contains multibyte UTF-8 characters hasMultibyte := false for _, r := range input { if r >= 0x80 { @@ -161,16 +168,8 @@ func hasUTF8CodepointDifference(pattern, input string) bool { return false } - // Patterns that match at byte level vs codepoint level - codepointPatterns := map[string]bool{ - `.`: true, // dot matches any codepoint in stdlib, any byte in coregex - `\D`: true, // non-digit - `\W`: true, // non-word - `\S`: true, // non-space - `[^a-z]`: true, // negated class - `[^0-9]`: true, // negated class - `[^a-zA-Z]`: true, - // Empty-match patterns step by codepoint in stdlib, by byte in coregex + // Only empty-match stepping patterns remain (class B/C) + emptyMatchPatterns := map[string]bool{ ``: true, // empty pattern `a*`: true, // can match empty `a?`: true, // can match empty @@ -179,14 +178,10 @@ func hasUTF8CodepointDifference(pattern, input string) bool { `.*`: true, // can match empty `.*?`: true, // can match empty `.?`: true, // can match empty - // Dot with captures also has codepoint differences - `(.)`: true, - `(.)+`: true, - `(.)*`: true, - `(.)?`: true, - `.+`: true, - } - return codepointPatterns[pattern] + `(.)*`: true, // can match empty + `(.)?`: true, // can match empty + } + return emptyMatchPatterns[pattern] } // isEmptyPatternCase returns true if this is an empty pattern case From b929d3d0888dc525b00729cb2c82afb7dd7d3bb5 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 20:35:16 +0300 Subject: [PATCH 11/12] fix: OnePass evaluates LookInvalidUTF8 at runtime, gofmt (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 review fix: OnePass regression. Problem: LookInvalidUTF8 in NFA caused OnePass builder to see overlapping byte ranges (0x80-0xFF from FFFD path vs multi-byte UTF-8 paths) → rejected as not-one-pass → 9-17x regression on FindSubmatch for log-parsing patterns. Fix: OnePass builder skips LookInvalidUTF8 in epsilon closure (avoids ambiguity), records FFFD targets separately. buildTransitions fills dead byte classes >= 0x80 with conditional FFFD transitions flagged via lookInvalidUTF8Bit in the look-around field. Search loop checks IsInvalidUTF8Position at runtime before taking conditional transitions. - builder.go: skip LookInvalidUTF8 in closure, record fffdTargets, fill dead byte classes with conditional FFFD transitions - transition.go: add lookInvalidUTF8Bit, NeedsInvalidUTF8Check() - search.go: runtime check before IsDead - onepass.go: runtime check in IsMatch - pikevm.go: export IsInvalidUTF8Position for cross-package use - gofmt: fix formatting on fuzz_stdlib_test.go, nfa/compile.go --- dfa/onepass/builder.go | 80 ++++++++++++++++++++++++++++++--------- dfa/onepass/onepass.go | 7 ++-- dfa/onepass/search.go | 7 +++- dfa/onepass/transition.go | 10 +++++ fuzz_stdlib_test.go | 16 ++++---- nfa/compile.go | 7 ++-- nfa/pikevm.go | 6 +-- 7 files changed, 98 insertions(+), 35 deletions(-) diff --git a/dfa/onepass/builder.go b/dfa/onepass/builder.go index 5736fe1..4b497ff 100644 --- a/dfa/onepass/builder.go +++ b/dfa/onepass/builder.go @@ -13,10 +13,11 @@ type Builder struct { nfa *nfa.NFA // Working state for DFS during one-pass check - seen *sparse.SparseSet // visited NFA states during epsilon closure - stack []stackEntry // DFS stack - matched bool // true if we've reached a match state in current closure - matchMask uint32 // slot mask accumulated to reach match state + seen *sparse.SparseSet // visited NFA states during epsilon closure + stack []stackEntry // DFS stack + matched bool // true if we've reached a match state in current closure + matchMask uint32 // slot mask accumulated to reach match state + fffdTargets []closureEntry // NFA states behind LookInvalidUTF8 (for FFFD fallback) // DFA state being built numStates int // number of DFA states created @@ -162,6 +163,7 @@ func (b *Builder) epsilonClosureOnePass(root nfa.StateID) ([]closureEntry, bool, b.seen.Clear() b.matched = false b.matchMask = 0 + b.fffdTargets = b.fffdTargets[:0] b.stack = b.stack[:0] // Start DFS from root @@ -230,19 +232,20 @@ func (b *Builder) epsilonClosureOnePass(root nfa.StateID) ([]closureEntry, bool, } case nfa.StateLook: - // Handle anchors (^, $, \A, \z) as epsilon transitions. - // For onepass DFA (which is always anchored at start): - // - Start anchors (^, \A): Always satisfied - follow epsilon - // - End anchors ($, \z): Follow epsilon; match checked at input end - _, next := state.Look() - if next != nfa.InvalidState { + look, next := state.Look() + if look == nfa.LookInvalidUTF8 { + // Don't follow as epsilon — it would create byte-range + // overlap with multi-byte UTF-8 transitions. Record the + // target; buildTransitions fills dead byte classes ≥ 0x80 + // with conditional FFFD transitions checked at runtime. + if next != nfa.InvalidState { + b.fffdTargets = append(b.fffdTargets, closureEntry{next, slots}) + } + } else if next != nfa.InvalidState { if err := b.stackPush(next, slots); err != nil { return nil, false, err } } - - // ByteRange and Sparse are not epsilon transitions - // They will be handled in buildTransitions } } @@ -276,7 +279,7 @@ type transInfo struct { // (entry.slots), not from the target state. These slots represent capture // positions that should be recorded BEFORE consuming the byte. // -//nolint:gocognit // complexity inherent to DFA construction algorithm +//nolint:gocognit,cyclop // complexity inherent to DFA construction algorithm func (b *Builder) buildTransitions(tableIdx int, closure []closureEntry) error { // Track which byte classes have transitions // Key: byte class, Value: target NFA state + source slots @@ -339,16 +342,13 @@ func (b *Builder) buildTransitions(tableIdx int, closure []closureEntry) error { // Build DFA transitions from byte transitions for class, info := range byteTransitions { - // Recursively build target DFA state nextDFA, err := b.buildState(info.targetNFA) if err != nil { return err } - // Create transition with SOURCE slots (applied at current position BEFORE consuming byte) trans := NewTransition(nextDFA, false, info.slots) - // Store in table idx := tableIdx + int(class) if idx >= len(b.table) { return fmt.Errorf("transition table index out of bounds") @@ -356,6 +356,52 @@ func (b *Builder) buildTransitions(tableIdx int, closure []closureEntry) error { b.table[idx] = trans } + // FFFD fallback: for LookInvalidUTF8 targets, fill dead byte classes ≥ 0x80 + // with conditional transitions. These are checked at runtime via + // isInvalidUTF8Position — the assertion is deterministic so one-pass + // property is preserved. + for _, entry := range b.fffdTargets { + fffdState := b.nfa.State(entry.nfaID) + if fffdState == nil { + continue + } + // The FFFD target is a ByteRange(0x80, 0xFF) or Sparse state + var fffdNext nfa.StateID + switch fffdState.Kind() { + case nfa.StateByteRange: + _, _, fffdNext = fffdState.ByteRange() + case nfa.StateSparse: + trans := fffdState.Transitions() + if len(trans) > 0 { + fffdNext = trans[0].Next + } + default: + continue + } + + nextDFA, err := b.buildState(fffdNext) + if err != nil { + return err + } + + // Fill dead byte classes in 0x80-0xFF with conditional FFFD transition + for by := 0x80; by <= 0xFF; by++ { + class := b.nfa.ByteClasses().Get(byte(by)) + if _, exists := byteTransitions[class]; exists { + continue + } + idx := tableIdx + int(class) + if idx >= len(b.table) { + continue + } + if b.table[idx].IsDead() { + trans := NewTransition(nextDFA, false, entry.slots) + trans = trans.WithLookAround(lookInvalidUTF8Bit) + b.table[idx] = trans + } + } + } + return nil } diff --git a/dfa/onepass/onepass.go b/dfa/onepass/onepass.go index 34f5bdc..6ac1922 100644 --- a/dfa/onepass/onepass.go +++ b/dfa/onepass/onepass.go @@ -133,23 +133,24 @@ func (d *DFA) NumCaptures() int { func (d *DFA) IsMatch(input []byte) bool { state := d.startState - for _, b := range input { + for pos, b := range input { class := d.classes.Get(b) trans := d.getTransition(state, class) + if trans.NeedsInvalidUTF8Check() && !nfa.IsInvalidUTF8Position(input, pos) { + return false + } if trans.IsDead() { return false } state = trans.NextState() - // Check for match (early termination) if d.isMatchState(state) { return true } } - // Check final state return d.isMatchState(state) } diff --git a/dfa/onepass/search.go b/dfa/onepass/search.go index 6410549..e036400 100644 --- a/dfa/onepass/search.go +++ b/dfa/onepass/search.go @@ -1,5 +1,7 @@ package onepass +import "github.com/coregx/coregex/nfa" + // Search performs an anchored search starting at input[0]. // Returns the capture group slots or nil if no match. // @@ -33,7 +35,10 @@ func (d *DFA) Search(input []byte, cache *Cache) []int { class := d.classes.Get(b) trans := d.getTransition(state, class) - // Check for dead state (no match) + // Conditional FFFD transition: check assertion at runtime + if trans.NeedsInvalidUTF8Check() && !nfa.IsInvalidUTF8Position(input, pos) { + return nil + } if trans.IsDead() { return nil } diff --git a/dfa/onepass/transition.go b/dfa/onepass/transition.go index c7eedf6..ac18088 100644 --- a/dfa/onepass/transition.go +++ b/dfa/onepass/transition.go @@ -26,6 +26,10 @@ const ( slotMask = 0xFFFFFFFF // bits 0-31 + // lookInvalidUTF8Bit flags a transition as conditional on LookInvalidUTF8. + // The search loop checks isInvalidUTF8Position before taking such transitions. + lookInvalidUTF8Bit = uint16(1 << 0) + // DeadState represents a dead/fail state (no valid transition) DeadState StateID = 0 @@ -104,3 +108,9 @@ func (t Transition) WithLookAround(look uint16) Transition { // Clear existing look-around bits, set new ones return (t &^ Transition(lookAroundMask)) | (Transition(look) << lookAroundShift) } + +// NeedsInvalidUTF8Check returns true if this transition is conditional on +// LookInvalidUTF8 and must be verified at runtime via isInvalidUTF8Position. +func (t Transition) NeedsInvalidUTF8Check() bool { + return t.LookAround()&lookInvalidUTF8Bit != 0 +} diff --git a/fuzz_stdlib_test.go b/fuzz_stdlib_test.go index af14efd..328c555 100644 --- a/fuzz_stdlib_test.go +++ b/fuzz_stdlib_test.go @@ -170,14 +170,14 @@ func hasUTF8CodepointDifference(pattern, input string) bool { // Only empty-match stepping patterns remain (class B/C) emptyMatchPatterns := map[string]bool{ - ``: true, // empty pattern - `a*`: true, // can match empty - `a?`: true, // can match empty - `a*?`: true, // can match empty - `a??`: true, // can match empty - `.*`: true, // can match empty - `.*?`: true, // can match empty - `.?`: true, // can match empty + ``: true, // empty pattern + `a*`: true, // can match empty + `a?`: true, // can match empty + `a*?`: true, // can match empty + `a??`: true, // can match empty + `.*`: true, // can match empty + `.*?`: true, // can match empty + `.?`: true, // can match empty `(.)*`: true, // can match empty `(.)?`: true, // can match empty } diff --git a/nfa/compile.go b/nfa/compile.go index 81e244f..03d2f8a 100644 --- a/nfa/compile.go +++ b/nfa/compile.go @@ -634,6 +634,7 @@ func (c *Compiler) compileUnicodeClassLarge(ranges []rune) (start, end StateID, // // UTF-8 encoding: // - 1-byte: U+0000-U+007F → 0x00-0x7F +// // containsRuneFFFD checks if U+FFFD is within any of the given rune ranges. func containsRuneFFFD(ranges [][2]rune) bool { for _, rng := range ranges { @@ -653,9 +654,9 @@ func (c *Compiler) buildInvalidUTF8Fallback(endState StateID) StateID { return c.builder.AddLook(LookInvalidUTF8, invalidByte) } -// - 2-byte: U+0080-U+07FF → 0xC2-0xDF, 0x80-0xBF -// - 3-byte: U+0800-U+FFFF → 0xE0-0xEF, 0x80-0xBF, 0x80-0xBF -// - 4-byte: U+10000-U+10FFFF → 0xF0-0xF4, 0x80-0xBF, 0x80-0xBF, 0x80-0xBF +// - 2-byte: U+0080-U+07FF → 0xC2-0xDF, 0x80-0xBF +// - 3-byte: U+0800-U+FFFF → 0xE0-0xEF, 0x80-0xBF, 0x80-0xBF +// - 4-byte: U+10000-U+10FFFF → 0xF0-0xF4, 0x80-0xBF, 0x80-0xBF, 0x80-0xBF func (c *Compiler) compileUTF8Range(lo, hi rune, endState StateID) []StateID { var starts []StateID diff --git a/nfa/pikevm.go b/nfa/pikevm.go index 3c4160b..5ca2d0e 100644 --- a/nfa/pikevm.go +++ b/nfa/pikevm.go @@ -1670,16 +1670,16 @@ func checkLookAssertion(look Look, haystack []byte, pos int) bool { wordAfter := pos < len(haystack) && isWordByte(haystack[pos]) return wordBefore == wordAfter case LookInvalidUTF8: - return isInvalidUTF8Position(haystack, pos) + return IsInvalidUTF8Position(haystack, pos) } return false } -// isInvalidUTF8Position checks if position pos in haystack is an invalid UTF-8 +// IsInvalidUTF8Position checks if position pos in haystack is an invalid UTF-8 // byte that should be treated as U+FFFD width 1. Two conditions must hold: // 1. The position is at a rune boundary (not inside a valid multi-byte sequence) // 2. utf8.DecodeRune(haystack[pos:]) returns (RuneError, 1) -func isInvalidUTF8Position(haystack []byte, pos int) bool { +func IsInvalidUTF8Position(haystack []byte, pos int) bool { if pos >= len(haystack) { return false } From a61f3aa68b4866f8c455268ae9ef4a305da6b693 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 9 Oct 2026 20:46:20 +0300 Subject: [PATCH 12/12] docs: add #179 invalid UTF-8 to CHANGELOG [Unreleased] --- CHANGELOG.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1510724..ca0008b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Invalid UTF-8 bytes now match as U+FFFD width 1** ([#179](https://github.com/coregx/coregex/issues/179)): + `.`, `\S`, `\D`, `\W`, `[^x]`, `\P{Han}`, and literal `\x{FFFD}` now match + invalid UTF-8 bytes (orphan continuations, overlong leads, out-of-range bytes) + as U+FFFD replacement character with width 1, matching stdlib semantics. + New `LookInvalidUTF8` assertion ensures matches fire only at rune boundaries, + not inside valid multi-byte sequences. All four engines (PikeVM, BoundedBacktracker, + OnePass DFA, lazy DFA) handle the assertion correctly. Lazy DFA uses temporary + quit-to-PikeVM fallback on bytes ≥ 0x80 (phase 3 will add UTF-8 validator product). + Harness divergence: 14 → 1 (only `FindReaderIndex` remains) - **Negated classes matched bytes, not runes** ([#174](https://github.com/coregx/coregex/issues/174)): `\S{2}` matched single Cyrillic `К` (2 UTF-8 bytes, 1 rune) by treating each byte as a separate character. Same for `\D`, `\W`, `[^x]` on any multi-byte rune. Root cause: `0x80–0xFF` single-byte range in NFA compiler's - `coversAllNonASCII` path. Invalid UTF-8 byte handling deferred to - [#179](https://github.com/coregx/coregex/issues/179). + `coversAllNonASCII` path. Random-fuzz divergence vs stdlib: 0.75% → 0.21% ### Planned