From 9b40c3bd8e0a437eaeaea5e2692b3d5313f658b1 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Sun, 13 Jul 2025 20:09:58 -0700 Subject: [PATCH] add fuzzy search --- internal/app/table_data.go | 101 +++++++++++++++++++++++++++++++++++-- internal/app/table_list.go | 94 ++++++++++++++++++++++++++++++++-- 2 files changed, 188 insertions(+), 7 deletions(-) diff --git a/internal/app/table_data.go b/internal/app/table_data.go index 404da69..f8e0b58 100644 --- a/internal/app/table_data.go +++ b/internal/app/table_data.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "sort" "strings" tea "github.com/charmbracelet/bubbletea" @@ -176,15 +177,39 @@ func (m *TableDataModel) filterData() { m.Shared.FilteredData = make([][]string, len(m.Shared.TableData)) copy(m.Shared.FilteredData, m.Shared.TableData) } else { - m.Shared.FilteredData = [][]string{} + // Fuzzy search with scoring for rows + type rowMatch struct { + row []string + score int + } + + var matches []rowMatch searchLower := strings.ToLower(m.searchInput) + for _, row := range m.Shared.TableData { + bestScore := 0 + // Check each cell in the row and take the best score for _, cell := range row { - if strings.Contains(strings.ToLower(cell), searchLower) { - m.Shared.FilteredData = append(m.Shared.FilteredData, row) - break + score := m.fuzzyScore(strings.ToLower(cell), searchLower) + if score > bestScore { + bestScore = score } } + + if bestScore > 0 { + matches = append(matches, rowMatch{row: row, score: bestScore}) + } + } + + // Sort by score (highest first) + sort.Slice(matches, func(i, j int) bool { + return matches[i].score > matches[j].score + }) + + // Extract sorted rows + m.Shared.FilteredData = make([][]string, len(matches)) + for i, match := range matches { + m.Shared.FilteredData[i] = match.row } } @@ -193,6 +218,74 @@ func (m *TableDataModel) filterData() { } } +// fuzzyScore calculates a fuzzy match score between text and pattern +// Returns 0 for no match, higher scores for better matches +func (m *TableDataModel) fuzzyScore(text, pattern string) int { + if pattern == "" { + return 1 + } + + textLen := len(text) + patternLen := len(pattern) + + if patternLen > textLen { + return 0 + } + + // Exact match gets highest score + if text == pattern { + return 1000 + } + + // Prefix match gets high score + if strings.HasPrefix(text, pattern) { + return 900 + } + + // Contains match gets medium score + if strings.Contains(text, pattern) { + return 800 + } + + // Fuzzy character sequence matching + score := 0 + textIdx := 0 + patternIdx := 0 + consecutiveMatches := 0 + + for textIdx < textLen && patternIdx < patternLen { + if text[textIdx] == pattern[patternIdx] { + score += 10 + consecutiveMatches++ + + // Bonus for consecutive matches + if consecutiveMatches > 1 { + score += consecutiveMatches * 5 + } + + // Bonus for matches at word boundaries + if textIdx == 0 || text[textIdx-1] == '_' || text[textIdx-1] == '-' || text[textIdx-1] == ' ' { + score += 20 + } + + patternIdx++ + } else { + consecutiveMatches = 0 + } + textIdx++ + } + + // Must match all pattern characters + if patternIdx < patternLen { + return 0 + } + + // Bonus for shorter text (more precise match) + score += (100 - textLen) + + return score +} + func (m *TableDataModel) View() string { var content strings.Builder diff --git a/internal/app/table_list.go b/internal/app/table_list.go index a57251a..9164050 100644 --- a/internal/app/table_list.go +++ b/internal/app/table_list.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "sort" "strings" tea "github.com/charmbracelet/bubbletea" @@ -161,13 +162,32 @@ func (m *TableListModel) filterTables() { m.Shared.FilteredTables = make([]string, len(m.Shared.Tables)) copy(m.Shared.FilteredTables, m.Shared.Tables) } else { - m.Shared.FilteredTables = []string{} + // Fuzzy search with scoring + type tableMatch struct { + name string + score int + } + + var matches []tableMatch searchLower := strings.ToLower(m.searchInput) + for _, table := range m.Shared.Tables { - if strings.Contains(strings.ToLower(table), searchLower) { - m.Shared.FilteredTables = append(m.Shared.FilteredTables, table) + score := m.fuzzyScore(strings.ToLower(table), searchLower) + if score > 0 { + matches = append(matches, tableMatch{name: table, score: score}) } } + + // Sort by score (highest first) + sort.Slice(matches, func(i, j int) bool { + return matches[i].score > matches[j].score + }) + + // Extract sorted table names + m.Shared.FilteredTables = make([]string, len(matches)) + for i, match := range matches { + m.Shared.FilteredTables[i] = match.name + } } if m.selectedTable >= len(m.Shared.FilteredTables) { @@ -176,6 +196,74 @@ func (m *TableListModel) filterTables() { } } +// fuzzyScore calculates a fuzzy match score between text and pattern +// Returns 0 for no match, higher scores for better matches +func (m *TableListModel) fuzzyScore(text, pattern string) int { + if pattern == "" { + return 1 + } + + textLen := len(text) + patternLen := len(pattern) + + if patternLen > textLen { + return 0 + } + + // Exact match gets highest score + if text == pattern { + return 1000 + } + + // Prefix match gets high score + if strings.HasPrefix(text, pattern) { + return 900 + } + + // Contains match gets medium score + if strings.Contains(text, pattern) { + return 800 + } + + // Fuzzy character sequence matching + score := 0 + textIdx := 0 + patternIdx := 0 + consecutiveMatches := 0 + + for textIdx < textLen && patternIdx < patternLen { + if text[textIdx] == pattern[patternIdx] { + score += 10 + consecutiveMatches++ + + // Bonus for consecutive matches + if consecutiveMatches > 1 { + score += consecutiveMatches * 5 + } + + // Bonus for matches at word boundaries + if textIdx == 0 || text[textIdx-1] == '_' || text[textIdx-1] == '-' { + score += 20 + } + + patternIdx++ + } else { + consecutiveMatches = 0 + } + textIdx++ + } + + // Must match all pattern characters + if patternIdx < patternLen { + return 0 + } + + // Bonus for shorter text (more precise match) + score += (100 - textLen) + + return score +} + func (m *TableListModel) getVisibleCount() int { reservedLines := 8 if m.searching {