mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
565 lines
15 KiB
Go
565 lines
15 KiB
Go
package peco
|
|
|
|
import (
|
|
"context"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lestrrat-go/pdebug"
|
|
"github.com/peco/peco/filter"
|
|
"github.com/peco/peco/hub"
|
|
"github.com/peco/peco/internal/buffer"
|
|
"github.com/peco/peco/line"
|
|
"github.com/peco/peco/pipeline"
|
|
)
|
|
|
|
// FilterErrorHandler handles errors that occur during filter execution.
|
|
type FilterErrorHandler interface {
|
|
HandleError(error)
|
|
}
|
|
|
|
// FilterErrorHandlerFunc is a function that implements FilterErrorHandler.
|
|
type FilterErrorHandlerFunc func(error)
|
|
|
|
// HandleError calls the underlying function.
|
|
func (f FilterErrorHandlerFunc) HandleError(err error) {
|
|
f(err)
|
|
}
|
|
|
|
// nopFilterErrorHandler is a FilterErrorHandler that silently discards errors.
|
|
var nopFilterErrorHandler = FilterErrorHandlerFunc(func(error) {})
|
|
|
|
// Filter is responsible for the actual "grep" part of peco
|
|
type Filter struct {
|
|
state *Peco
|
|
prevQuery string
|
|
prevResults *MemoryBuffer
|
|
prevFilterName string
|
|
prevFrozenSrc *MemoryBuffer // frozen source at cache time
|
|
prevMu sync.Mutex
|
|
}
|
|
|
|
type filterProcessor struct {
|
|
filter filter.Filter
|
|
query string
|
|
bufSize int
|
|
errorHandler FilterErrorHandler
|
|
}
|
|
|
|
// newFilterProcessor creates a filterProcessor that runs the given filter
|
|
// against a query, managing result buffering and error reporting.
|
|
func newFilterProcessor(f filter.Filter, q string, bufSize int, eh FilterErrorHandler) *filterProcessor {
|
|
return &filterProcessor{
|
|
filter: f,
|
|
query: q,
|
|
bufSize: bufSize,
|
|
errorHandler: eh,
|
|
}
|
|
}
|
|
|
|
// Accept receives lines from the pipeline, applies the filter, and forwards matches.
|
|
func (fp *filterProcessor) Accept(ctx context.Context, in <-chan line.Line, out pipeline.ChanOutput) {
|
|
acceptAndFilter(ctx, fp.filter, fp.bufSize, fp.errorHandler, in, out)
|
|
}
|
|
|
|
// orderedChunk is a batch of lines tagged with a sequence number
|
|
// so that parallel filter results can be merged back in order.
|
|
type orderedChunk struct {
|
|
seq int
|
|
lines []line.Line
|
|
}
|
|
|
|
// orderedResult is a filtered chunk tagged with the original sequence number.
|
|
type orderedResult struct {
|
|
seq int
|
|
matched []line.Line
|
|
}
|
|
|
|
// reportFilterError reports a filter error to the given handler.
|
|
// If the context is already cancelled or err is nil, it does nothing.
|
|
func reportFilterError(ctx context.Context, err error, eh FilterErrorHandler) {
|
|
if err == nil || ctx.Err() != nil {
|
|
return
|
|
}
|
|
eh.HandleError(err)
|
|
}
|
|
|
|
// flusher is the single-threaded fallback used when the filter does not
|
|
// support parallel execution (e.g. Fuzzy with sortLongest).
|
|
func flusher(ctx context.Context, f filter.Filter, incoming chan []line.Line, done chan struct{}, out pipeline.ChanOutput, eh FilterErrorHandler) {
|
|
if pdebug.Enabled {
|
|
g := pdebug.Marker("flusher goroutine")
|
|
defer g.End()
|
|
}
|
|
|
|
defer close(done)
|
|
defer close(out)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case buf, ok := <-incoming:
|
|
if !ok {
|
|
return
|
|
}
|
|
if pdebug.Enabled {
|
|
pdebug.Printf("flusher: %#v", buf)
|
|
}
|
|
if err := f.Apply(ctx, buf, out); err != nil {
|
|
reportFilterError(ctx, err, eh)
|
|
}
|
|
buffer.ReleaseLineListBuf(buf)
|
|
}
|
|
}
|
|
}
|
|
|
|
// parallelFlusher distributes filter work across multiple goroutines
|
|
// and merges the results back in sequence order.
|
|
func parallelFlusher(ctx context.Context, f filter.Filter, incoming chan orderedChunk, done chan struct{}, out pipeline.ChanOutput, eh FilterErrorHandler) {
|
|
if pdebug.Enabled {
|
|
g := pdebug.Marker("parallelFlusher goroutine")
|
|
defer g.End()
|
|
}
|
|
|
|
defer close(done)
|
|
defer close(out)
|
|
|
|
numWorkers := max(runtime.GOMAXPROCS(0), 1)
|
|
|
|
// workCh distributes chunks to workers
|
|
workCh := make(chan orderedChunk, numWorkers*2)
|
|
// resultCh collects filtered results from workers
|
|
resultCh := make(chan orderedResult, numWorkers*2)
|
|
|
|
// Check once whether the filter supports direct collection (bypasses
|
|
// per-chunk channel allocation and goroutine spawn).
|
|
collector, canCollect := f.(filter.Collector)
|
|
|
|
// Start workers
|
|
var workerWg sync.WaitGroup
|
|
workerWg.Add(numWorkers)
|
|
for range numWorkers {
|
|
go func() {
|
|
defer workerWg.Done()
|
|
for chunk := range workCh {
|
|
select {
|
|
case <-ctx.Done():
|
|
buffer.ReleaseLineListBuf(chunk.lines)
|
|
continue
|
|
default:
|
|
}
|
|
|
|
var matched []line.Line
|
|
if canCollect {
|
|
// Fast path: collect results directly into a slice
|
|
var err error
|
|
matched, err = collector.ApplyCollect(ctx, chunk.lines)
|
|
if err != nil {
|
|
reportFilterError(ctx, err, eh)
|
|
}
|
|
} else {
|
|
// Fallback: use channel-based Apply for filters that
|
|
// don't implement Collector (e.g. ExternalCmd)
|
|
collectCh := make(chan line.Line, len(chunk.lines))
|
|
go func(chunk orderedChunk) {
|
|
if err := f.Apply(ctx, chunk.lines, pipeline.ChanOutput(collectCh)); err != nil {
|
|
reportFilterError(ctx, err, eh)
|
|
}
|
|
close(collectCh)
|
|
}(chunk)
|
|
matched = make([]line.Line, 0, len(chunk.lines)/2)
|
|
for l := range collectCh {
|
|
matched = append(matched, l)
|
|
}
|
|
}
|
|
|
|
buffer.ReleaseLineListBuf(chunk.lines)
|
|
|
|
select {
|
|
case resultCh <- orderedResult{seq: chunk.seq, matched: matched}:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Close resultCh when all workers are done
|
|
go func() {
|
|
workerWg.Wait()
|
|
close(resultCh)
|
|
}()
|
|
|
|
// Merger goroutine: reorder results and send to out in sequence order
|
|
mergerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(mergerDone)
|
|
pending := make(map[int]orderedResult)
|
|
nextSeq := 0
|
|
|
|
for result := range resultCh {
|
|
pending[result.seq] = result
|
|
|
|
// Flush as many in-order results as possible
|
|
for {
|
|
r, ok := pending[nextSeq]
|
|
if !ok {
|
|
break
|
|
}
|
|
delete(pending, nextSeq)
|
|
nextSeq++
|
|
|
|
for _, l := range r.matched {
|
|
if err := out.Send(ctx, l); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush any remaining (shouldn't happen if producers are well-behaved)
|
|
for seq := nextSeq; ; seq++ {
|
|
r, ok := pending[seq]
|
|
if !ok {
|
|
break
|
|
}
|
|
for _, l := range r.matched {
|
|
if err := out.Send(ctx, l); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Dispatch incoming chunks to workers
|
|
for chunk := range incoming {
|
|
select {
|
|
case <-ctx.Done():
|
|
buffer.ReleaseLineListBuf(chunk.lines)
|
|
case workCh <- chunk:
|
|
}
|
|
}
|
|
close(workCh)
|
|
|
|
// Wait for merger to finish
|
|
<-mergerDone
|
|
}
|
|
|
|
// AcceptAndFilter is the exported entry point for the filter pipeline stage.
|
|
// It batches incoming lines and dispatches them to the filter, using parallel
|
|
// workers when the filter supports it.
|
|
func AcceptAndFilter(ctx context.Context, f filter.Filter, configBufSize int, in <-chan line.Line, out pipeline.ChanOutput) {
|
|
acceptAndFilter(ctx, f, configBufSize, nopFilterErrorHandler, in, out)
|
|
}
|
|
|
|
// acceptAndFilter is the core filtering loop: it reads lines from in, batches
|
|
// them, applies the filter function, and buffers matches for output.
|
|
func acceptAndFilter(ctx context.Context, f filter.Filter, configBufSize int, eh FilterErrorHandler, in <-chan line.Line, out pipeline.ChanOutput) {
|
|
useParallel := f.SupportsParallel() && runtime.GOMAXPROCS(0) > 1
|
|
|
|
buf := buffer.GetLineListBuf()
|
|
bufsiz := f.BufSize()
|
|
if bufsiz <= 0 {
|
|
if configBufSize > 0 {
|
|
bufsiz = configBufSize
|
|
} else {
|
|
bufsiz = cap(buf)
|
|
}
|
|
}
|
|
|
|
if useParallel {
|
|
acceptAndFilterParallel(ctx, f, bufsiz, buf, eh, in, out)
|
|
} else {
|
|
acceptAndFilterSerial(ctx, f, bufsiz, buf, eh, in, out)
|
|
}
|
|
}
|
|
|
|
// acceptAndFilterSerial runs the filter in a single goroutine, used as the
|
|
// fallback when the filter does not support parallel execution.
|
|
func acceptAndFilterSerial(ctx context.Context, f filter.Filter, bufsiz int, buf []line.Line, eh FilterErrorHandler, in <-chan line.Line, out pipeline.ChanOutput) {
|
|
flush := make(chan []line.Line)
|
|
flushDone := make(chan struct{})
|
|
go flusher(ctx, f, flush, flushDone, out, eh)
|
|
defer func() { <-flushDone }()
|
|
defer close(flush)
|
|
|
|
batchAndFlush(ctx, bufsiz, buf, in, flush, func(b []line.Line) []line.Line { return b })
|
|
}
|
|
|
|
// acceptAndFilterParallel distributes filter work across multiple goroutines,
|
|
// tagging each batch with a sequence number for ordered result merging.
|
|
func acceptAndFilterParallel(ctx context.Context, f filter.Filter, bufsiz int, buf []line.Line, eh FilterErrorHandler, in <-chan line.Line, out pipeline.ChanOutput) {
|
|
flush := make(chan orderedChunk)
|
|
flushDone := make(chan struct{})
|
|
go parallelFlusher(ctx, f, flush, flushDone, out, eh)
|
|
defer func() { <-flushDone }()
|
|
defer close(flush)
|
|
|
|
seq := 0
|
|
batchAndFlush(ctx, bufsiz, buf, in, flush, func(b []line.Line) orderedChunk {
|
|
chunk := orderedChunk{seq: seq, lines: b}
|
|
seq++
|
|
return chunk
|
|
})
|
|
}
|
|
|
|
// batchAndFlush reads lines from in, batches them into slices of up to bufsiz,
|
|
// and sends each batch to flushCh via the wrap function. Batches are flushed
|
|
// when full or every 50ms, whichever comes first.
|
|
func batchAndFlush[T any](ctx context.Context, bufsiz int, buf []line.Line, in <-chan line.Line, flushCh chan T, wrap func([]line.Line) T) {
|
|
flushTicker := time.NewTicker(50 * time.Millisecond)
|
|
defer flushTicker.Stop()
|
|
|
|
start := time.Now()
|
|
lines := 0
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
if pdebug.Enabled {
|
|
pdebug.Printf("filter received done")
|
|
}
|
|
return
|
|
case <-flushTicker.C:
|
|
if len(buf) > 0 {
|
|
flushCh <- wrap(buf)
|
|
buf = buffer.GetLineListBuf()
|
|
}
|
|
case v, ok := <-in:
|
|
if !ok {
|
|
if pdebug.Enabled {
|
|
pdebug.Printf("filter input closed (read %d lines, %s since starting accept loop)", lines+len(buf), time.Since(start).String())
|
|
}
|
|
if len(buf) > 0 {
|
|
flushCh <- wrap(buf)
|
|
}
|
|
return
|
|
}
|
|
if pdebug.Enabled {
|
|
pdebug.Printf("incoming line")
|
|
lines++
|
|
}
|
|
buf = append(buf, v)
|
|
if len(buf) >= bufsiz {
|
|
flushCh <- wrap(buf)
|
|
buf = buffer.GetLineListBuf()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewFilter creates a new Filter bound to the given Peco state.
|
|
func NewFilter(state *Peco) *Filter {
|
|
return &Filter{
|
|
state: state,
|
|
}
|
|
}
|
|
|
|
// isQueryRefinement returns true if newQuery is a refinement of prevQuery,
|
|
// meaning the new query can only produce a subset of the previous results.
|
|
// With negative terms, refinement requires:
|
|
// 1. Positive portion of prev is a prefix of positive portion of new
|
|
// 2. All previous negative terms are still present in new
|
|
// 3. New query may have additional positive or negative terms
|
|
func isQueryRefinement(prev, cur string) bool {
|
|
prev = strings.TrimSpace(prev)
|
|
cur = strings.TrimSpace(cur)
|
|
if prev == "" || cur == "" {
|
|
return false
|
|
}
|
|
|
|
prevPos, prevNeg := filter.SplitQueryTerms(prev)
|
|
newPos, newNeg := filter.SplitQueryTerms(cur)
|
|
|
|
// Positive portion: the joined prev positive terms must be a prefix of the joined new positive terms
|
|
prevPosStr := strings.Join(prevPos, " ")
|
|
newPosStr := strings.Join(newPos, " ")
|
|
if prevPosStr != "" && !strings.HasPrefix(newPosStr, prevPosStr) {
|
|
return false
|
|
}
|
|
|
|
// All previous negative terms must still be present in new negative terms
|
|
if len(prevNeg) > 0 {
|
|
newNegSet := make(map[string]struct{}, len(newNeg))
|
|
for _, t := range newNeg {
|
|
newNegSet[t] = struct{}{}
|
|
}
|
|
for _, t := range prevNeg {
|
|
if _, ok := newNegSet[t]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// At least one positive or negative term must exist in both
|
|
if len(prevPos) == 0 && len(prevNeg) == 0 {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Work is the actual work horse that does the matching
|
|
// in a goroutine of its own. It wraps Matcher.Match().
|
|
func (f *Filter) Work(ctx context.Context, q *hub.Payload[string]) {
|
|
defer q.Done()
|
|
|
|
query := q.Data()
|
|
|
|
if pdebug.Enabled {
|
|
g := pdebug.Marker("Filter.Work (query=%#v, batch=%#v)", query, q.Batch())
|
|
defer g.End()
|
|
}
|
|
|
|
state := f.state
|
|
if query == "" {
|
|
f.prevMu.Lock()
|
|
f.prevQuery = ""
|
|
f.prevResults = nil
|
|
f.prevFilterName = ""
|
|
f.prevFrozenSrc = nil
|
|
f.prevMu.Unlock()
|
|
|
|
state.ResetCurrentLineBuffer(ctx)
|
|
if !state.config.StickySelection {
|
|
state.Selection().Reset()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Create a new pipeline
|
|
p := pipeline.New()
|
|
|
|
// Determine the source: use incremental filtering if possible
|
|
selectedFilter := state.Filters().Current()
|
|
filterName := selectedFilter.String()
|
|
|
|
var src pipeline.Source
|
|
var srcSize int
|
|
f.prevMu.Lock()
|
|
if f.prevResults != nil &&
|
|
f.prevFilterName == filterName &&
|
|
f.prevFrozenSrc == state.Frozen().Source() &&
|
|
isQueryRefinement(f.prevQuery, query) {
|
|
if pdebug.Enabled {
|
|
pdebug.Printf("Using incremental source (prev=%q, new=%q, prevSize=%d)", f.prevQuery, query, f.prevResults.Size())
|
|
}
|
|
srcSize = f.prevResults.Size()
|
|
src = NewMemoryBufferSource(f.prevResults)
|
|
}
|
|
f.prevMu.Unlock()
|
|
|
|
if src == nil {
|
|
if fs := state.Frozen().Source(); fs != nil {
|
|
src = NewMemoryBufferSource(fs)
|
|
srcSize = fs.Size()
|
|
} else {
|
|
src = state.Source()
|
|
if sizer, ok := src.(interface{ Size() int }); ok {
|
|
srcSize = sizer.Size()
|
|
}
|
|
}
|
|
}
|
|
p.SetSource(src)
|
|
|
|
ctx = selectedFilter.NewContext(ctx, query)
|
|
// Report non-cancellation filter errors (e.g. regex compilation failures)
|
|
// to the status bar so the user can see why results are missing.
|
|
onFilterError := FilterErrorHandlerFunc(func(err error) {
|
|
state.Hub().SendStatusMsg(ctx, err.Error(), 5*time.Second)
|
|
})
|
|
p.Add(newFilterProcessor(selectedFilter, query, state.config.FilterBufSize, onFilterError))
|
|
|
|
buf := NewMemoryBuffer(srcSize / 4)
|
|
p.SetDestination(buf)
|
|
state.SetCurrentLineBuffer(ctx, buf)
|
|
|
|
go func(ctx context.Context) {
|
|
defer state.Hub().SendDraw(ctx, &hub.DrawOptions{RunningQuery: true})
|
|
if err := p.Run(ctx); err != nil {
|
|
state.Hub().SendStatusMsg(ctx, err.Error(), 0)
|
|
}
|
|
}(ctx)
|
|
|
|
go func() {
|
|
if pdebug.Enabled {
|
|
g := pdebug.Marker("Periodic draw request for '%s'", query)
|
|
defer g.End()
|
|
}
|
|
t := time.NewTicker(50 * time.Millisecond)
|
|
defer t.Stop()
|
|
defer state.Hub().SendStatusMsg(ctx, "", 0)
|
|
defer state.Hub().SendDraw(ctx, &hub.DrawOptions{RunningQuery: true})
|
|
for {
|
|
select {
|
|
case <-p.Done():
|
|
return
|
|
case <-t.C:
|
|
state.Hub().SendDraw(ctx, &hub.DrawOptions{RunningQuery: true})
|
|
}
|
|
}
|
|
}()
|
|
|
|
<-p.Done()
|
|
|
|
// Save results for incremental filtering only if pipeline completed
|
|
// successfully (context not cancelled)
|
|
if ctx.Err() == nil {
|
|
f.prevMu.Lock()
|
|
f.prevQuery = query
|
|
f.prevResults = buf
|
|
f.prevFilterName = filterName
|
|
f.prevFrozenSrc = state.Frozen().Source()
|
|
f.prevMu.Unlock()
|
|
}
|
|
|
|
if !state.config.StickySelection {
|
|
state.Selection().Reset()
|
|
}
|
|
}
|
|
|
|
// Loop keeps watching for incoming queries, and upon receiving
|
|
// a query, spawns a goroutine to do the heavy work. It also
|
|
// checks for previously running queries, so we can avoid
|
|
// running many goroutines doing the grep at the same time
|
|
func (f *Filter) Loop(ctx context.Context, cancel func()) error {
|
|
defer cancel()
|
|
|
|
// wg tracks in-flight Work goroutines so Loop doesn't return
|
|
// while they are still running. This ensures clean shutdown
|
|
// without blocking new queries from starting immediately.
|
|
var wg sync.WaitGroup
|
|
defer wg.Wait()
|
|
|
|
// previous holds the function that can cancel the previous
|
|
// query. This is used when multiple queries come in succession
|
|
// and the previous query is discarded anyway
|
|
var mutex sync.Mutex
|
|
var previous func()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case q := <-f.state.Hub().QueryCh():
|
|
workctx, workcancel := context.WithCancel(ctx)
|
|
|
|
mutex.Lock()
|
|
if previous != nil {
|
|
if pdebug.Enabled {
|
|
pdebug.Printf("Canceling previous query")
|
|
}
|
|
previous()
|
|
}
|
|
previous = workcancel
|
|
mutex.Unlock()
|
|
|
|
f.state.Hub().SendStatusMsg(ctx, "Running query...", 0)
|
|
|
|
wg.Go(func() {
|
|
f.Work(workctx, q)
|
|
})
|
|
}
|
|
}
|
|
}
|