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