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>
This commit is contained in:
232
internal/suggest/suggest.go
Normal file
232
internal/suggest/suggest.go
Normal file
@@ -0,0 +1,232 @@
|
||||
// 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()
|
||||
}
|
||||
120
internal/suggest/suggest_test.go
Normal file
120
internal/suggest/suggest_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindSimilar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
candidates []string
|
||||
maxResults int
|
||||
wantFirst string // expect this to be the first result
|
||||
}{
|
||||
{
|
||||
name: "exact prefix match",
|
||||
target: "toa",
|
||||
candidates: []string{"Toast", "Nux", "Capable", "Ghost"},
|
||||
maxResults: 3,
|
||||
wantFirst: "Toast",
|
||||
},
|
||||
{
|
||||
name: "typo match",
|
||||
target: "Tosat",
|
||||
candidates: []string{"Toast", "Nux", "Capable"},
|
||||
maxResults: 3,
|
||||
wantFirst: "Toast",
|
||||
},
|
||||
{
|
||||
name: "case insensitive",
|
||||
target: "TOAST",
|
||||
candidates: []string{"Nux", "Toast", "Capable"},
|
||||
maxResults: 1,
|
||||
wantFirst: "Toast", // finds Toast even with different case
|
||||
},
|
||||
{
|
||||
name: "no matches",
|
||||
target: "xyz",
|
||||
candidates: []string{"abc", "def"},
|
||||
maxResults: 3,
|
||||
wantFirst: "", // no good matches
|
||||
},
|
||||
{
|
||||
name: "empty candidates",
|
||||
target: "test",
|
||||
candidates: []string{},
|
||||
maxResults: 3,
|
||||
wantFirst: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := FindSimilar(tt.target, tt.candidates, tt.maxResults)
|
||||
|
||||
if tt.wantFirst == "" {
|
||||
if len(results) > 0 {
|
||||
// Allow some results for partial matches, just check they're reasonable
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
t.Errorf("FindSimilar(%q) returned no results, want first = %q", tt.target, tt.wantFirst)
|
||||
return
|
||||
}
|
||||
|
||||
if results[0] != tt.wantFirst {
|
||||
t.Errorf("FindSimilar(%q) first result = %q, want %q", tt.target, results[0], tt.wantFirst)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshteinDistance(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "a", 1},
|
||||
{"abc", "abc", 0},
|
||||
{"abc", "abd", 1},
|
||||
{"abc", "adc", 1},
|
||||
{"abc", "abcd", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.a+"_"+tt.b, func(t *testing.T) {
|
||||
got := levenshteinDistance(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("levenshteinDistance(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSuggestion(t *testing.T) {
|
||||
msg := FormatSuggestion("Polecat", "Tosat", []string{"Toast", "Ghost"}, "Create with: gt polecat add Tosat")
|
||||
|
||||
if !strings.Contains(msg, "Polecat") {
|
||||
t.Errorf("FormatSuggestion missing entity name")
|
||||
}
|
||||
if !strings.Contains(msg, "Tosat") {
|
||||
t.Errorf("FormatSuggestion missing target name")
|
||||
}
|
||||
if !strings.Contains(msg, "Did you mean?") {
|
||||
t.Errorf("FormatSuggestion missing 'Did you mean?' section")
|
||||
}
|
||||
if !strings.Contains(msg, "Toast") {
|
||||
t.Errorf("FormatSuggestion missing suggestion 'Toast'")
|
||||
}
|
||||
if !strings.Contains(msg, "Create with:") {
|
||||
t.Errorf("FormatSuggestion missing hint")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user