fix(history): substring fuzzy search (#74)
closes #73 <img width="1434" height="302" alt="image" src="https://github.com/user-attachments/assets/87bb8431-40c7-4ee8-ad58-79c4fea6efa7" /> I used use prefix match imagine you have a long command ENOUGH that you used to use (like image), with fuzzy search + substring, it will always show suggestion about that command in the list but in the bottom and I see this very annoying but now I limit the score from fuzzy search, if the command is too long (which means fuzzy score is extremely weak), skip it right away <img width="1296" height="437" alt="image" src="https://github.com/user-attachments/assets/77d807a2-ddce-4c37-9f5b-a02cd44de325" /> it now has substring support
This commit is contained in:
+25
-75
@@ -208,52 +208,33 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
|
|||||||
var results []HistResult
|
var results []HistResult
|
||||||
seenCmds := make(map[string]bool)
|
seenCmds := make(map[string]bool)
|
||||||
|
|
||||||
addMatches := func(q string, subcmdFilter bool) {
|
addMatches := func(q string) {
|
||||||
qLow := strings.ToLower(q)
|
qLow := strings.ToLower(q)
|
||||||
queryFirstWord := ""
|
|
||||||
querySecondWord := ""
|
|
||||||
if strings.IndexByte(qLow, ' ') != -1 {
|
|
||||||
if fields := strings.Fields(qLow); len(fields) > 0 {
|
|
||||||
queryFirstWord = fields[0]
|
|
||||||
// find first non-flag token after the command as the subcommand
|
|
||||||
for _, f := range fields[1:] {
|
|
||||||
if !strings.HasPrefix(f, "-") {
|
|
||||||
querySecondWord = f
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// extract pure prefix matches based strictly on recency order (historyCache is newest-first)
|
// extract pure substring matches (all words present) based strictly on recency order (historyCache is newest-first)
|
||||||
|
// this ensures that long commands with exact substrings are never truncated by the fuzzy searcher's limit
|
||||||
strictMatches := 0
|
strictMatches := 0
|
||||||
|
words := strings.Fields(qLow)
|
||||||
|
if len(words) == 0 {
|
||||||
|
words = []string{qLow}
|
||||||
|
}
|
||||||
|
|
||||||
for _, cmd := range historyCache {
|
for _, cmd := range historyCache {
|
||||||
if seenCmds[cmd] {
|
if seenCmds[cmd] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fields := strings.Fields(cmd)
|
|
||||||
firstWordLow := ""
|
cmdLow := strings.ToLower(cmd)
|
||||||
if len(fields) > 0 {
|
matchAll := true
|
||||||
firstWordLow = strings.ToLower(fields[0])
|
for _, w := range words {
|
||||||
|
if !strings.Contains(cmdLow, w) {
|
||||||
|
matchAll = false
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if queryFirstWord != "" {
|
if !matchAll {
|
||||||
if firstWordLow != queryFirstWord {
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
if subcmdFilter && querySecondWord != "" {
|
|
||||||
if len(fields) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
secondWordLow := strings.ToLower(fields[1])
|
|
||||||
if !strings.HasPrefix(secondWordLow, querySecondWord) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !strings.HasPrefix(firstWordLow, qLow) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seenCmds[cmd] = true
|
seenCmds[cmd] = true
|
||||||
@@ -273,33 +254,11 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
|
|||||||
if seenCmds[m.Str] {
|
if seenCmds[m.Str] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter results by command name match
|
// filter out extremely weak fuzzy matches (e.g. random garbage typing that
|
||||||
fields := strings.Fields(m.Str)
|
// loosely matches across a very long command)
|
||||||
firstWord := m.Str
|
if len(q) > 0 && m.Score/len(q) < 150 {
|
||||||
if len(fields) > 0 {
|
continue
|
||||||
firstWord = fields[0]
|
|
||||||
}
|
|
||||||
firstWordLow := strings.ToLower(firstWord)
|
|
||||||
|
|
||||||
if queryFirstWord != "" {
|
|
||||||
if firstWordLow != queryFirstWord {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// when query has a non-flag second token, filter by subcommand prefix
|
|
||||||
if subcmdFilter && querySecondWord != "" {
|
|
||||||
if len(fields) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
secondWordLow := strings.ToLower(fields[1])
|
|
||||||
if !strings.HasPrefix(secondWordLow, querySecondWord) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !strings.HasPrefix(firstWordLow, qLow) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seenCmds[m.Str] = true
|
seenCmds[m.Str] = true
|
||||||
@@ -311,18 +270,9 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addMatches(query, true)
|
addMatches(query)
|
||||||
for _, altQ := range alternativeQueries {
|
for _, altQ := range alternativeQueries {
|
||||||
addMatches(altQ, true)
|
addMatches(altQ)
|
||||||
}
|
|
||||||
|
|
||||||
// fallback: if subcommand filter produced nothing, retry without it
|
|
||||||
// so typos like "git chckout" still surface fuzzy matches
|
|
||||||
if len(results) == 0 {
|
|
||||||
addMatches(query, false)
|
|
||||||
for _, altQ := range alternativeQueries {
|
|
||||||
addMatches(altQ, false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getTier := func(cmd, q string) int {
|
getTier := func(cmd, q string) int {
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ func (f *FrecencyStore) QueryLocal(ctx context.Context, cwd, prefix string, limi
|
|||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ? AND cmd LIKE ?`, cwd, prefix+"%")
|
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ? AND cmd LIKE ?`, cwd, "%"+prefix+"%")
|
||||||
} else {
|
} else {
|
||||||
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ?`, cwd)
|
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ?`, cwd)
|
||||||
}
|
}
|
||||||
@@ -397,7 +397,7 @@ func (f *FrecencyStore) QueryGlobal(ctx context.Context, prefix string, limit in
|
|||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cmd LIKE ?`, prefix+"%")
|
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cmd LIKE ?`, "%"+prefix+"%")
|
||||||
} else {
|
} else {
|
||||||
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries`)
|
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,55 @@ func TestFrecencyStore_SQLiteConfigurationAndContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFrecencyStore_SubstringMatch(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tmpDir, "history.db")
|
||||||
|
store, err := NewFrecencyStore(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFrecencyStore failed: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
cwd := "/home/user/project"
|
||||||
|
_ = store.Record(context.Background(), "sudo chown -R www-data:www-data /var/www/html", cwd, 0)
|
||||||
|
_ = store.Record(context.Background(), "sudo systemctl restart nginx", cwd, 0)
|
||||||
|
_ = store.Record(context.Background(), "chmod +x script.sh", cwd, 0)
|
||||||
|
|
||||||
|
_ = store.Record(context.Background(), "echo hello world", cwd, 0)
|
||||||
|
|
||||||
|
// Test QueryLocal with substring
|
||||||
|
entries, err := store.QueryLocal(context.Background(), cwd, "cho", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QueryLocal failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 { // should match 'sudo chown...' and 'echo...' (has 'cho')
|
||||||
|
t.Fatalf("expected 2 entries for 'cho', got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
foundChown := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Cmd == "sudo chown -R www-data:www-data /var/www/html" {
|
||||||
|
foundChown = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundChown {
|
||||||
|
t.Errorf("expected 'sudo chown...' to be found with substring 'cho'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test QueryGlobal with substring
|
||||||
|
entriesGlobal, err := store.QueryGlobal(context.Background(), "chown", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QueryGlobal failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entriesGlobal) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry for 'chown', got %d", len(entriesGlobal))
|
||||||
|
}
|
||||||
|
if entriesGlobal[0].Cmd != "sudo chown -R www-data:www-data /var/www/html" {
|
||||||
|
t.Errorf("expected 'sudo chown...' to be found with substring 'chown', got %s", entriesGlobal[0].Cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFrecencyStore_NilReceiver(t *testing.T) {
|
func TestFrecencyStore_NilReceiver(t *testing.T) {
|
||||||
var nilStore *FrecencyStore
|
var nilStore *FrecencyStore
|
||||||
if err := nilStore.Record(context.Background(), "cmd", "cwd", 0); err != nil {
|
if err := nilStore.Record(context.Background(), "cmd", "cwd", 0); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user