diff --git a/Makefile b/Makefile index 75ea8ed..3077c9b 100644 --- a/Makefile +++ b/Makefile @@ -22,4 +22,4 @@ clean: test: go test -v ./... - \ No newline at end of file + diff --git a/README.md b/README.md index 383ec37..4698e85 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,7 @@ __If any values from this file are removed, they will not be readded when the pr | `tempDir` | string | Directory used for image files. | | `enableMouse` | bool | Enables mouse interaction in the UI. | | `enableDescription` | bool | Shows additional descriptive text for clipboard entries. | +| `search` | map | Fuzzy search engine and ranking options. See [Search](#search). | | `keyBindings` | map | Custom keybind definitions. | | `autoPaste` | map | Auto-paste options. | | `imageDisplay` | map | Image display options (basic/kitty/sixel). | @@ -380,6 +381,40 @@ Absolute paths starting with `/`, paths relative to the user home dir using `~`, | `keyBindings.up` | string | Moves selection up by one entry. | | `keyBindings.yankFilter` | string | Copies the current filter text. | +## Search + +The `search` object configures how filter matches are scored and ordered. + +| Option | Type | Description | +| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `search.engine` | string | `"default"` (existing behavior, backed by `sahilm/fuzzy`) or `"fzf"` (fzf v2 scoring from `github.com/junegunn/fzf`). Default `"default"`. | +| `search.algo` | string | `"v1"` or `"v2"` — fzf scoring algorithm. Default `"v2"`. Ignored when `engine` is `"default"`. | +| `search.caseSensitivity` | string | `"smart"`, `"respect"`, or `"ignore"`. `smart` is case-insensitive unless the query contains uppercase. Default `"smart"`. Fzf engine only. | +| `search.normalize` | bool | Strip diacritics so `cafe` matches `café`. Default `true`. Fzf engine only. | +| `search.matchMode` | string | `"fuzzy"` (characters can be non-contiguous) or `"exact"` (contiguous substring, still scored). Default `"fuzzy"`. Fzf engine only. | +| `search.tiebreak` | array | Ordering applied when fzf scores tie. Any of `"score"`, `"length"`, `"index"`, `"frecency"`, `"begin"` (prefer matches closer to the start of the entry), `"end"` (prefer matches closer to the end). Entries may be plain strings or `{"key": "...", "bucket": "log2"}` to quantize numeric values so later tiebreaks still decide close calls. Default `["score","length",{"key":"frecency","bucket":"log2"},"index"]`. Fzf engine only. | + +Opting into fzf scoring with frecency: + +```json +{ + "search": { + "engine": "fzf", + "algo": "v2", + "matchMode": "fuzzy", + "caseSensitivity": "smart", + "normalize": true, + "tiebreak": [ + { "key": "score", "bucket": "log2" }, + { "key": "frecency", "bucket": "log2" }, + "index" + ] + } +} +``` + +Adding `"frecency"` to `tiebreak` makes clipse track how often and how recently each entry was selected (written as `useCount` and `lastUsed` into `clipboard_history.json`) and surface frequently used entries first when fzf scores tie. + Key bindings can take multiple keys delimited by `,`. For example: diff --git a/app/delegate.go b/app/delegate.go index 68cf461..df8cb6e 100644 --- a/app/delegate.go +++ b/app/delegate.go @@ -3,7 +3,6 @@ package app import ( "fmt" "io" - "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -53,11 +52,8 @@ func (d itemDelegate) Render( switch { case m.SettingFilter(): - if strings.Contains( - strings.ToLower(i.titleFull), - strings.ToLower(m.FilterValue()), - ) && m.FilterValue() != "" { - renderStr = d.itemSelectedStyle(i) + if m.FilterValue() != "" && len(m.MatchesForItem(index)) > 0 { + renderStr = d.itemFilterMatchStyle(i) } else { renderStr = d.itemFilterStyle(i) } diff --git a/app/model.go b/app/model.go index facc899..a59b5f8 100644 --- a/app/model.go +++ b/app/model.go @@ -3,6 +3,7 @@ package app import ( "fmt" "strings" + "time" "unicode" "github.com/charmbracelet/bubbles/help" @@ -12,9 +13,12 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/savedra1/clipse/config" + "github.com/savedra1/clipse/search" "github.com/savedra1/clipse/utils" ) +const searchTargetCap = 4096 + type Model struct { list list.Model // list items keys *keyMap // keybindings @@ -59,7 +63,7 @@ func (i item) TitleFull() string { return i.titleFull } func (i item) TimeStamp() string { return i.timeStamp } func (i item) Description() string { return i.description } func (i item) FilePath() string { return i.filePath } -func (i item) FilterValue() string { return i.title } +func (i item) FilterValue() string { return i.titleFull } func (m Model) Init() tea.Cmd { return tea.EnterAltScreen @@ -95,7 +99,7 @@ func NewModel() Model { del := m.newItemDelegate() clipboardList := list.New(entryItems, del, 0, 0) - clipboardList.Filter = sanitizedFilter + clipboardList.Filter = buildFilter(clipboardItems) clipboardList.KeyMap = defaultOverrides(config.ClipseConfig.KeyBindings) // override default list keys with custom values clipboardList.Title = clipboardTitle // set hardcoded title clipboardList.SetShowHelp(false) // override with custom @@ -128,12 +132,46 @@ func NewModel() Model { return m } -func sanitizedFilter(term string, targets []string) []list.Rank { - sanitized := make([]string, len(targets)) - for i, t := range targets { - sanitized[i] = stripNonPrintable(t) +func buildFilter(items []config.ClipboardItem) func(string, []string) []list.Rank { + meta := make(map[string]search.ItemMeta, len(items)) + for _, it := range items { + lastUsed, _ := time.Parse(utils.DateLayout, it.LastUsed) + recorded, _ := time.Parse(utils.DateLayout, it.Recorded) + k := stripNonPrintable(it.Value) + if len(k) > searchTargetCap { + k = k[:searchTargetCap] + } + meta[k] = search.ItemMeta{UseCount: it.UseCount, LastUsed: lastUsed, Recorded: recorded} + } + lookup := func(target string) search.ItemMeta { + return meta[target] + } + sc := config.ClipseConfig.Search + tb := make([]search.TiebreakEntry, len(sc.Tiebreak)) + for i, e := range sc.Tiebreak { + tb[i] = search.TiebreakEntry{Key: e.Key, Bucket: e.Bucket} + } + inner := search.Filter(search.Config{ + Engine: sc.Engine, + Algo: sc.Algo, + MatchMode: sc.MatchMode, + CaseSensitivity: sc.CaseSensitivity, + Normalize: sc.Normalize, + TypoTolerance: sc.TypoTolerance, + MaxScatter: sc.MaxScatter, + Tiebreak: tb, + }, lookup) + return func(term string, targets []string) []list.Rank { + sanitized := make([]string, len(targets)) + for i, t := range targets { + s := stripNonPrintable(t) + if len(s) > searchTargetCap { + s = s[:searchTargetCap] + } + sanitized[i] = s + } + return inner(term, sanitized) } - return list.DefaultFilter(term, sanitized) } func stripNonPrintable(s string) string { diff --git a/app/styles.go b/app/styles.go index 80bceb0..22f087c 100644 --- a/app/styles.go +++ b/app/styles.go @@ -54,6 +54,31 @@ func (d itemDelegate) itemFilterStyle(i item) string { return fmt.Sprintf("%s\n%s", titleStyle, descStyle) } +func (d itemDelegate) itemFilterMatchStyle(i item) string { + titleStyle := style. + Foreground(lipgloss.Color(d.theme.SelectedTitle)). + PaddingLeft(2). + Render(i.titleBase) + + if !config.ClipseConfig.EnableDescription { + if i.pinned { + titleStyle += styledPin(d.theme) + } + return titleStyle + } + + descStyle := style. + Foreground(lipgloss.Color(d.theme.DimmedDesc)). + PaddingLeft(2). + Render(i.descriptionBase) + + if i.pinned { + descStyle += styledPin(d.theme) + } + + return fmt.Sprintf("%s\n%s", titleStyle, descStyle) +} + func (d itemDelegate) itemChosenStyle(i item) string { titleStyle = style. Foreground(lipgloss.Color(d.theme.SelectedTitle)). diff --git a/app/update.go b/app/update.go index d9ce964..a344103 100644 --- a/app/update.go +++ b/app/update.go @@ -552,6 +552,15 @@ func (m *Model) filterMatches() []string { return filteredItems } +func recordUse(timeStamp string) { + if timeStamp == "" { + return + } + if err := config.RecordUse(timeStamp); err != nil { + utils.LogERROR(fmt.Sprintf("failed to record frecency use: %s", err)) + } +} + func (m Model) handleChooseOperation(i item, cmds []tea.Cmd) (Model, []tea.Cmd, bool) { selectedItems := m.selectedItems() if len(selectedItems) < 1 { @@ -563,6 +572,8 @@ func (m Model) handleChooseOperation(i item, cmds []tea.Cmd) (Model, []tea.Cmd, display.DisplayServer.CopyText(i.titleFull) } + recordUse(i.timeStamp) + if KeepEnabled { cmds = append( cmds, @@ -583,6 +594,11 @@ func (m Model) handleChooseOperation(i item, cmds []tea.Cmd) (Model, []tea.Cmd, display.DisplayServer.CopyText(yank) + recordUse(i.timeStamp) + for _, item := range selectedItems { + recordUse(item.TimeStamp) + } + if KeepEnabled { statusMsg := "Copied to clipboard: *selected items*" display.DisplayServer.CopyText(yank) diff --git a/config/config.go b/config/config.go index 6105cd4..d5b510b 100644 --- a/config/config.go +++ b/config/config.go @@ -25,6 +25,63 @@ type Config struct { AutoPaste AutoPaste `json:"autoPaste"` EnableMouse bool `json:"enableMouse"` EnableDescription bool `json:"enableDescription"` + Search SearchConfig `json:"search"` +} + +type SearchConfig struct { + Engine string `json:"engine"` + Algo string `json:"algo"` + MatchMode string `json:"matchMode"` + CaseSensitivity string `json:"caseSensitivity"` + Normalize bool `json:"normalize"` + TypoTolerance bool `json:"typoTolerance"` + MaxScatter int `json:"maxScatter"` + Tiebreak TiebreakList `json:"tiebreak"` +} + +type TiebreakEntry struct { + Key string `json:"key"` + Bucket string `json:"bucket,omitempty"` +} + +type TiebreakList []TiebreakEntry + +func (t *TiebreakList) UnmarshalJSON(data []byte) error { + var raw []json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + out := make(TiebreakList, 0, len(raw)) + for _, r := range raw { + trimmed := len(r) > 0 && r[0] == '"' + if trimmed { + var s string + if err := json.Unmarshal(r, &s); err != nil { + return err + } + out = append(out, TiebreakEntry{Key: s}) + continue + } + var e TiebreakEntry + if err := json.Unmarshal(r, &e); err != nil { + return err + } + out = append(out, e) + } + *t = out + return nil +} + +func (t TiebreakList) MarshalJSON() ([]byte, error) { + raw := make([]interface{}, len(t)) + for i, e := range t { + if e.Bucket == "" { + raw[i] = e.Key + } else { + raw[i] = e + } + } + return json.Marshal(raw) } type AutoPaste struct { diff --git a/config/constants.go b/config/constants.go index a9a06c2..d51905f 100644 --- a/config/constants.go +++ b/config/constants.go @@ -85,5 +85,24 @@ func defaultConfig() Config { Keybind: defaultAutoPasteKeyBind, Buffer: defaultAutoPasteBuffer, }, + Search: defaultSearchConfig(), + } +} + +func defaultSearchConfig() SearchConfig { + return SearchConfig{ + Engine: "default", + Algo: "v2", + MatchMode: "fuzzy", + CaseSensitivity: "smart", + Normalize: true, + TypoTolerance: true, + MaxScatter: 24, + Tiebreak: TiebreakList{ + {Key: "score", Bucket: "32"}, + {Key: "frecency"}, + {Key: "length"}, + {Key: "index"}, + }, } } diff --git a/config/history.go b/config/history.go index 687127b..820d6d1 100644 --- a/config/history.go +++ b/config/history.go @@ -19,6 +19,8 @@ type ClipboardItem struct { Recorded string `json:"recorded"` FilePath string `json:"filePath"` Pinned bool `json:"pinned"` + UseCount int `json:"useCount,omitempty"` + LastUsed string `json:"lastUsed,omitempty"` } type ClipboardHistory struct { @@ -335,6 +337,18 @@ func TogglePinClipboardItem(timeStamp string) (bool, error) { return pinned, nil } +func RecordUse(timeStamp string) error { + data := fileContents() + for i, item := range data.ClipboardHistory { + if item.Recorded == timeStamp { + data.ClipboardHistory[i].UseCount = item.UseCount + 1 + data.ClipboardHistory[i].LastUsed = utils.GetTime() + return WriteUpdate(data) + } + } + return nil +} + func SanitizeHistory() error { data := fileContents() newData := ClipboardHistory{} diff --git a/go.mod b/go.mod index 63b7fac..8c5ad1c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/go-vgo/robotgo v1.0.0 + github.com/junegunn/fzf v0.71.0 github.com/mitchellh/go-ps v1.0.0 gopkg.in/bendahl/uinput.v1 v1.2.0 ) @@ -20,11 +22,11 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect github.com/ebitengine/purego v0.9.1 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gen2brain/shm v0.1.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/jezek/xgb v1.2.0 // indirect + github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/otiai10/gosseract/v2 v2.4.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect diff --git a/go.sum b/go.sum index ed8f307..831114e 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/jezek/xgb v1.2.0 h1:LzgkD11wOrPnxXEqo588cnjUt4NwMHrFh/tgajo50Q0= github.com/jezek/xgb v1.2.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/junegunn/fzf v0.71.0 h1:vPmJH1MUlysSczjn2HZ6+6KSMe8HxXUy323g9x9RmUQ= +github.com/junegunn/fzf v0.71.0/go.mod h1:xlXX2/rmsccKQUnr9QOXPDi5DyV9cM0UjKy/huScBeE= +github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs= +github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/search/search.go b/search/search.go new file mode 100644 index 0000000..f86e1f3 --- /dev/null +++ b/search/search.go @@ -0,0 +1,458 @@ +package search + +import ( + "math" + "sort" + "strconv" + "strings" + "time" + "unicode" + + "github.com/charmbracelet/bubbles/list" + "github.com/junegunn/fzf/src/algo" + "github.com/junegunn/fzf/src/util" +) + +func init() { + algo.Init("default") +} + +const ( + EngineDefault = "default" + EngineFzf = "fzf" + + AlgoV1 = "v1" + AlgoV2 = "v2" + + MatchModeFuzzy = "fuzzy" + MatchModeExact = "exact" + + CaseSmart = "smart" + CaseRespect = "respect" + CaseIgnore = "ignore" + + TiebreakScore = "score" + TiebreakLength = "length" + TiebreakIndex = "index" + TiebreakFrecency = "frecency" + TiebreakBegin = "begin" + TiebreakEnd = "end" + + frecencyHalflife = 24 * time.Hour + slab16Size = 100 * 1024 + slab32Size = 2048 + + typoEditPenalty = 4 + typoMaxTargetLen = 256 + + scoreContiguousBonus = 1 << 13 +) + +type Config struct { + Engine string `json:"engine"` + Algo string `json:"algo"` + MatchMode string `json:"matchMode"` + CaseSensitivity string `json:"caseSensitivity"` + Normalize bool `json:"normalize"` + TypoTolerance bool `json:"typoTolerance"` + MaxScatter int `json:"maxScatter"` + Tiebreak []TiebreakEntry `json:"tiebreak"` +} + +type TiebreakEntry struct { + Key string + Bucket string +} + +type ItemMeta struct { + UseCount int + LastUsed time.Time + Recorded time.Time +} + +type MetaLookup func(target string) ItemMeta + +func Filter(cfg Config, metaLookup MetaLookup) func(term string, targets []string) []list.Rank { + if cfg.Engine != EngineFzf { + return list.DefaultFilter + } + return fzfFilter(cfg, metaLookup) +} + +type rankWithScore struct { + rank list.Rank + score int + length int + frecency float64 + begin int + end int +} + +func fzfFilter(cfg Config, metaLookup MetaLookup) func(string, []string) []list.Rank { + tiebreak := cfg.Tiebreak + if len(tiebreak) == 0 { + tiebreak = []TiebreakEntry{{Key: TiebreakScore, Bucket: "32"}, {Key: TiebreakFrecency}, {Key: TiebreakLength}, {Key: TiebreakIndex}} + } + useFrecency := metaLookup != nil && hasKey(tiebreak, TiebreakFrecency) + matchFn := algo.FuzzyMatchV2 + if cfg.Algo == AlgoV1 { + matchFn = algo.FuzzyMatchV1 + } + if cfg.MatchMode == MatchModeExact { + matchFn = algo.ExactMatchNaive + } + typo := cfg.TypoTolerance && cfg.MatchMode != MatchModeExact + + return func(term string, targets []string) []list.Rank { + slab := util.MakeSlab(slab16Size, slab32Size) + term = strings.TrimSpace(term) + if term == "" { + out := make([]list.Rank, len(targets)) + for i := range targets { + out[i] = list.Rank{Index: i} + } + return out + } + + tokens := strings.Fields(term) + now := time.Now() + + results := make([]rankWithScore, 0, len(targets)) + for idx, target := range targets { + totalScore := 0 + var matchedPositions []int + begin, end := math.MaxInt, -1 + matched := true + for _, tok := range tokens { + caseSensitive := isCaseSensitive(cfg.CaseSensitivity, tok) + text := util.ToChars([]byte(target)) + patternStr := tok + if !caseSensitive { + patternStr = strings.ToLower(patternStr) + } + pattern := []rune(patternStr) + if cfg.Normalize { + pattern = algo.NormalizeRunes(pattern) + } + + tokScore := math.MinInt + var tokPositions []int + tokBegin, tokEnd := math.MaxInt, -1 + + ptext := util.ToChars([]byte(string(pattern))) + perfect, _ := matchFn(caseSensitive, cfg.Normalize, true, &ptext, pattern, true, slab) + + res, pos := matchFn(caseSensitive, cfg.Normalize, true, &text, pattern, true, slab) + if res.Start >= 0 { + tokScore = res.Score + if pos != nil { + tokPositions = append(tokPositions, *pos...) + for _, p := range *pos { + if p < tokBegin { + tokBegin = p + } + if p > tokEnd { + tokEnd = p + } + } + } else { + for p := res.Start; p < res.End; p++ { + tokPositions = append(tokPositions, p) + } + tokBegin, tokEnd = res.Start, res.End-1 + } + if cfg.MaxScatter > 0 && tokEnd-tokBegin+1-len(pattern) > cfg.MaxScatter { + tokScore, tokBegin, tokEnd, tokPositions = math.MinInt, math.MaxInt, -1, nil + } + } + + if typo { + if ts, tb, te, tp, ok := typoTokenMatch(pattern, target, cfg.Normalize, perfect.Score); ok && ts > tokScore { + tokScore, tokBegin, tokEnd, tokPositions = ts, tb, te, tp + } + } + + if cb, ce, ok := contiguousMatch(pattern, target, cfg.Normalize, caseSensitive); ok { + tokScore = scoreContiguousBonus + tokBegin, tokEnd = cb, ce + tokPositions = tokPositions[:0] + for p := cb; p <= ce; p++ { + tokPositions = append(tokPositions, p) + } + } + + if tokScore == math.MinInt { + matched = false + break + } + totalScore += tokScore + matchedPositions = append(matchedPositions, tokPositions...) + if tokBegin < begin { + begin = tokBegin + } + if tokEnd > end { + end = tokEnd + } + } + if !matched { + continue + } + fr := 0.0 + if useFrecency { + m := metaLookup(target) + fr = frecencyScore(m, now) + } + results = append(results, rankWithScore{ + rank: list.Rank{Index: idx, MatchedIndexes: matchedPositions}, + score: totalScore, + length: len(target), + frecency: fr, + begin: begin, + end: end, + }) + } + + sort.SliceStable(results, func(i, j int) bool { + return lessByTiebreak(results[i], results[j], tiebreak) + }) + + out := make([]list.Rank, len(results)) + for i, r := range results { + out[i] = r.rank + } + return out + } +} + +func lessByTiebreak(a, b rankWithScore, tiebreak []TiebreakEntry) bool { + for _, tb := range tiebreak { + switch tb.Key { + case TiebreakScore: + av, bv := bucketize(float64(a.score), tb.Bucket), bucketize(float64(b.score), tb.Bucket) + if av != bv { + return av > bv + } + case TiebreakLength: + av, bv := bucketize(float64(a.length), tb.Bucket), bucketize(float64(b.length), tb.Bucket) + if av != bv { + return av < bv + } + case TiebreakIndex: + if a.rank.Index != b.rank.Index { + return a.rank.Index < b.rank.Index + } + case TiebreakFrecency: + av, bv := bucketize(a.frecency, tb.Bucket), bucketize(b.frecency, tb.Bucket) + if av != bv { + return av > bv + } + case TiebreakBegin: + av, bv := bucketize(float64(a.begin), tb.Bucket), bucketize(float64(b.begin), tb.Bucket) + if av != bv { + return av < bv + } + case TiebreakEnd: + av, bv := bucketize(float64(a.end), tb.Bucket), bucketize(float64(b.end), tb.Bucket) + if av != bv { + return av > bv + } + } + } + return false +} + +func bucketize(val float64, strategy string) float64 { + switch strategy { + case "", "raw": + return val + case "log2": + if val <= 1 { + return 0 + } + return math.Floor(math.Log2(val)) + default: + // A numeric strategy is a linear quantization width: values are + // collapsed into floor(val/width) buckets, so anything within one + // width ties and a later tiebreak (e.g. frecency) decides. fzf's + // per-character score unit is 16, so a width of ~16 gives "gentle" + // near-tie breaking. Unparsable / non-positive widths fall back to raw. + if w, err := strconv.Atoi(strategy); err == nil && w > 0 { + return math.Floor(val / float64(w)) + } + return val + } +} + +func hasKey(entries []TiebreakEntry, key string) bool { + for _, e := range entries { + if e.Key == key { + return true + } + } + return false +} + +func frecencyScore(m ItemMeta, now time.Time) float64 { + last := m.Recorded + if m.LastUsed.After(last) { + last = m.LastUsed + } + if last.IsZero() { + return 0 + } + age := max(now.Sub(last), 0) + return float64(1+m.UseCount) * math.Exp(-float64(age)/float64(frecencyHalflife)) +} + +func typoMaxEdits(n int) int { + switch { + case n < 3: + return 0 + case n <= 4: + return 1 + default: + return 2 + } +} + +func typoTokenMatch(pattern []rune, target string, normalize bool, perfectScore int) (score, begin, end int, positions []int, ok bool) { + maxEd := typoMaxEdits(len(pattern)) + if maxEd == 0 || len(target) > typoMaxTargetLen { + return 0, 0, 0, nil, false + } + runes := []rune(target) + bestD := maxEd + 1 + bestStart, bestLen := -1, 0 + for i := 0; i < len(runes); { + if !isWordRune(runes[i]) { + i++ + continue + } + j := i + for j < len(runes) && isWordRune(runes[j]) { + j++ + } + word := make([]rune, 0, j-i) + for k := i; k < j; k++ { + word = append(word, unicode.ToLower(runes[k])) + } + if normalize { + word = algo.NormalizeRunes(word) + } + if absInt(len(word)-len(pattern)) <= maxEd { + if d := damerauLevenshtein(pattern, word, maxEd); d < bestD { + bestD, bestStart, bestLen = d, i, j-i + } + } + if len(word) > len(pattern) { + if d := damerauLevenshtein(pattern, word[:len(pattern)], maxEd); d < bestD { + bestD, bestStart, bestLen = d, i, len(pattern) + } + } + i = j + } + if bestStart < 0 || bestD > maxEd { + return 0, 0, 0, nil, false + } + positions = make([]int, bestLen) + for k := range positions { + positions[k] = bestStart + k + } + score = perfectScore - typoEditPenalty*bestD + return score, bestStart, bestStart + bestLen - 1, positions, true +} + +func damerauLevenshtein(a, b []rune, maxD int) int { + la, lb := len(a), len(b) + if absInt(la-lb) > maxD { + return maxD + 1 + } + prev2 := make([]int, lb+1) + prev := make([]int, lb+1) + curr := make([]int, lb+1) + for j := 0; j <= lb; j++ { + prev[j] = j + } + for i := 1; i <= la; i++ { + curr[0] = i + rowMin := curr[0] + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + m := min(prev[j]+1, curr[j-1]+1) + m = min(m, prev[j-1]+cost) + if i > 1 && j > 1 && a[i-1] == b[j-2] && a[i-2] == b[j-1] { + m = min(m, prev2[j-2]+1) + } + curr[j] = m + if m < rowMin { + rowMin = m + } + } + if rowMin > maxD { + return maxD + 1 + } + prev2, prev, curr = prev, curr, prev2 + } + return prev[lb] +} + +func contiguousMatch(pattern []rune, target string, normalize, caseSensitive bool) (begin, end int, ok bool) { + if len(pattern) == 0 { + return 0, 0, false + } + hay := []rune(target) + if len(hay) < len(pattern) { + return 0, 0, false + } + for i := range hay { + if !caseSensitive { + hay[i] = unicode.ToLower(hay[i]) + } + } + if normalize { + hay = algo.NormalizeRunes(hay) + } + for i := 0; i+len(pattern) <= len(hay); i++ { + match := true + for j := range pattern { + if hay[i+j] != pattern[j] { + match = false + break + } + } + if match { + return i, i + len(pattern) - 1, true + } + } + return 0, 0, false +} + +func isWordRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) +} + +func absInt(n int) int { + if n < 0 { + return -n + } + return n +} + +func isCaseSensitive(mode, pattern string) bool { + switch mode { + case CaseRespect: + return true + case CaseIgnore: + return false + default: // smart + for _, r := range pattern { + if unicode.IsUpper(r) { + return true + } + } + return false + } +} diff --git a/tests/config/config_test.go b/tests/config/config_test.go index 476d1a1..25a3234 100644 --- a/tests/config/config_test.go +++ b/tests/config/config_test.go @@ -1,7 +1,44 @@ package config import ( + "encoding/json" + "reflect" "testing" + + "github.com/savedra1/clipse/config" ) func Test(_ *testing.T) {} + +func TestTiebreakListUnmarshalMixed(t *testing.T) { + raw := []byte(`["score","length",{"key":"frecency","bucket":"log2"},"index"]`) + var got config.TiebreakList + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + want := config.TiebreakList{ + {Key: "score"}, + {Key: "length"}, + {Key: "frecency", Bucket: "log2"}, + {Key: "index"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestTiebreakListMarshalPreservesShape(t *testing.T) { + in := config.TiebreakList{ + {Key: "score"}, + {Key: "frecency", Bucket: "log2"}, + {Key: "index"}, + } + out, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + want := `["score",{"key":"frecency","bucket":"log2"},"index"]` + if string(out) != want { + t.Errorf("got %s, want %s", string(out), want) + } +} diff --git a/tests/search/search_test.go b/tests/search/search_test.go new file mode 100644 index 0000000..5353ae7 --- /dev/null +++ b/tests/search/search_test.go @@ -0,0 +1,418 @@ +package search_test + +import ( + "reflect" + "strings" + "testing" + "time" + + "github.com/charmbracelet/bubbles/list" + + "github.com/savedra1/clipse/search" +) + +func TestDefaultEngineMatchesListDefaultFilter(t *testing.T) { + targets := []string{ + "git commit -m fix", + "go test ./...", + "git checkout main", + } + terms := []string{"git", "go", "gx", "CHECKOUT"} + + cfg := search.Config{Engine: search.EngineDefault} + filter := search.Filter(cfg, nil) + + for _, term := range terms { + got := filter(term, targets) + want := list.DefaultFilter(term, targets) + if !reflect.DeepEqual(got, want) { + t.Errorf("term %q: default engine diverged from list.DefaultFilter\n got=%+v\nwant=%+v", term, got, want) + } + } +} + +func TestFzfRanksWordBoundaryAbove(t *testing.T) { + targets := []string{ + "git commit", + "go compile output", + "git checkout origin", + } + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{{Key: search.TiebreakScore}, {Key: search.TiebreakLength}, {Key: search.TiebreakIndex}}, + } + filter := search.Filter(cfg, nil) + + ranks := filter("gco", targets) + if len(ranks) == 0 { + t.Fatal("expected at least one match for 'gco'") + } + top := targets[ranks[0].Index] + if top != "git checkout origin" && top != "go compile output" { + t.Errorf("expected a word-boundary match at top, got %q", top) + } + for _, r := range ranks { + if targets[r.Index] == "git commit" && targets[ranks[0].Index] == "git commit" { + t.Errorf("unexpected: 'git commit' ranked top for pattern 'gco'") + } + } +} + +func TestFzfMultiTermAnd(t *testing.T) { + targets := []string{ + "git commit", + "git checkout main", + "go test", + } + cfg := search.Config{Engine: search.EngineFzf, Algo: search.AlgoV2, Normalize: true} + filter := search.Filter(cfg, nil) + + ranks := filter("git ch", targets) + if len(ranks) != 1 || targets[ranks[0].Index] != "git checkout main" { + t.Errorf("expected only 'git checkout main', got %v", ranks) + } +} + +func TestFzfSmartCase(t *testing.T) { + targets := []string{"Hello World", "hello there"} + cfg := search.Config{Engine: search.EngineFzf, Algo: search.AlgoV2, CaseSensitivity: search.CaseSmart, Normalize: true} + filter := search.Filter(cfg, nil) + + if ranks := filter("hello", targets); len(ranks) != 2 { + t.Errorf("smart case lowercase: expected 2 matches, got %d", len(ranks)) + } + ranks := filter("Hello", targets) + if len(ranks) != 1 || targets[ranks[0].Index] != "Hello World" { + t.Errorf("smart case mixed: expected only 'Hello World', got %+v", ranks) + } +} + +func TestFzfNormalize(t *testing.T) { + targets := []string{"café au lait", "tea"} + cfg := search.Config{Engine: search.EngineFzf, Algo: search.AlgoV2, Normalize: true} + filter := search.Filter(cfg, nil) + + ranks := filter("cafe", targets) + if len(ranks) == 0 { + t.Errorf("normalize=true: expected 'cafe' to match 'café au lait'") + } +} + +func TestFrecencyTiebreak(t *testing.T) { + targets := []string{"foo bar", "foo baz"} + now := time.Now() + meta := map[string]search.ItemMeta{ + "foo bar": {UseCount: 1, LastUsed: now.Add(-48 * time.Hour)}, + "foo baz": {UseCount: 10, LastUsed: now.Add(-1 * time.Hour)}, + } + lookup := func(t string) search.ItemMeta { return meta[t] } + + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{{Key: search.TiebreakFrecency}, {Key: search.TiebreakIndex}}, + } + filter := search.Filter(cfg, lookup) + + ranks := filter("foo", targets) + if len(ranks) != 2 { + t.Fatalf("expected 2 matches, got %d", len(ranks)) + } + if targets[ranks[0].Index] != "foo baz" { + t.Errorf("frecency tiebreak: expected 'foo baz' first, got %q", targets[ranks[0].Index]) + } +} + +func TestFrecencyDisabledWhenLookupNil(t *testing.T) { + targets := []string{"foo bar", "foo baz"} + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{{Key: search.TiebreakFrecency}, {Key: search.TiebreakIndex}}, + } + filter := search.Filter(cfg, nil) + ranks := filter("foo", targets) + if len(ranks) != 2 { + t.Fatalf("expected 2 matches, got %d", len(ranks)) + } + if ranks[0].Index != 0 { + t.Errorf("nil lookup: expected index 0 first, got %d", ranks[0].Index) + } +} + +func TestFrecencyBucketLog2LetsLaterTiebreakDecide(t *testing.T) { + targets := []string{"foo bar", "foo baz"} + now := time.Now() + meta := map[string]search.ItemMeta{ + "foo bar": {UseCount: 100, LastUsed: now}, + "foo baz": {UseCount: 105, LastUsed: now}, + } + lookup := func(t string) search.ItemMeta { return meta[t] } + + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{ + {Key: search.TiebreakScore}, + {Key: search.TiebreakFrecency, Bucket: "log2"}, + {Key: search.TiebreakIndex}, + }, + } + ranks := search.Filter(cfg, lookup)("foo", targets) + if len(ranks) != 2 { + t.Fatalf("expected 2 matches, got %d", len(ranks)) + } + if targets[ranks[0].Index] != "foo bar" { + t.Errorf("log2 bucket should tie frecencies 100 vs 105, letting index decide (foo bar first); got %q", targets[ranks[0].Index]) + } + + cfg.Tiebreak[1].Bucket = "" + ranks = search.Filter(cfg, lookup)("foo", targets) + if targets[ranks[0].Index] != "foo baz" { + t.Errorf("unbucketed: expected 'foo baz' to win on frecency, got %q", targets[ranks[0].Index]) + } +} + +func TestBeginTiebreak(t *testing.T) { + targets := []string{"world hello", "hello world"} + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{{Key: search.TiebreakBegin}, {Key: search.TiebreakIndex}}, + } + ranks := search.Filter(cfg, nil)("hello", targets) + if len(ranks) != 2 { + t.Fatalf("expected 2 matches, got %d", len(ranks)) + } + if targets[ranks[0].Index] != "hello world" { + t.Errorf("begin tiebreak: expected 'hello world' first (match at position 0), got %q", targets[ranks[0].Index]) + } +} + +func TestEndTiebreak(t *testing.T) { + targets := []string{"hello world", "world hello"} + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{{Key: search.TiebreakEnd}, {Key: search.TiebreakIndex}}, + } + ranks := search.Filter(cfg, nil)("hello", targets) + if len(ranks) != 2 { + t.Fatalf("expected 2 matches, got %d", len(ranks)) + } + if targets[ranks[0].Index] != "world hello" { + t.Errorf("end tiebreak: expected 'world hello' first (match closer to tail), got %q", targets[ranks[0].Index]) + } +} + +func TestBeginBucketLog2LetsLaterTiebreakDecide(t *testing.T) { + // 'x' is at byte 2 in "aax" and byte 3 in "aaax" — both log2-bucket to 1. + targets := []string{"aaax", "aax"} + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{ + {Key: search.TiebreakBegin, Bucket: "log2"}, + {Key: search.TiebreakIndex}, + }, + } + ranks := search.Filter(cfg, nil)("x", targets) + if len(ranks) != 2 { + t.Fatalf("expected 2 matches, got %d", len(ranks)) + } + if targets[ranks[0].Index] != "aaax" { + t.Errorf("log2 bucket should tie begins 2 vs 3; index should decide (aaax first), got %q", targets[ranks[0].Index]) + } + + cfg.Tiebreak[0].Bucket = "" + ranks = search.Filter(cfg, nil)("x", targets) + if targets[ranks[0].Index] != "aax" { + t.Errorf("unbucketed: expected 'aax' (begin 2 < 3) first, got %q", targets[ranks[0].Index]) + } +} + +func TestFzfEmptyTerm(t *testing.T) { + targets := []string{"a", "b", "c"} + cfg := search.Config{Engine: search.EngineFzf, Algo: search.AlgoV2} + filter := search.Filter(cfg, nil) + ranks := filter("", targets) + if len(ranks) != 3 { + t.Errorf("empty term should pass all items, got %d", len(ranks)) + } +} + +// TestScoreBucketAbsorbsBoundaryNoiseSoLengthDecides guards against a fine score +// bucket letting fzf's boundary-bonus noise override length. fzf scores a match +// preceded by whitespace higher than the same match preceded by '[', so with a +// narrow bucket a long log line that merely contains "git" can outrank a short, +// focused "[git]" entry — and length, a later tiebreak, cannot rescue it across +// buckets. A width-32 score bucket collapses that boundary-bonus spread into one +// bucket so length sorts clean matches short-first, while genuinely scattered +// matches still fall into a lower bucket. +func TestScoreBucketAbsorbsBoundaryNoiseSoLengthDecides(t *testing.T) { + longLog := "deploy notes: cut a release candidate\n remember to checkout main before tagging\n" + + " then run the pipeline ............................................" + targets := []string{ + longLog, // clean " main" but long -> max score (whitespace boundary) + "[main] release notes", // clean but '[' boundary -> slightly lower score, short + "main branch", // clean prefix, shortest + "magician", // scattered m..a..i..n, shortest overall -> lower score bucket + } + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{ + {Key: search.TiebreakScore, Bucket: "32"}, + {Key: search.TiebreakLength}, + {Key: search.TiebreakIndex}, + }, + } + ranks := search.Filter(cfg, nil)("main", targets) + order := make([]string, len(ranks)) + for i, r := range ranks { + order[i] = targets[r.Index] + } + // Short clean matches must come before the long clean match, even though the + // long one has a higher raw fzf score (whitespace boundary beats '['). + posLong, posBracket, posPrefix := indexOf(order, longLog), indexOf(order, "[main] release notes"), indexOf(order, "main branch") + if posPrefix >= posBracket || posBracket >= posLong { + t.Errorf("expected short clean matches before long log; got order:\n %q", order) + } + // "magician" is the shortest target but a scattered match: a coarse bucket + // (e.g. log2) would float it to the top, but width-32 keeps it below the + // clean matches. + if posScatter := indexOf(order, "magician"); posScatter < posLong { + t.Errorf("scattered match should rank below clean matches; got order:\n %q", order) + } +} + +func TestFrecencyRewardsRecencyWithoutUseCount(t *testing.T) { + targets := []string{"chimr old", "chimr new"} + now := time.Now() + meta := map[string]search.ItemMeta{ + "chimr old": {Recorded: now.Add(-72 * time.Hour)}, + "chimr new": {Recorded: now.Add(-1 * time.Minute)}, + } + lookup := func(t string) search.ItemMeta { return meta[t] } + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{{Key: search.TiebreakFrecency}, {Key: search.TiebreakIndex}}, + } + ranks := search.Filter(cfg, lookup)("chimr", targets) + if len(ranks) != 2 { + t.Fatalf("expected 2 matches, got %d", len(ranks)) + } + if targets[ranks[0].Index] != "chimr new" { + t.Errorf("recency: a freshly recorded item (useCount 0) should win, got %q", targets[ranks[0].Index]) + } +} + +func TestRecentLongMatchBeatsOlderShortMatch(t *testing.T) { + short := "chimr" + long := "https://chimr.coelhorocha.com/d8c952vfm9u94tr4" + targets := []string{short, long} + now := time.Now() + meta := map[string]search.ItemMeta{ + short: {Recorded: now.Add(-240 * time.Hour)}, + long: {Recorded: now.Add(-1 * time.Minute)}, + } + lookup := func(t string) search.ItemMeta { return meta[t] } + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []search.TiebreakEntry{ + {Key: search.TiebreakScore, Bucket: "32"}, + {Key: search.TiebreakFrecency}, + {Key: search.TiebreakLength}, + {Key: search.TiebreakIndex}, + }, + } + ranks := search.Filter(cfg, lookup)("chimr", targets) + if targets[ranks[0].Index] != long { + t.Errorf("recency above length: recent long match should win over old short match, got %q", targets[ranks[0].Index]) + } +} + +func TestTypoToleranceMatchesTransposition(t *testing.T) { + hit := "chimr.coelhorocha.com" + noise := "the quick brown fox jumps over a chair in march" + targets := []string{noise, hit} + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + TypoTolerance: true, + Tiebreak: []search.TiebreakEntry{ + {Key: search.TiebreakScore, Bucket: "32"}, + {Key: search.TiebreakLength}, + {Key: search.TiebreakIndex}, + }, + } + for _, term := range []string{"chirm", "chmir", "chrmi"} { + ranks := search.Filter(cfg, nil)(term, targets) + if len(ranks) == 0 { + t.Fatalf("%q: expected a typo match, got none", term) + } + if targets[ranks[0].Index] != hit { + t.Errorf("%q: edit-distance match should rank the near-spelling above scattered noise, got %q", term, targets[ranks[0].Index]) + } + } +} + +func TestTypoToleranceSkipsLargeTargets(t *testing.T) { + hit := "chimr" + blob := "module github.com/charmbracelet/bubbletea\n" + strings.Repeat("x", 300) + targets := []string{blob, hit} + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + TypoTolerance: true, + MaxScatter: 24, + Tiebreak: []search.TiebreakEntry{{Key: search.TiebreakScore, Bucket: "32"}, {Key: search.TiebreakLength}}, + } + ranks := search.Filter(cfg, nil)("chimr", targets) + if len(ranks) == 0 || targets[ranks[0].Index] != hit { + t.Fatalf("clean 'chimr' should win; got %v", ranks) + } + for _, r := range ranks { + if targets[r.Index] == blob { + t.Errorf("large target should not typo-match 'charm'->'chimr'; blob leaked into results") + } + } +} + +func TestTypoToleranceDisabled(t *testing.T) { + targets := []string{"chimr.coelhorocha.com"} + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + TypoTolerance: false, + Tiebreak: []search.TiebreakEntry{{Key: search.TiebreakScore}}, + } + if ranks := search.Filter(cfg, nil)("chrmi", targets); len(ranks) != 0 { + t.Errorf("with typo tolerance off, a transposition should not match, got %d results", len(ranks)) + } +} + +func indexOf(s []string, v string) int { + for i, x := range s { + if x == v { + return i + } + } + return -1 +} diff --git a/utils/string.go b/utils/string.go index 1421613..371ffb3 100644 --- a/utils/string.go +++ b/utils/string.go @@ -10,6 +10,7 @@ import ( "regexp" "strings" "time" + "unicode" ) // any chars that cause the fuzzy find to crash can be appended here @@ -18,12 +19,17 @@ var badChars = []string{ } func Shorten(s string, maxChar int) string { - sl := strings.TrimSpace( - strings.ReplaceAll( - strings.ReplaceAll(s, "\n", "\\n"), - "\t", " ", - ), + escaped := strings.ReplaceAll( + strings.ReplaceAll(s, "\n", "\\n"), + "\t", " ", ) + escaped = strings.Map(func(r rune) rune { + if unicode.IsPrint(r) { + return r + } + return -1 + }, escaped) + sl := strings.TrimSpace(escaped) if len(sl) <= maxChar { return strings.ReplaceAll(sl, " ", " ")