Files
gastown/internal/suggest/suggest.go
Steve Yegge df0495be32 feat(tui): add self-documenting help with ASCII diagrams and table helpers
TUI improvements for Christmas launch:
- Add phase transition table and lifecycle diagram to `gt molecule --help`
- Add swarm lifecycle diagram to `gt swarm --help`
- Add mail routing diagram to `gt mail --help`
- Add sling mechanics diagram to `gt sling --help`
- Create Lipgloss table helper (internal/style/table.go)
- Migrate mq_list to use styled tables with color-coded priorities
- Migrate molecule list to use styled tables
- Add fuzzy matching "did you mean" suggestions for polecat not found errors
- Add suggest package with Levenshtein distance implementation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 17:52:44 -08:00

233 lines
4.5 KiB
Go

// Package suggest provides fuzzy matching and "did you mean" suggestions.
package suggest
import (
"sort"
"strings"
"unicode"
)
// Match represents a potential match with its score.
type Match struct {
Value string
Score int
}
// FindSimilar finds similar strings from candidates that are close to target.
// Returns up to maxResults matches, sorted by similarity (best first).
func FindSimilar(target string, candidates []string, maxResults int) []string {
if len(candidates) == 0 || maxResults <= 0 {
return nil
}
target = strings.ToLower(target)
var matches []Match
for _, c := range candidates {
score := similarity(target, strings.ToLower(c))
if score > 0 {
matches = append(matches, Match{Value: c, Score: score})
}
}
// Sort by score descending
sort.Slice(matches, func(i, j int) bool {
return matches[i].Score > matches[j].Score
})
// Take top results
if len(matches) > maxResults {
matches = matches[:maxResults]
}
result := make([]string, len(matches))
for i, m := range matches {
result[i] = m.Value
}
return result
}
// similarity calculates a similarity score between two strings.
// Higher is more similar. Uses a combination of techniques:
// - Prefix matching (high weight)
// - Contains matching (medium weight)
// - Levenshtein distance (for close matches)
// - Common substring matching
func similarity(a, b string) int {
if a == b {
return 1000 // Exact match
}
score := 0
// Prefix matching - high value
prefixLen := commonPrefixLength(a, b)
if prefixLen > 0 {
score += prefixLen * 20
}
// Suffix matching
suffixLen := commonSuffixLength(a, b)
if suffixLen > 0 {
score += suffixLen * 10
}
// Contains matching
if strings.Contains(b, a) {
score += len(a) * 15
} else if strings.Contains(a, b) {
score += len(b) * 10
}
// Levenshtein distance for close matches
dist := levenshteinDistance(a, b)
maxLen := max(len(a), len(b))
if maxLen > 0 && dist <= maxLen/2 {
// Closer distance = higher score
score += (maxLen - dist) * 5
}
// Common characters bonus (order-independent)
common := commonChars(a, b)
if common > 0 {
score += common * 2
}
// Penalize very different lengths
lenDiff := abs(len(a) - len(b))
if lenDiff > 5 {
score -= lenDiff * 2
}
return score
}
// commonPrefixLength returns the length of the common prefix.
func commonPrefixLength(a, b string) int {
minLen := min(len(a), len(b))
for i := 0; i < minLen; i++ {
if a[i] != b[i] {
return i
}
}
return minLen
}
// commonSuffixLength returns the length of the common suffix.
func commonSuffixLength(a, b string) int {
minLen := min(len(a), len(b))
for i := 0; i < minLen; i++ {
if a[len(a)-1-i] != b[len(b)-1-i] {
return i
}
}
return minLen
}
// commonChars counts characters that appear in both strings.
func commonChars(a, b string) int {
aChars := make(map[rune]int)
for _, r := range a {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
aChars[r]++
}
}
common := 0
for _, r := range b {
if count, ok := aChars[r]; ok && count > 0 {
common++
aChars[r]--
}
}
return common
}
// levenshteinDistance calculates the edit distance between two strings.
func levenshteinDistance(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
// Create distance matrix
d := make([][]int, len(a)+1)
for i := range d {
d[i] = make([]int, len(b)+1)
d[i][0] = i
}
for j := range d[0] {
d[0][j] = j
}
// Fill the matrix
for i := 1; i <= len(a); i++ {
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
d[i][j] = min3(
d[i-1][j]+1, // deletion
d[i][j-1]+1, // insertion
d[i-1][j-1]+cost, // substitution
)
}
}
return d[len(a)][len(b)]
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min3(a, b, c int) int {
return min(min(a, b), c)
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
// FormatSuggestion formats an error message with suggestions.
func FormatSuggestion(entity, name string, suggestions []string, createHint string) string {
var sb strings.Builder
sb.WriteString(entity)
sb.WriteString(" '")
sb.WriteString(name)
sb.WriteString("' not found")
if len(suggestions) > 0 {
sb.WriteString("\n\n Did you mean?\n")
for _, s := range suggestions {
sb.WriteString(" • ")
sb.WriteString(s)
sb.WriteString("\n")
}
}
if createHint != "" {
sb.WriteString("\n ")
sb.WriteString(createHint)
}
return sb.String()
}