mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
Negative filters
This commit is contained in:
parent
4b209aaf84
commit
87ce6ab7cb
16
README.md
16
README.md
|
|
@ -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)
|
||||
|
|
|
|||
38
filter.go
38
filter.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ type regexpQueryFactory struct {
|
|||
}
|
||||
|
||||
type regexpQuery struct {
|
||||
rx []*regexp.Regexp
|
||||
positive []*regexp.Regexp
|
||||
negative []*regexp.Regexp
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue