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>
233 lines
4.5 KiB
Go
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()
|
|
}
|