Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 84 additions & 62 deletions pkg/parser/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -668,31 +668,36 @@ func generateFieldSuggestions(invalidProps, acceptedFields []string) string {

var suggestion strings.Builder

if len(invalidProps) == 1 {
suggestion.WriteString("Did you mean one of: ")
} else {
suggestion.WriteString("Valid fields are: ")
}

// Find closest matches using simple string distance
// Find closest matches using Levenshtein distance
var suggestions []string
for _, invalidProp := range invalidProps {
closest := findClosestMatches(invalidProp, acceptedFields, maxClosestMatches)
suggestions = append(suggestions, closest...)
}

// If we have specific suggestions, show them first
if len(suggestions) > 0 {
// Remove duplicates
uniqueSuggestions := removeDuplicates(suggestions)
if len(uniqueSuggestions) <= maxSuggestions {
suggestion.WriteString(strings.Join(uniqueSuggestions, ", "))
// Remove duplicates
uniqueSuggestions := removeDuplicates(suggestions)

// Generate appropriate message based on suggestions found
if len(uniqueSuggestions) > 0 {
if len(invalidProps) == 1 && len(uniqueSuggestions) == 1 {
// Single typo, single suggestion
suggestion.WriteString("Did you mean '")
suggestion.WriteString(uniqueSuggestions[0])
suggestion.WriteString("'?")
} else {
suggestion.WriteString(strings.Join(uniqueSuggestions[:maxSuggestions], ", "))
suggestion.WriteString(", ...")
// Multiple typos or multiple suggestions
suggestion.WriteString("Did you mean: ")
if len(uniqueSuggestions) <= maxSuggestions {
suggestion.WriteString(strings.Join(uniqueSuggestions, ", "))
} else {
suggestion.WriteString(strings.Join(uniqueSuggestions[:maxSuggestions], ", "))
suggestion.WriteString(", ...")
}
}
} else {
// Show all accepted fields if no close matches
// No close matches found - show all valid fields
suggestion.WriteString("Valid fields are: ")
if len(acceptedFields) <= maxAcceptedFields {
suggestion.WriteString(strings.Join(acceptedFields, ", "))
} else {
Expand All @@ -704,29 +709,40 @@ func generateFieldSuggestions(invalidProps, acceptedFields []string) string {
return suggestion.String()
}

// findClosestMatches finds the closest matching strings using simple edit distance heuristics
// findClosestMatches finds the closest matching strings using Levenshtein distance
func findClosestMatches(target string, candidates []string, maxResults int) []string {
type match struct {
value string
score int
value string
distance int
}

const maxDistance = 3 // Maximum acceptable Levenshtein distance

var matches []match
targetLower := strings.ToLower(target)

for _, candidate := range candidates {
candidateLower := strings.ToLower(candidate)
score := calculateSimilarityScore(targetLower, candidateLower)

// Only include if there's some similarity
if score > 0 {
matches = append(matches, match{value: candidate, score: score})
// Skip exact matches
if targetLower == candidateLower {
continue
}

distance := levenshteinDistance(targetLower, candidateLower)

// Only include if distance is within acceptable range
if distance <= maxDistance {
matches = append(matches, match{value: candidate, distance: distance})
}
}

// Sort by score (higher is better)
// Sort by distance (lower is better), then alphabetically for ties
sort.Slice(matches, func(i, j int) bool {
return matches[i].score > matches[j].score
if matches[i].distance != matches[j].distance {
return matches[i].distance < matches[j].distance
}
return matches[i].value < matches[j].value
})

// Return top matches
Expand All @@ -738,44 +754,58 @@ func findClosestMatches(target string, candidates []string, maxResults int) []st
return results
}

// calculateSimilarityScore calculates a simple similarity score between two strings
func calculateSimilarityScore(a, b string) int {
// Early exit for obviously poor matches (length difference > 2x shorter string length)
minLen := len(a)
if len(b) < minLen {
minLen = len(b)
// levenshteinDistance computes the Levenshtein distance between two strings.
// This is the minimum number of single-character edits (insertions, deletions, or substitutions)
// required to change one string into the other.
func levenshteinDistance(a, b string) int {
aLen := len(a)
bLen := len(b)

// Early exit for empty strings
if aLen == 0 {
return bLen
}
lengthDiff := abs(len(a) - len(b))
if lengthDiff > minLen*2 && minLen > 0 {
return 0
if bLen == 0 {
return aLen
}

// Simple heuristics for string similarity
score := 0
// Create a 2D matrix for dynamic programming
// We only need the previous row, so we can optimize space
previousRow := make([]int, bLen+1)
currentRow := make([]int, bLen+1)

// Bonus for substring matches
if strings.Contains(b, a) || strings.Contains(a, b) {
score += 10
// Initialize the first row (distance from empty string)
for i := 0; i <= bLen; i++ {
previousRow[i] = i
}

// Bonus for common prefixes
commonPrefix := 0
for i := 0; i < len(a) && i < len(b) && a[i] == b[i]; i++ {
commonPrefix++
}
score += commonPrefix * 2
// Calculate distances for each character in string a
for i := 1; i <= aLen; i++ {
currentRow[0] = i // Distance from empty string

// Bonus for common suffixes
commonSuffix := 0
for i := 0; i < len(a) && i < len(b) && a[len(a)-1-i] == b[len(b)-1-i]; i++ {
commonSuffix++
}
score += commonSuffix * 2
for j := 1; j <= bLen; j++ {
// Cost of substitution (0 if characters match, 1 otherwise)
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}

// Penalty for length difference
score -= lengthDiff
// Minimum of:
// - Deletion: previousRow[j] + 1
// - Insertion: currentRow[j-1] + 1
// - Substitution: previousRow[j-1] + cost
deletion := previousRow[j] + 1
insertion := currentRow[j-1] + 1
substitution := previousRow[j-1] + cost

return score
currentRow[j] = min(deletion, min(insertion, substitution))
}

// Swap rows for next iteration
previousRow, currentRow = currentRow, previousRow
}

return previousRow[bLen]
}

// generateExampleJSONForPath generates an example JSON object for a specific schema path
Expand Down Expand Up @@ -897,14 +927,6 @@ func removeDuplicates(strings []string) []string {
return result
}

// abs returns the absolute value of an integer
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

// rewriteAdditionalPropertiesError rewrites "additional properties not allowed" errors to be more user-friendly
func rewriteAdditionalPropertiesError(message string) string {
// Check if this is an "additional properties not allowed" error
Expand Down
26 changes: 13 additions & 13 deletions pkg/parser/schema_suggestions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ func TestGenerateSchemaBasedSuggestions(t *testing.T) {
}{
{
name: "additional property error at root level",
errorMessage: "additional property 'invalid_prop' not allowed",
errorMessage: "additional property 'naem' not allowed", // Typo for "name" (distance 2)
jsonPath: "",
wantContains: []string{"Did you mean one of:", "name", "age", "email"},
wantContains: []string{"Did you mean", "name"}, // Close match found
},
{
name: "additional property error in nested object",
errorMessage: "additional property 'unknown_permission' not allowed",
errorMessage: "additional property 'contnt' not allowed", // Typo for "contents" (distance 1)
jsonPath: "/permissions",
wantContains: []string{"Did you mean one of:", "contents", "issues", "pull-requests"},
wantContains: []string{"Did you mean", "contents"}, // Close match found
},
{
name: "type error with integer expected",
Expand All @@ -54,9 +54,9 @@ func TestGenerateSchemaBasedSuggestions(t *testing.T) {
},
{
name: "multiple additional properties",
errorMessage: "additional properties 'prop1', 'prop2' not allowed",
errorMessage: "additional properties 'prop1', 'prop2' not allowed", // Far from any valid field
jsonPath: "",
wantContains: []string{"Valid fields are:", "name", "age"},
wantContains: []string{"Valid fields are:", "name", "age"}, // No close matches, show valid fields
},
{
name: "non-validation error",
Expand Down Expand Up @@ -155,16 +155,16 @@ func TestGenerateFieldSuggestions(t *testing.T) {
wantContains []string
}{
{
name: "single invalid property with close match",
name: "single invalid property with close matches",
invalidProps: []string{"contnt"},
acceptedFields: []string{"content", "contents", "name"},
wantContains: []string{"Did you mean one of:", "content"},
wantContains: []string{"Did you mean:", "content"}, // Returns suggestions including "content"
},
{
name: "multiple invalid properties",
invalidProps: []string{"prop1", "prop2"},
acceptedFields: []string{"name", "age", "email"},
wantContains: []string{"Valid fields are:", "name", "age", "email"},
wantContains: []string{"Valid fields are:", "name", "age", "email"}, // No close matches, show all
},
{
name: "no accepted fields",
Expand Down Expand Up @@ -204,22 +204,22 @@ func TestFindClosestMatches(t *testing.T) {
wantFirst string // First result should be this
}{
{
name: "exact substring match",
name: "exact match skipped - returns next closest",
target: "content",
maxResults: 3,
wantFirst: "content",
wantFirst: "contents", // Exact match is skipped, "contents" has distance 1
},
{
name: "partial match",
target: "contnt",
maxResults: 2,
wantFirst: "content",
wantFirst: "content", // "content" has distance 1
},
{
name: "prefix match",
target: "time",
maxResults: 1,
wantFirst: "timeout",
wantFirst: "name", // "name" has distance 2, closer than "timeout" (distance 3)
},
}

Expand Down
Loading