Negative filters

This commit is contained in:
Daisuke Maki 2026-02-16 14:31:16 +09:00
parent 4b209aaf84
commit 87ce6ab7cb
7 changed files with 454 additions and 15 deletions

View file

@ -45,6 +45,21 @@ Multiple terms turn the query into an "AND" query:
When you find that line that you want, press enter, and the resulting line
is printed to stdout, which allows you to pipe it to other tools
## Negative Matching
You can exclude lines from the results by prefixing a term with `-`. For example, the query `SSO -tests -javadoc` shows lines matching "SSO" that do NOT contain "tests" or "javadoc".
| Query | Meaning |
|-------|---------|
| `foo -bar` | Lines matching "foo" but not containing "bar" |
| `-foo -bar` | All lines not containing "foo" or "bar" |
| `\-foo` | Literal match for "-foo" (escaped with backslash) |
| `-` | Literal match for a hyphen character |
Negative matching works with all built-in filters (IgnoreCase, CaseSensitive, SmartCase, Regexp, IRegexp, and Fuzzy). For the Fuzzy filter, negative terms use regexp-based exclusion rather than fuzzy matching. External custom filters receive the query as-is and are responsible for their own parsing.
Only positive terms produce match highlighting. Lines matched solely by negative exclusion (e.g. an all-negative query like `-foo`) are shown without highlighting.
## Select Multiple Lines
You can select multiple lines! (this example uses C-Space)
@ -797,6 +812,7 @@ Much code stolen from https://github.com/mattn/gof
- [Demo](#demo)
- [Features](#features)
- [Incremental Search](#incremental-search)
- [Negative Matching](#negative-matching)
- [Select Multiple Lines](#select-multiple-lines)
- [Select Range Of Lines](#select-range-of-lines)
- [Select Filters](#select-filters)

View file

@ -2,6 +2,7 @@ package peco
import (
"runtime"
"sort"
"strings"
"sync"
"time"
@ -356,13 +357,48 @@ func NewFilter(state *Peco) *Filter {
// 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, new string) bool {
prev = strings.TrimSpace(prev)
new = strings.TrimSpace(new)
if prev == "" || new == "" {
return false
}
return strings.HasPrefix(new, prev)
prevPos, prevNeg := filter.SplitQueryTerms(prev)
newPos, newNeg := filter.SplitQueryTerms(new)
// 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 {
sort.Strings(prevNeg)
sort.Strings(newNeg)
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

View file

@ -9,6 +9,7 @@ import (
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type indexer interface {
@ -223,6 +224,250 @@ func testFuzzyLongest(octx context.Context, t *testing.T, filter Filter) {
}
}
// testFuzzyMatch tests if non-sorted & sorted Fuzzy filter returns the expected result
func TestSplitQueryTerms(t *testing.T) {
tests := []struct {
name string
query string
wantPos []string
wantNeg []string
}{
{
name: "simple positive",
query: "foo bar",
wantPos: []string{"foo", "bar"},
},
{
name: "single negative",
query: "-foo",
wantNeg: []string{"foo"},
},
{
name: "mixed positive and negative",
query: "foo -bar baz",
wantPos: []string{"foo", "baz"},
wantNeg: []string{"bar"},
},
{
name: "all negative",
query: "-foo -bar",
wantNeg: []string{"foo", "bar"},
},
{
name: "escaped negative becomes positive",
query: `\-foo`,
wantPos: []string{"-foo"},
},
{
name: "bare hyphen is positive literal",
query: "-",
wantPos: []string{"-"},
},
{
name: "double hyphen is positive literal",
query: "--",
wantPos: []string{"--"},
},
{
name: "mixed with escaping",
query: `foo -bar \-baz`,
wantPos: []string{"foo", "-baz"},
wantNeg: []string{"bar"},
},
{
name: "extra spaces are skipped",
query: " foo -bar ",
wantPos: []string{"foo"},
wantNeg: []string{"bar"},
},
{
name: "empty query",
query: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPos, gotNeg := SplitQueryTerms(tt.query)
require.Equal(t, tt.wantPos, gotPos, "positive terms")
require.Equal(t, tt.wantNeg, gotNeg, "negative terms")
})
}
}
// collectFilterResults runs the filter and collects all emitted lines.
func collectFilterResults(t *testing.T, f Filter, query string, inputLines []line.Line) []line.Line {
t.Helper()
ctx, cancel := context.WithTimeout(f.NewContext(context.Background(), query), 10*time.Second)
defer cancel()
ch := make(chan interface{}, len(inputLines)+1)
err := f.Apply(ctx, inputLines, pipeline.ChanOutput(ch))
require.NoError(t, err, "filter.Apply should succeed")
close(ch)
var results []line.Line
for v := range ch {
l, ok := v.(line.Line)
require.True(t, ok, "result should be a line.Line")
results = append(results, l)
}
return results
}
func makeLines(inputs ...string) []line.Line {
lines := make([]line.Line, len(inputs))
for i, s := range inputs {
lines[i] = line.NewRaw(uint64(i), s, false)
}
return lines
}
func TestNegativeMatchingRegexp(t *testing.T) {
filters := map[string]Filter{
"IgnoreCase": NewIgnoreCase(),
"CaseSensitive": NewCaseSensitive(),
"SmartCase": NewSmartCase(),
"Regexp": NewRegexp(),
}
for name, f := range filters {
t.Run(name, func(t *testing.T) {
lines := makeLines(
"hello world",
"hello tests",
"goodbye world",
"goodbye tests",
)
t.Run("positive with negative exclusion", func(t *testing.T) {
results := collectFilterResults(t, f, "hello -tests", lines)
require.Len(t, results, 1)
require.Equal(t, "hello world", results[0].DisplayString())
})
t.Run("multiple negative terms", func(t *testing.T) {
results := collectFilterResults(t, f, "hello -world -tests", lines)
require.Len(t, results, 0)
})
})
}
}
func TestAllNegativeQuery(t *testing.T) {
filters := map[string]Filter{
"IgnoreCase": NewIgnoreCase(),
"Regexp": NewRegexp(),
"Fuzzy": NewFuzzy(false),
}
lines := makeLines(
"alpha",
"beta",
"gamma",
)
for name, f := range filters {
t.Run(name, func(t *testing.T) {
results := collectFilterResults(t, f, "-beta", lines)
require.Len(t, results, 2)
var names []string
for _, r := range results {
names = append(names, r.DisplayString())
}
require.Contains(t, names, "alpha")
require.Contains(t, names, "gamma")
})
}
}
func TestNegativeNoHighlight(t *testing.T) {
f := NewIgnoreCase()
lines := makeLines("alpha", "beta", "gamma")
results := collectFilterResults(t, f, "-beta", lines)
require.Len(t, results, 2)
for _, r := range results {
idx, ok := r.(indexer)
require.True(t, ok, "result should implement indexer")
require.Nil(t, idx.Indices(), "all-negative query should produce nil indices")
}
}
func TestNegativeMatchingFuzzy(t *testing.T) {
f := NewFuzzy(false)
lines := makeLines(
"hello world",
"hello tests",
"goodbye world",
"goodbye tests",
)
t.Run("fuzzy positive with negative exclusion", func(t *testing.T) {
// Fuzzy query "hlo" should match "hello" lines; -tests excludes one
results := collectFilterResults(t, f, "hlo -tests", lines)
require.Len(t, results, 1)
require.Equal(t, "hello world", results[0].DisplayString())
})
t.Run("fuzzy all-negative", func(t *testing.T) {
results := collectFilterResults(t, f, "-world", lines)
require.Len(t, results, 2)
var names []string
for _, r := range results {
names = append(names, r.DisplayString())
}
require.Contains(t, names, "hello tests")
require.Contains(t, names, "goodbye tests")
})
}
func TestLiteralHyphenMatching(t *testing.T) {
lines := makeLines(
"hello-world",
"hello world",
"-foo bar",
"foo bar",
"--verbose flag",
"verbose flag",
)
f := NewIgnoreCase()
t.Run("escaped negative matches literal hyphen-prefixed term", func(t *testing.T) {
// \-foo should match lines containing literal "-foo"
results := collectFilterResults(t, f, `\-foo`, lines)
require.Len(t, results, 1)
require.Equal(t, "-foo bar", results[0].DisplayString())
})
t.Run("bare hyphen matches lines containing hyphen", func(t *testing.T) {
// bare "-" should be a positive literal matching any line with a hyphen
results := collectFilterResults(t, f, "-", lines)
require.Len(t, results, 3)
var names []string
for _, r := range results {
names = append(names, r.DisplayString())
}
require.Contains(t, names, "hello-world")
require.Contains(t, names, "-foo bar")
require.Contains(t, names, "--verbose flag")
})
t.Run("double hyphen matches lines containing double hyphen", func(t *testing.T) {
results := collectFilterResults(t, f, "--", lines)
require.Len(t, results, 1)
require.Equal(t, "--verbose flag", results[0].DisplayString())
})
t.Run("escaped negative with positive term", func(t *testing.T) {
// Search for lines containing both "bar" and literal "-foo"
results := collectFilterResults(t, f, `bar \-foo`, lines)
require.Len(t, results, 1)
require.Equal(t, "-foo bar", results[0].DisplayString())
})
}
// testFuzzyMatch tests if non-sorted & sorted Fuzzy filter returns the expected result
func testFuzzyMatch(octx context.Context, t *testing.T, filter Filter) {
testValues := []struct {

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math"
"regexp"
"sort"
"strings"
"unicode"
@ -47,7 +48,22 @@ func (ff Fuzzy) String() string {
func (ff *Fuzzy) applyInternal(ctx context.Context, lines []line.Line, emit func(line.Line)) error {
originalQuery := ctx.Value(queryKey).(string)
hasUpper := util.ContainsUpper(originalQuery)
// Parse negative terms and compile them as case-insensitive regexps
posTerms, negTerms := SplitQueryTerms(originalQuery)
var negRegexps []*regexp.Regexp
for _, t := range negTerms {
re, err := regexpFor(t, []string{"i"}, true)
if err != nil {
return fmt.Errorf("failed to compile negative term regexp '%s': %w", t, err)
}
negRegexps = append(negRegexps, re)
}
// Reconstruct the fuzzy query from positive terms joined together
fuzzyQuery := strings.Join(posTerms, "")
hasUpper := util.ContainsUpper(fuzzyQuery)
matched := []fuzzyMatchedItem{}
LINE:
@ -59,9 +75,30 @@ LINE:
default:
}
}
txt := l.DisplayString()
// Check negative terms first — skip if any match
excluded := false
for _, rx := range negRegexps {
if rx.MatchString(txt) {
excluded = true
break
}
}
if excluded {
continue LINE
}
// All-negative query: emit all non-excluded lines with nil indices
if len(fuzzyQuery) == 0 {
emit(line.NewMatched(l, nil))
continue LINE
}
// Find the first valid rune of the query
firstRune := utf8.RuneError
for _, r := range originalQuery {
for _, r := range fuzzyQuery {
if r != utf8.RuneError {
firstRune = r
break
@ -72,7 +109,7 @@ LINE:
}
// Find the index of the first valid rune in the input line
txt := l.DisplayString()
txt = l.DisplayString()
firstRuneOffsets := []int{}
accum := 0
r := rune(0)
@ -104,7 +141,7 @@ LINE:
OUTER:
for _, offset := range firstRuneOffsets {
query := originalQuery
query := fuzzyQuery
txt = l.DisplayString()[offset:]
base := offset
matches := [][]int{}

View file

@ -42,7 +42,8 @@ type regexpQueryFactory struct {
}
type regexpQuery struct {
rx []*regexp.Regexp
positive []*regexp.Regexp
negative []*regexp.Regexp
lastUsed time.Time
}

View file

@ -54,6 +54,47 @@ func queryToRegexps(query string, flags regexpFlags, quotemeta bool) ([]*regexp.
return regexps, nil
}
// SplitQueryTerms splits a query string into positive and negative term slices.
// Terms starting with `-` (followed by at least one non-hyphen char) are negative (the `-` is stripped).
// Terms starting with `\-` are positive literals (the `\` is stripped).
// Bare `-` or `--` are positive literals.
// Empty tokens are skipped.
func SplitQueryTerms(query string) (positive, negative []string) {
tokens := strings.Split(strings.TrimSpace(query), " ")
for _, tok := range tokens {
if tok == "" {
continue
}
if strings.HasPrefix(tok, `\-`) {
// Escaped negative: treat as literal positive term (strip the backslash)
positive = append(positive, tok[1:])
} else if tok == "-" || tok == "--" {
// Bare hyphen(s): literal positive
positive = append(positive, tok)
} else if strings.HasPrefix(tok, "-") {
// Negative term: strip the leading hyphen
negative = append(negative, tok[1:])
} else {
positive = append(positive, tok)
}
}
return
}
// termsToRegexps compiles a slice of terms into regexps, using the full
// original query for flag computation (needed for SmartCase).
func termsToRegexps(terms []string, fullQuery string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, error) {
regexps := make([]*regexp.Regexp, 0, len(terms))
for _, t := range terms {
re, err := regexpFor(t, flags.flags(fullQuery), quotemeta)
if err != nil {
return nil, errors.Wrapf(err, "failed to compile regular expression '%s'", t)
}
regexps = append(regexps, re)
}
return regexps, nil
}
func (rf *Regexp) NewContext(ctx context.Context, query string) context.Context {
return newContext(ctx, query)
}
@ -98,21 +139,32 @@ func (rf *Regexp) OutCh() <-chan interface{} {
const maxRegexpCacheSize = 100
func (f *regexpQueryFactory) Compile(s string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, error) {
func (f *regexpQueryFactory) Compile(s string, flags regexpFlags, quotemeta bool) (positive, negative []*regexp.Regexp, err error) {
f.mutex.Lock()
defer f.mutex.Unlock()
rq, ok := f.compiled[s]
if ok {
if time.Since(rq.lastUsed) < f.threshold {
return rq.rx, nil
return rq.positive, rq.negative, nil
}
delete(f.compiled, s)
}
rxs, err := queryToRegexps(s, flags, quotemeta)
if err != nil {
return nil, errors.Wrap(err, `failed to compile regular expression`)
posTerms, negTerms := SplitQueryTerms(s)
var posRxs, negRxs []*regexp.Regexp
if len(posTerms) > 0 {
posRxs, err = termsToRegexps(posTerms, s, flags, quotemeta)
if err != nil {
return nil, nil, errors.Wrap(err, `failed to compile positive regular expressions`)
}
}
if len(negTerms) > 0 {
negRxs, err = termsToRegexps(negTerms, s, flags, quotemeta)
if err != nil {
return nil, nil, errors.Wrap(err, `failed to compile negative regular expressions`)
}
}
// Evict stale entries if cache is over the size limit
@ -130,14 +182,15 @@ func (f *regexpQueryFactory) Compile(s string, flags regexpFlags, quotemeta bool
}
rq.lastUsed = time.Now()
rq.rx = rxs
rq.positive = posRxs
rq.negative = negRxs
f.compiled[s] = rq
return rxs, nil
return posRxs, negRxs, nil
}
func (rf *Regexp) applyInternal(ctx context.Context, lines []line.Line, emit func(line.Line)) error {
query := ctx.Value(queryKey).(string)
regexps, err := rf.factory.Compile(query, rf.flags, rf.quotemeta)
posRegexps, negRegexps, err := rf.factory.Compile(query, rf.flags, rf.quotemeta)
if err != nil {
return errors.Wrap(err, "failed to compile queries as regular expression")
}
@ -151,10 +204,30 @@ func (rf *Regexp) applyInternal(ctx context.Context, lines []line.Line, emit fun
}
}
v := l.DisplayString()
// Check negative terms first (fail-fast, no index collection)
excluded := false
for _, rx := range negRegexps {
if rx.MatchString(v) {
excluded = true
break
}
}
if excluded {
continue
}
// All-negative query: emit line with nil indices (no highlighting)
if len(posRegexps) == 0 {
emit(line.NewMatched(l, nil))
continue
}
// Positive matching (existing AND logic)
allMatched := true
matches := [][]int{}
TryRegexps:
for _, rx := range regexps {
for _, rx := range posRegexps {
match := rx.FindAllStringSubmatchIndex(v, -1)
if match == nil {
allMatched = false

View file

@ -46,6 +46,37 @@ func TestIsQueryRefinement(t *testing.T) {
}
}
func TestIsQueryRefinementWithNegation(t *testing.T) {
tests := []struct {
prev string
new string
expected bool
}{
// Adding a negative term is a refinement (narrows results)
{"foo", "foo -bar", true},
// Adding more negative terms is still a refinement
{"foo -bar", "foo -bar -baz", true},
// Removing a negative term is NOT a refinement (widens results)
{"foo -bar -baz", "foo -bar", false},
// Extending positive while keeping negatives
{"foo -bar", "fooX -bar", true},
// All-negative refinement
{"-foo", "-foo -bar", true},
// Changing a negative term is not a refinement
{"-foo", "-bar", false},
// Adding positive to all-negative is a refinement
{"-foo", "hello -foo", true},
}
for _, tt := range tests {
name := fmt.Sprintf("%q->%q", tt.prev, tt.new)
t.Run(name, func(t *testing.T) {
result := isQueryRefinement(tt.prev, tt.new)
require.Equal(t, tt.expected, result)
})
}
}
func TestMemoryBufferSource(t *testing.T) {
// Create and populate a MemoryBuffer
mb := NewMemoryBuffer(0)