Skip to main content

shape_runtime/type_system/
suggestions.rs

1//! Error Suggestions
2//!
3//! Provides "did you mean?" suggestions for type errors using Levenshtein distance
4//! to find similar names when users make typos.
5
6/// Calculate the Levenshtein edit distance between two strings.
7///
8/// This is the minimum number of single-character edits (insertions,
9/// deletions, or substitutions) required to change one string into the other.
10pub fn levenshtein_distance(a: &str, b: &str) -> usize {
11    let a_len = a.chars().count();
12    let b_len = b.chars().count();
13
14    // Handle empty strings
15    if a_len == 0 {
16        return b_len;
17    }
18    if b_len == 0 {
19        return a_len;
20    }
21
22    // Use two rows for space efficiency
23    let mut prev_row: Vec<usize> = (0..=b_len).collect();
24    let mut curr_row = vec![0; b_len + 1];
25
26    for (i, a_char) in a.chars().enumerate() {
27        curr_row[0] = i + 1;
28
29        for (j, b_char) in b.chars().enumerate() {
30            let cost = if a_char == b_char { 0 } else { 1 };
31            curr_row[j + 1] = (prev_row[j + 1] + 1) // deletion
32                .min(curr_row[j] + 1) // insertion
33                .min(prev_row[j] + cost); // substitution
34        }
35
36        std::mem::swap(&mut prev_row, &mut curr_row);
37    }
38
39    prev_row[b_len]
40}
41
42/// Find similar strings from a list of candidates.
43///
44/// Returns candidates that are within `max_distance` edits of the target,
45/// sorted by similarity (closest first).
46pub fn find_similar<'a>(
47    candidates: impl IntoIterator<Item = &'a str>,
48    target: &str,
49    max_distance: usize,
50) -> Vec<&'a str> {
51    let mut results: Vec<(&str, usize)> = candidates
52        .into_iter()
53        .filter_map(|candidate| {
54            let distance = levenshtein_distance(candidate, target);
55            if distance <= max_distance && distance > 0 {
56                Some((candidate, distance))
57            } else {
58                None
59            }
60        })
61        .collect();
62
63    // Sort by distance (closest first)
64    results.sort_by_key(|(_, d)| *d);
65
66    // Return just the strings
67    results.into_iter().map(|(s, _)| s).collect()
68}
69
70/// Calculate a reasonable max distance for suggestions based on target length.
71///
72/// Short names get stricter matching, long names allow more edits.
73pub fn reasonable_max_distance(target: &str) -> usize {
74    let len = target.len();
75    if len <= 2 {
76        1
77    } else if len <= 5 {
78        2
79    } else {
80        3
81    }
82}
83
84/// Format a "did you mean?" suggestion.
85pub fn format_suggestion(similar: &[&str]) -> Option<String> {
86    match similar.len() {
87        0 => None,
88        1 => Some(format!("Did you mean '{}'?", similar[0])),
89        2 => Some(format!(
90            "Did you mean '{}' or '{}'?",
91            similar[0], similar[1]
92        )),
93        _ => Some(format!(
94            "Did you mean '{}', '{}', or '{}'?",
95            similar[0], similar[1], similar[2]
96        )),
97    }
98}
99
100/// Suggestion for an undefined variable.
101pub fn suggest_variable(
102    undefined_name: &str,
103    available_names: impl IntoIterator<Item = impl AsRef<str>>,
104) -> Option<String> {
105    let available: Vec<String> = available_names
106        .into_iter()
107        .map(|s| s.as_ref().to_string())
108        .collect();
109    let max_dist = reasonable_max_distance(undefined_name);
110    let similar = find_similar(
111        available.iter().map(|s| s.as_str()),
112        undefined_name,
113        max_dist,
114    );
115    format_suggestion(&similar)
116}
117
118/// Suggestion for an undefined function.
119pub fn suggest_function(
120    undefined_name: &str,
121    available_functions: impl IntoIterator<Item = impl AsRef<str>>,
122) -> Option<String> {
123    let available: Vec<String> = available_functions
124        .into_iter()
125        .map(|s| s.as_ref().to_string())
126        .collect();
127    let max_dist = reasonable_max_distance(undefined_name);
128    let similar = find_similar(
129        available.iter().map(|s| s.as_str()),
130        undefined_name,
131        max_dist,
132    );
133    format_suggestion(&similar)
134}
135
136/// Suggestion for an unknown property.
137pub fn suggest_property(
138    unknown_prop: &str,
139    available_props: impl IntoIterator<Item = impl AsRef<str>>,
140) -> Option<String> {
141    let available: Vec<String> = available_props
142        .into_iter()
143        .map(|s| s.as_ref().to_string())
144        .collect();
145    let max_dist = reasonable_max_distance(unknown_prop);
146    let similar = find_similar(available.iter().map(|s| s.as_str()), unknown_prop, max_dist);
147    format_suggestion(&similar)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_levenshtein_same_string() {
156        assert_eq!(levenshtein_distance("hello", "hello"), 0);
157    }
158
159    #[test]
160    fn test_levenshtein_empty_strings() {
161        assert_eq!(levenshtein_distance("", ""), 0);
162        assert_eq!(levenshtein_distance("hello", ""), 5);
163        assert_eq!(levenshtein_distance("", "world"), 5);
164    }
165
166    #[test]
167    fn test_levenshtein_single_edit() {
168        // Substitution
169        assert_eq!(levenshtein_distance("hello", "hallo"), 1);
170        // Insertion
171        assert_eq!(levenshtein_distance("hello", "helloo"), 1);
172        // Deletion
173        assert_eq!(levenshtein_distance("hello", "helo"), 1);
174    }
175
176    #[test]
177    fn test_levenshtein_multiple_edits() {
178        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
179        assert_eq!(levenshtein_distance("saturday", "sunday"), 3);
180    }
181
182    #[test]
183    fn test_find_similar() {
184        let candidates = vec!["count", "counter", "amount", "account", "mount"];
185        let similar = find_similar(candidates.iter().copied(), "cont", 2);
186        assert!(similar.contains(&"count"));
187    }
188
189    #[test]
190    fn test_find_similar_no_matches() {
191        let candidates = vec!["apple", "banana", "cherry"];
192        let similar = find_similar(candidates.iter().copied(), "xyz", 2);
193        assert!(similar.is_empty());
194    }
195
196    #[test]
197    fn test_format_suggestion_single() {
198        let similar = vec!["count"];
199        assert_eq!(
200            format_suggestion(&similar),
201            Some("Did you mean 'count'?".to_string())
202        );
203    }
204
205    #[test]
206    fn test_format_suggestion_multiple() {
207        let similar = vec!["count", "counter"];
208        assert_eq!(
209            format_suggestion(&similar),
210            Some("Did you mean 'count' or 'counter'?".to_string())
211        );
212    }
213
214    #[test]
215    fn test_suggest_variable() {
216        let available = vec!["count", "counter", "total"];
217        let suggestion = suggest_variable("cont", available);
218        assert!(suggestion.is_some());
219        assert!(suggestion.unwrap().contains("count"));
220    }
221
222    #[test]
223    fn test_reasonable_max_distance() {
224        assert_eq!(reasonable_max_distance("a"), 1);
225        assert_eq!(reasonable_max_distance("ab"), 1);
226        assert_eq!(reasonable_max_distance("abc"), 2);
227        assert_eq!(reasonable_max_distance("hello"), 2);
228        assert_eq!(reasonable_max_distance("variable"), 3);
229    }
230}