Skip to main content

fuzzy_parser/
distance.rs

1//! String distance/similarity calculation utilities
2//!
3//! This module provides wrappers around strsim algorithms for fuzzy matching.
4
5use strsim::{damerau_levenshtein, jaro_winkler, levenshtein};
6
7/// Algorithm for similarity calculation
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum Algorithm {
10    /// Jaro-Winkler similarity (recommended for typos)
11    ///
12    /// Good for: transpositions, prefix matching
13    /// Returns: 0.0 to 1.0 (1.0 = identical)
14    #[default]
15    JaroWinkler,
16
17    /// Levenshtein distance (edit distance)
18    ///
19    /// Good for: insertions, deletions, substitutions
20    /// Normalized to 0.0 to 1.0 (1.0 = identical)
21    Levenshtein,
22
23    /// Damerau-Levenshtein distance
24    ///
25    /// Like Levenshtein but also handles transpositions
26    /// Normalized to 0.0 to 1.0 (1.0 = identical)
27    DamerauLevenshtein,
28}
29
30/// Calculate similarity between two strings
31///
32/// Returns a value between 0.0 (completely different) and 1.0 (identical).
33///
34/// Levenshtein-based scores are normalized by the character count of the
35/// longer string (not the byte length), because strsim computes edit
36/// distance over `char`s. Using byte length would deflate scores for
37/// multi-byte (non-ASCII) input and could even produce negative values.
38pub fn similarity(a: &str, b: &str, algo: Algorithm) -> f64 {
39    if a == b {
40        return 1.0;
41    }
42    if a.is_empty() || b.is_empty() {
43        return 0.0;
44    }
45
46    match algo {
47        Algorithm::JaroWinkler => jaro_winkler(a, b),
48        Algorithm::Levenshtein => {
49            let dist = levenshtein(a, b);
50            let max_len = a.chars().count().max(b.chars().count());
51            1.0 - (dist as f64 / max_len as f64)
52        }
53        Algorithm::DamerauLevenshtein => {
54            let dist = damerau_levenshtein(a, b);
55            let max_len = a.chars().count().max(b.chars().count());
56            1.0 - (dist as f64 / max_len as f64)
57        }
58    }
59}
60
61/// Match result with similarity score
62#[derive(Debug, Clone, PartialEq)]
63pub struct Match {
64    /// The matched candidate string
65    pub candidate: String,
66    /// Similarity score (0.0 to 1.0)
67    pub similarity: f64,
68}
69
70impl Match {
71    /// Create a new match result
72    ///
73    /// Exists so callers (and internal code) can build a `Match` without
74    /// spelling out the `Into<String>` conversion at every call site.
75    pub fn new(candidate: impl Into<String>, similarity: f64) -> Self {
76        Self {
77            candidate: candidate.into(),
78            similarity,
79        }
80    }
81}
82
83/// Find the closest match from a list of candidates
84///
85/// Returns `None` if no candidate meets the minimum similarity threshold.
86pub fn find_closest<'a>(
87    input: &str,
88    candidates: impl IntoIterator<Item = &'a str>,
89    min_similarity: f64,
90    algo: Algorithm,
91) -> Option<Match> {
92    candidates
93        .into_iter()
94        .map(|c| Match::new(c, similarity(input, c, algo)))
95        .filter(|m| m.similarity >= min_similarity)
96        .max_by(|a, b| a.similarity.total_cmp(&b.similarity))
97}
98
99/// Find all matches above the minimum similarity threshold
100///
101/// Returns matches sorted by similarity (highest first).
102pub fn find_all_matches<'a>(
103    input: &str,
104    candidates: impl IntoIterator<Item = &'a str>,
105    min_similarity: f64,
106    algo: Algorithm,
107) -> Vec<Match> {
108    let mut matches: Vec<_> = candidates
109        .into_iter()
110        .map(|c| Match::new(c, similarity(input, c, algo)))
111        .filter(|m| m.similarity >= min_similarity)
112        .collect();
113
114    matches.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
115    matches
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_identical_strings() {
124        assert_eq!(similarity("hello", "hello", Algorithm::JaroWinkler), 1.0);
125        assert_eq!(similarity("hello", "hello", Algorithm::Levenshtein), 1.0);
126        assert_eq!(
127            similarity("hello", "hello", Algorithm::DamerauLevenshtein),
128            1.0
129        );
130    }
131
132    #[test]
133    fn test_empty_strings() {
134        assert_eq!(similarity("", "", Algorithm::JaroWinkler), 1.0);
135        assert_eq!(similarity("hello", "", Algorithm::JaroWinkler), 0.0);
136        assert_eq!(similarity("", "hello", Algorithm::JaroWinkler), 0.0);
137    }
138
139    #[test]
140    fn test_typo_detection_jaro_winkler() {
141        // Typos should have high similarity with Jaro-Winkler
142        let sim = similarity("AddDeriv", "AddDerive", Algorithm::JaroWinkler);
143        assert!(sim > 0.9, "Expected > 0.9, got {}", sim);
144
145        let sim = similarity("RenamIdent", "RenameIdent", Algorithm::JaroWinkler);
146        assert!(sim > 0.9, "Expected > 0.9, got {}", sim);
147    }
148
149    #[test]
150    fn test_field_name_typo() {
151        let sim = similarity("target_name", "target", Algorithm::JaroWinkler);
152        assert!(sim > 0.7, "Expected > 0.7, got {}", sim);
153
154        let sim = similarity("struct_nam", "struct_name", Algorithm::JaroWinkler);
155        assert!(sim > 0.9, "Expected > 0.9, got {}", sim);
156    }
157
158    #[test]
159    fn test_find_closest() {
160        let candidates = ["AddDerive", "RemoveDerive", "AddField", "RemoveField"];
161        let result = find_closest(
162            "AddDeriv",
163            candidates.iter().copied(),
164            0.7,
165            Algorithm::JaroWinkler,
166        );
167
168        assert!(result.is_some());
169        let m = result.unwrap();
170        assert_eq!(m.candidate, "AddDerive");
171        assert!(m.similarity > 0.9);
172    }
173
174    #[test]
175    fn test_find_closest_no_match() {
176        let candidates = ["AddDerive", "RemoveDerive"];
177        let result = find_closest(
178            "CompletelyDifferent",
179            candidates.iter().copied(),
180            0.9, // High threshold
181            Algorithm::JaroWinkler,
182        );
183
184        assert!(result.is_none());
185    }
186
187    #[test]
188    fn test_find_all_matches() {
189        let candidates = ["target", "target_mod", "target_fn", "body"];
190        let matches = find_all_matches(
191            "target_name",
192            candidates.iter().copied(),
193            0.6,
194            Algorithm::JaroWinkler,
195        );
196
197        assert!(!matches.is_empty());
198        // Results should be sorted by similarity (highest first)
199        for i in 1..matches.len() {
200            assert!(matches[i - 1].similarity >= matches[i].similarity);
201        }
202    }
203
204    #[test]
205    fn test_levenshtein_normalization_non_ascii() {
206        // "こんにちは" vs "こんにちわ": 5 chars each, 1 substitution.
207        // Char-based normalization: 1 - 1/5 = 0.8.
208        // Byte-based normalization would give 1 - 1/15 ≈ 0.93 (wrong basis).
209        let sim = similarity("こんにちは", "こんにちわ", Algorithm::Levenshtein);
210        assert!((sim - 0.8).abs() < 1e-9, "Expected 0.8, got {}", sim);
211
212        let sim = similarity("こんにちは", "こんにちわ", Algorithm::DamerauLevenshtein);
213        assert!((sim - 0.8).abs() < 1e-9, "Expected 0.8, got {}", sim);
214    }
215
216    #[test]
217    fn test_levenshtein_non_ascii_completely_different() {
218        // Completely different Japanese strings must not go below 0.0.
219        // With byte-based normalization the score stays artificially high;
220        // with char-based it is exactly 0.0 here (3 chars, distance 3).
221        let sim = similarity("りんご", "みかん", Algorithm::Levenshtein);
222        assert!((0.0..=1.0).contains(&sim), "Score out of range: {}", sim);
223        assert!((sim - 0.0).abs() < 1e-9, "Expected 0.0, got {}", sim);
224    }
225
226    #[test]
227    fn test_transposition_damerau() {
228        // Damerau-Levenshtein handles transpositions better
229        let sim_dl = similarity("teh", "the", Algorithm::DamerauLevenshtein);
230        let sim_l = similarity("teh", "the", Algorithm::Levenshtein);
231        // DL should give same or higher similarity for transpositions
232        assert!(sim_dl >= sim_l);
233    }
234}