Skip to main content

hyperstack_idl/
search.rs

1//! Search utilities for IDL specs with fuzzy matching
2
3use crate::types::IdlSpec;
4use strsim::levenshtein;
5
6/// A fuzzy match suggestion with candidate name and edit distance.
7#[derive(Debug, Clone)]
8pub struct Suggestion {
9    pub candidate: String,
10    pub distance: usize,
11}
12
13/// Which section of the IDL a search result came from.
14#[derive(Debug, Clone)]
15pub enum IdlSection {
16    Instruction,
17    Account,
18    Type,
19    Error,
20    Event,
21    Constant,
22}
23
24/// How a search result was matched.
25#[derive(Debug, Clone)]
26pub enum MatchType {
27    Exact,
28    CaseInsensitive,
29    Contains,
30    Fuzzy(usize),
31}
32
33/// A single search result from `search_idl`.
34#[derive(Debug, Clone)]
35pub struct SearchResult {
36    pub name: String,
37    pub section: IdlSection,
38    pub match_type: MatchType,
39}
40
41/// Suggest similar names from a list of candidates using fuzzy matching.
42///
43/// Returns candidates sorted by edit distance (closest first).
44/// Exact matches are excluded. Case-insensitive matches get distance 0,
45/// substring matches get distance 1, and Levenshtein matches use their
46/// actual edit distance.
47pub fn suggest_similar(name: &str, candidates: &[&str], max_distance: usize) -> Vec<Suggestion> {
48    let name_lower = name.to_lowercase();
49    let mut suggestions: Vec<Suggestion> = candidates
50        .iter()
51        .filter_map(|&candidate| {
52            // Skip exact matches
53            if candidate == name {
54                return None;
55            }
56            let candidate_lower = candidate.to_lowercase();
57            // Case-insensitive match
58            if candidate_lower == name_lower {
59                return Some(Suggestion {
60                    candidate: candidate.to_string(),
61                    distance: 0,
62                });
63            }
64            // Substring match
65            if candidate_lower.contains(&name_lower) || name_lower.contains(&candidate_lower) {
66                return Some(Suggestion {
67                    candidate: candidate.to_string(),
68                    distance: 1,
69                });
70            }
71            // Levenshtein distance
72            let dist = levenshtein(name, candidate);
73            if dist <= max_distance {
74                Some(Suggestion {
75                    candidate: candidate.to_string(),
76                    distance: dist,
77                })
78            } else {
79                None
80            }
81        })
82        .collect();
83    suggestions.sort_by_key(|s| s.distance);
84    suggestions
85}
86
87/// Search across all sections of an IDL spec for names matching the query.
88///
89/// Performs case-insensitive substring matching against instruction names,
90/// account names, type names, error names, event names, and constant names.
91pub fn search_idl(idl: &IdlSpec, query: &str) -> Vec<SearchResult> {
92    let mut results = Vec::new();
93    let q = query.to_lowercase();
94
95    for ix in &idl.instructions {
96        if ix.name.to_lowercase().contains(&q) {
97            results.push(SearchResult {
98                name: ix.name.clone(),
99                section: IdlSection::Instruction,
100                match_type: MatchType::Contains,
101            });
102        }
103    }
104    for acc in &idl.accounts {
105        if acc.name.to_lowercase().contains(&q) {
106            results.push(SearchResult {
107                name: acc.name.clone(),
108                section: IdlSection::Account,
109                match_type: MatchType::Contains,
110            });
111        }
112    }
113    for ty in &idl.types {
114        if ty.name.to_lowercase().contains(&q) {
115            results.push(SearchResult {
116                name: ty.name.clone(),
117                section: IdlSection::Type,
118                match_type: MatchType::Contains,
119            });
120        }
121    }
122    for err in &idl.errors {
123        if err.name.to_lowercase().contains(&q) {
124            results.push(SearchResult {
125                name: err.name.clone(),
126                section: IdlSection::Error,
127                match_type: MatchType::Contains,
128            });
129        }
130    }
131    for ev in &idl.events {
132        if ev.name.to_lowercase().contains(&q) {
133            results.push(SearchResult {
134                name: ev.name.clone(),
135                section: IdlSection::Event,
136                match_type: MatchType::Contains,
137            });
138        }
139    }
140    for c in &idl.constants {
141        if c.name.to_lowercase().contains(&q) {
142            results.push(SearchResult {
143                name: c.name.clone(),
144                section: IdlSection::Constant,
145                match_type: MatchType::Contains,
146            });
147        }
148    }
149    results
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_fuzzy_suggestions() {
158        let candidates = ["initialize", "close", "deposit"];
159        let suggestions = suggest_similar("initlize", &candidates, 3);
160        assert!(!suggestions.is_empty());
161        assert_eq!(suggestions[0].candidate, "initialize");
162    }
163
164    #[test]
165    fn test_fuzzy_case_insensitive() {
166        let candidates = ["Initialize", "close"];
167        let suggestions = suggest_similar("initialize", &candidates, 3);
168        assert!(!suggestions.is_empty());
169        assert_eq!(suggestions[0].candidate, "Initialize");
170        assert_eq!(suggestions[0].distance, 0);
171    }
172
173    #[test]
174    fn test_fuzzy_no_exact_match() {
175        let candidates = ["initialize"];
176        let suggestions = suggest_similar("initialize", &candidates, 3);
177        assert!(suggestions.is_empty(), "exact matches should be excluded");
178    }
179
180    #[test]
181    fn test_fuzzy_substring() {
182        let candidates = ["swap_exact_in", "close"];
183        let suggestions = suggest_similar("swap", &candidates, 3);
184        assert!(!suggestions.is_empty());
185        assert_eq!(suggestions[0].candidate, "swap_exact_in");
186    }
187
188    #[test]
189    fn test_search_idl() {
190        use crate::parse::parse_idl_file;
191        use std::path::PathBuf;
192        let path =
193            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/meteora_dlmm.json");
194        let idl = parse_idl_file(&path).expect("should parse");
195        let results = search_idl(&idl, "swap");
196        assert!(!results.is_empty(), "should find results for 'swap'");
197    }
198}