Bounded search, masking and replacement

Scan, MaskText, and ReplaceText exist on both AhoCorasick and VersionedCollection and all take a context. The existing Find/FindMatches APIs keep their unlimited result and position contracts. A V3 call holds one serving engine for the whole scan or rewrite, even if a refresh finishes concurrently.

Scan

Scan(ctx, text, *ScanOptions) returns SourceMatch entries carrying the normalized Keyword, the original Text substring, and half-open Start/End rune and ByteStart/ByteEnd byte offsets into the original input.

Case folding can change byte lengths — İSTANBUL matches istanbul, and its byte span still selects the original spelling. Output text is never normalized. Invalid UTF-8 is decoded as RuneError for matching, while the reported byte slices keep those exact original bytes.

OptionDefaultAt the limit
MaxInputBytes1 MiBErrInputLimit, before the engine is loaded or scanned
MaxMatches1,000Keeps at most this many; sets Truncated when another eligible match appears
MaxCandidates100,000ErrScanWorkLimit on the next raw automaton match

Zero-valued limits select the defaults; negative limits are rejected. Construct ScanOptions and RewriteOptions with named fields.

Kind defaults to MatchKindOverlapping. MatchKindLeftmostLongest takes the leftmost available start, then the longest keyword there, and discards overlaps — implemented by keeping the longest candidate at each pending start until the engine’s longest keyword makes the decision safe, not by collecting and sorting all raw matches. Scratch memory is bounded by input length plus the pending-start window and retained results; it is not constant.

WholeWord and WordRune carry the same normalized-rune semantics as MatchOptions: letters, digits, combining marks, and underscore are word characters by default, and scripts without spaces usually need an application-specific WordRune. Candidates are counted before whole-word and overlap filtering, so a dense rejected-match workload cannot slip past the work budget. Custom WordRune code runs in the caller’s goroutine.

Errors and Truncated are different signals. Input exhaustion, candidate exhaustion, and cancellation all return an error and no result; Truncated concerns only the eligible result count. Checking for a clean input means handling both. The context is checked during input indexing, traversal, and match handling.

Masking and replacement

ReplaceText(ctx, text, replacement, *RewriteOptions) inserts the literal replacement for each selected non-overlapping leftmost-longest match. No regular expressions, no capture expansion, no re-searching the replacement. An empty replacement deletes matched spans.

MaskText(ctx, text, maskRune, *RewriteOptions) writes one mask rune per matched original rune, preserving rune count but not necessarily byte count. Any valid Unicode rune is accepted, including NUL; invalid runes are rejected.

Both leave unmatched original bytes untouched. RewriteOptions shares the three scan limits and adds MaxOutputBytes, defaulting to 4 MiB. Overlap selection is always leftmost-longest. Exceeding MaxMatches gives ErrMatchLimit, exceeding the output bound gives ErrOutputLimit, and every error returns no RewriteResult — a caller cannot accidentally consume a half-masked document. Output size is checked before the buffer is allocated.

RewriteResult holds Text plus the SourceMatch entries used. Their offsets always refer to the input, even when replacement changes the output length. Match Text substrings can retain the original input string in memory.

package main

import (
    "context"
    "log"

    "github.com/skyoo2003/acor/pkg/acor"
)

func main() {
    ctx := context.Background()
    dictionary, err := acor.OpenVersioned(ctx, &acor.VersionedOptions{
        Redis: acor.AhoCorasickArgs{Addr: "localhost:6379", Name: "text-v3"},
    })
    if err != nil { log.Fatal(err) }
    defer dictionary.Close()

    snapshot, err := dictionary.Snapshot(ctx)
    if err != nil { log.Fatal(err) }
    expected := snapshot.Version()
    snapshot.Close(ctx)
    write, err := dictionary.Replace(ctx, expected, []string{"한국", "한국어", "istanbul"})
    if err != nil { log.Fatal(err) }
    if err := dictionary.WaitForVersion(ctx, write.Version); err != nil { log.Fatal(err) }

    found, err := dictionary.Scan(ctx, "한국어 İSTANBUL", &acor.ScanOptions{
        Kind: acor.MatchKindLeftmostLongest, MaxMatches: 10,
    })
    if err != nil { log.Fatal(err) }
    if found.Truncated { log.Fatal("incomplete result") }
    for _, match := range found.Matches {
        log.Printf("%q at input bytes [%d,%d)", match.Text, match.ByteStart, match.ByteEnd)
    }
    masked, err := dictionary.MaskText(ctx, "한국어 İSTANBUL", '*', nil)
    if err != nil { log.Fatal(err) }
    log.Print(masked.Text) // *** ********
}

Parity, resource-bound, and cancellation evidence: R2/R3 report.