// 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() }