v3 candidate
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
|
||||
"github.com/andydixon/nexus/internal/leviathan/document"
|
||||
)
|
||||
|
||||
// SearchHit describes one search result.
|
||||
type SearchHit struct {
|
||||
Offset int64 `json:"offset"`
|
||||
Length int `json:"length"`
|
||||
Sample string `json:"sample"` // short hex preview
|
||||
}
|
||||
|
||||
// SearchBytes finds `needle` in the document range and returns hit
|
||||
// offsets. Scans chunk by chunk with an overlap equal to len(needle)-1
|
||||
// so cross-chunk matches are not missed.
|
||||
func SearchBytes(d *document.Document, start, length int64, needle []byte, max int) ([]SearchHit, error) {
|
||||
return SearchBytesContext(context.Background(), d, start, length, needle, max)
|
||||
}
|
||||
|
||||
func SearchBytesContext(ctx context.Context, d *document.Document, start, length int64, needle []byte, max int) ([]SearchHit, error) {
|
||||
if len(needle) == 0 {
|
||||
return nil, errors.New("empty needle")
|
||||
}
|
||||
if max <= 0 {
|
||||
max = 10_000
|
||||
}
|
||||
if length < 0 {
|
||||
length = d.Size() - start
|
||||
}
|
||||
|
||||
hits := make([]SearchHit, 0, 64)
|
||||
overlap := int64(len(needle) - 1)
|
||||
|
||||
// We chunk in windows of chunkSize + overlap.
|
||||
const chunkSize = 1 << 20 // 1MB
|
||||
off := start
|
||||
endExclusive := start + length
|
||||
|
||||
for off < endExclusive && len(hits) < max {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
winEnd := off + chunkSize
|
||||
if winEnd > endExclusive {
|
||||
winEnd = endExclusive
|
||||
}
|
||||
readEnd := winEnd + overlap
|
||||
if readEnd > endExclusive {
|
||||
readEnd = endExclusive
|
||||
}
|
||||
buf, err := d.Range(off, readEnd-off)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Limit search to bytes whose start lies in [0, winEnd-off)
|
||||
searchLimit := winEnd - off
|
||||
scan := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx := bytes.Index(buf[scan:], needle)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
abs := int64(scan+idx) + off
|
||||
relStart := abs - off
|
||||
if relStart >= searchLimit {
|
||||
break
|
||||
}
|
||||
hits = append(hits, SearchHit{
|
||||
Offset: abs,
|
||||
Length: len(needle),
|
||||
Sample: hexPreview(buf[scan+idx:], len(needle)),
|
||||
})
|
||||
if len(hits) >= max {
|
||||
break
|
||||
}
|
||||
scan = scan + idx + 1
|
||||
if scan >= len(buf) {
|
||||
break
|
||||
}
|
||||
}
|
||||
off = winEnd
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// SearchRegex runs a Go regexp across the document range, matching
|
||||
// byte-wise. It decodes matches chunk by chunk with overlap, bounded
|
||||
// by a generous max hit count.
|
||||
func SearchRegex(d *document.Document, start, length int64, pattern string, max int) ([]SearchHit, error) {
|
||||
return SearchRegexContext(context.Background(), d, start, length, pattern, max)
|
||||
}
|
||||
|
||||
func SearchRegexContext(ctx context.Context, d *document.Document, start, length int64, pattern string, max int) ([]SearchHit, error) {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if max <= 0 {
|
||||
max = 10_000
|
||||
}
|
||||
if length < 0 {
|
||||
length = d.Size() - start
|
||||
}
|
||||
|
||||
// For regex, a conservative overlap is 4 KiB so matches spanning
|
||||
// chunk boundaries are still caught. Very long matches may still
|
||||
// be missed in extreme cases.
|
||||
const chunkSize = 1 << 20
|
||||
const overlap = 4096
|
||||
|
||||
hits := make([]SearchHit, 0, 64)
|
||||
off := start
|
||||
endExclusive := start + length
|
||||
|
||||
for off < endExclusive && len(hits) < max {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
winEnd := off + chunkSize
|
||||
if winEnd > endExclusive {
|
||||
winEnd = endExclusive
|
||||
}
|
||||
readEnd := winEnd + overlap
|
||||
if readEnd > endExclusive {
|
||||
readEnd = endExclusive
|
||||
}
|
||||
buf, err := d.Range(off, readEnd-off)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
searchLimit := winEnd - off
|
||||
locs := re.FindAllIndex(buf, max-len(hits))
|
||||
for _, loc := range locs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relStart := int64(loc[0])
|
||||
if relStart >= searchLimit {
|
||||
continue
|
||||
}
|
||||
abs := relStart + off
|
||||
hits = append(hits, SearchHit{
|
||||
Offset: abs,
|
||||
Length: loc[1] - loc[0],
|
||||
Sample: hexPreview(buf[loc[0]:loc[1]], loc[1]-loc[0]),
|
||||
})
|
||||
}
|
||||
off = winEnd
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
func hexPreview(b []byte, n int) string {
|
||||
if n > 32 {
|
||||
n = 32
|
||||
}
|
||||
if n > len(b) {
|
||||
n = len(b)
|
||||
}
|
||||
const hexchars = "0123456789abcdef"
|
||||
out := make([]byte, n*2)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i*2] = hexchars[b[i]>>4]
|
||||
out[i*2+1] = hexchars[b[i]&0x0f]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// HitsToMaps converts hits for bridge return.
|
||||
func HitsToMaps(hits []SearchHit) []map[string]any {
|
||||
out := make([]map[string]any, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = map[string]any{
|
||||
"offset": h.Offset,
|
||||
"length": h.Length,
|
||||
"sample": h.Sample,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user