Skip to main content

hyperstack_idl/
search.rs

1//! Search utilities for IDL specs with fuzzy matching
2
3use crate::error::IdlSearchError;
4use crate::types::IdlSpec;
5use crate::types::{IdlAccount, IdlInstruction, IdlTypeDef};
6use strsim::levenshtein;
7
8/// A fuzzy match suggestion with candidate name and edit distance.
9#[derive(Debug, Clone)]
10pub struct Suggestion {
11    pub candidate: String,
12    pub distance: usize,
13}
14
15/// Which section of the IDL a search result came from.
16#[derive(Debug, Clone)]
17pub enum IdlSection {
18    Instruction,
19    Account,
20    Type,
21    Error,
22    Event,
23    Constant,
24}
25
26/// How a search result was matched.
27#[derive(Debug, Clone)]
28pub enum MatchType {
29    Exact,
30    CaseInsensitive,
31    Contains,
32    Fuzzy(usize),
33}
34
35/// A single search result from `search_idl`.
36#[derive(Debug, Clone)]
37pub struct SearchResult {
38    pub name: String,
39    pub section: IdlSection,
40    pub match_type: MatchType,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum InstructionFieldKind {
45    Account,
46    Arg,
47}
48
49#[derive(Debug, Clone, Copy)]
50pub struct InstructionFieldLookup<'a> {
51    pub instruction: &'a IdlInstruction,
52    pub kind: InstructionFieldKind,
53}
54
55fn build_not_found_error(input: &str, section: String, available: Vec<String>) -> IdlSearchError {
56    let candidate_refs: Vec<&str> = available.iter().map(String::as_str).collect();
57    let suggestions = suggest_similar(input, &candidate_refs, 3);
58    IdlSearchError::NotFound {
59        input: input.to_string(),
60        section,
61        suggestions,
62        available,
63    }
64}
65
66pub fn lookup_instruction<'a>(
67    idl: &'a IdlSpec,
68    instruction_name: &str,
69) -> Result<&'a IdlInstruction, IdlSearchError> {
70    // Anchor IDLs use snake_case instruction names while Rust SDK paths use
71    // PascalCase; case-insensitive matching bridges the two conventions.
72    let available: Vec<String> = idl.instructions.iter().map(|ix| ix.name.clone()).collect();
73    idl.instructions
74        .iter()
75        .find(|ix| ix.name.eq_ignore_ascii_case(instruction_name))
76        .ok_or_else(|| {
77            build_not_found_error(instruction_name, "instructions".to_string(), available)
78        })
79}
80
81pub fn lookup_account<'a>(
82    idl: &'a IdlSpec,
83    account_name: &str,
84) -> Result<&'a IdlAccount, IdlSearchError> {
85    // Account names are PascalCase in both Rust and IDLs, so case-insensitive
86    // matching bridges minor casing differences across IDL versions.
87    let available: Vec<String> = idl
88        .accounts
89        .iter()
90        .map(|account| account.name.clone())
91        .collect();
92    idl.accounts
93        .iter()
94        .find(|account| account.name.eq_ignore_ascii_case(account_name))
95        .ok_or_else(|| build_not_found_error(account_name, "accounts".to_string(), available))
96}
97
98pub fn lookup_type<'a>(
99    idl: &'a IdlSpec,
100    type_name: &str,
101) -> Result<&'a IdlTypeDef, IdlSearchError> {
102    let available: Vec<String> = idl.types.iter().map(|ty| ty.name.clone()).collect();
103    idl.types
104        .iter()
105        .find(|ty| ty.name.eq_ignore_ascii_case(type_name))
106        .ok_or_else(|| build_not_found_error(type_name, "types".to_string(), available))
107}
108
109pub fn lookup_instruction_field<'a>(
110    idl: &'a IdlSpec,
111    instruction_name: &str,
112    field_name: &str,
113) -> Result<InstructionFieldLookup<'a>, IdlSearchError> {
114    let instruction = lookup_instruction(idl, instruction_name)?;
115    // Use case-insensitive matching to stay consistent with lookup_instruction.
116    if instruction
117        .accounts
118        .iter()
119        .any(|account| account.name.eq_ignore_ascii_case(field_name))
120    {
121        return Ok(InstructionFieldLookup {
122            instruction,
123            kind: InstructionFieldKind::Account,
124        });
125    }
126
127    if instruction
128        .args
129        .iter()
130        .any(|arg| arg.name.eq_ignore_ascii_case(field_name))
131    {
132        return Ok(InstructionFieldLookup {
133            instruction,
134            kind: InstructionFieldKind::Arg,
135        });
136    }
137
138    let mut available: Vec<String> = instruction
139        .accounts
140        .iter()
141        .map(|acc| acc.name.clone())
142        .collect();
143    available.extend(instruction.args.iter().map(|arg| arg.name.clone()));
144    Err(build_not_found_error(
145        field_name,
146        format!("instruction fields for '{}'", instruction.name),
147        available,
148    ))
149}
150
151/// Suggest similar names from a list of candidates using fuzzy matching.
152///
153/// Returns candidates sorted by edit distance (closest first).
154/// Exact matches are excluded. Case-insensitive matches get distance 0,
155/// substring matches get distance 1, and Levenshtein matches use their
156/// actual edit distance.
157pub fn suggest_similar(name: &str, candidates: &[&str], max_distance: usize) -> Vec<Suggestion> {
158    let name_lower = name.to_lowercase();
159    let mut suggestions: Vec<Suggestion> = candidates
160        .iter()
161        .filter_map(|&candidate| {
162            // Skip exact matches
163            if candidate == name {
164                return None;
165            }
166            let candidate_lower = candidate.to_lowercase();
167            // Case-insensitive match
168            if candidate_lower == name_lower {
169                return Some(Suggestion {
170                    candidate: candidate.to_string(),
171                    distance: 0,
172                });
173            }
174            // Substring match
175            if candidate_lower.contains(&name_lower) || name_lower.contains(&candidate_lower) {
176                return Some(Suggestion {
177                    candidate: candidate.to_string(),
178                    distance: 1,
179                });
180            }
181            // Levenshtein distance
182            let dist = levenshtein(name, candidate);
183            if dist <= max_distance {
184                Some(Suggestion {
185                    candidate: candidate.to_string(),
186                    distance: dist,
187                })
188            } else {
189                None
190            }
191        })
192        .collect();
193    suggestions.sort_by_key(|s| s.distance);
194    suggestions
195}
196
197/// Search across all sections of an IDL spec for names matching the query.
198///
199/// Performs case-insensitive substring matching against instruction names,
200/// account names, type names, error names, event names, and constant names.
201pub fn search_idl(idl: &IdlSpec, query: &str) -> Vec<SearchResult> {
202    let mut results = Vec::new();
203    let q = query.to_lowercase();
204
205    for ix in &idl.instructions {
206        if ix.name.to_lowercase().contains(&q) {
207            results.push(SearchResult {
208                name: ix.name.clone(),
209                section: IdlSection::Instruction,
210                match_type: MatchType::Contains,
211            });
212        }
213    }
214    for acc in &idl.accounts {
215        if acc.name.to_lowercase().contains(&q) {
216            results.push(SearchResult {
217                name: acc.name.clone(),
218                section: IdlSection::Account,
219                match_type: MatchType::Contains,
220            });
221        }
222    }
223    for ty in &idl.types {
224        if ty.name.to_lowercase().contains(&q) {
225            results.push(SearchResult {
226                name: ty.name.clone(),
227                section: IdlSection::Type,
228                match_type: MatchType::Contains,
229            });
230        }
231    }
232    for err in &idl.errors {
233        if err.name.to_lowercase().contains(&q) {
234            results.push(SearchResult {
235                name: err.name.clone(),
236                section: IdlSection::Error,
237                match_type: MatchType::Contains,
238            });
239        }
240    }
241    for ev in &idl.events {
242        if ev.name.to_lowercase().contains(&q) {
243            results.push(SearchResult {
244                name: ev.name.clone(),
245                section: IdlSection::Event,
246                match_type: MatchType::Contains,
247            });
248        }
249    }
250    for c in &idl.constants {
251        if c.name.to_lowercase().contains(&q) {
252            results.push(SearchResult {
253                name: c.name.clone(),
254                section: IdlSection::Constant,
255                match_type: MatchType::Contains,
256            });
257        }
258    }
259    results
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_fuzzy_suggestions() {
268        let candidates = ["initialize", "close", "deposit"];
269        let suggestions = suggest_similar("initlize", &candidates, 3);
270        assert!(!suggestions.is_empty());
271        assert_eq!(suggestions[0].candidate, "initialize");
272    }
273
274    #[test]
275    fn test_fuzzy_case_insensitive() {
276        let candidates = ["Initialize", "close"];
277        let suggestions = suggest_similar("initialize", &candidates, 3);
278        assert!(!suggestions.is_empty());
279        assert_eq!(suggestions[0].candidate, "Initialize");
280        assert_eq!(suggestions[0].distance, 0);
281    }
282
283    #[test]
284    fn test_fuzzy_no_exact_match() {
285        let candidates = ["initialize"];
286        let suggestions = suggest_similar("initialize", &candidates, 3);
287        assert!(suggestions.is_empty(), "exact matches should be excluded");
288    }
289
290    #[test]
291    fn test_fuzzy_substring() {
292        let candidates = ["swap_exact_in", "close"];
293        let suggestions = suggest_similar("swap", &candidates, 3);
294        assert!(!suggestions.is_empty());
295        assert_eq!(suggestions[0].candidate, "swap_exact_in");
296    }
297
298    #[test]
299    fn test_search_idl() {
300        use crate::parse::parse_idl_file;
301        use std::path::PathBuf;
302        let path =
303            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/meteora_dlmm.json");
304        let idl = parse_idl_file(&path).expect("should parse");
305        let results = search_idl(&idl, "swap");
306        assert!(!results.is_empty(), "should find results for 'swap'");
307    }
308
309    #[test]
310    fn test_lookup_instruction_with_suggestion() {
311        use crate::parse::parse_idl_file;
312        use std::path::PathBuf;
313
314        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
315        let idl = parse_idl_file(&path).expect("should parse");
316
317        let error = lookup_instruction(&idl, "initialise").expect_err("lookup should fail");
318        match error {
319            IdlSearchError::NotFound { suggestions, .. } => {
320                assert_eq!(suggestions[0].candidate, "initialize");
321            }
322            other => panic!("expected NotFound, got {other:?}"),
323        }
324    }
325
326    #[test]
327    fn test_lookup_instruction_field_with_suggestion() {
328        use crate::parse::parse_idl_file;
329        use std::path::PathBuf;
330
331        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
332        let idl = parse_idl_file(&path).expect("should parse");
333
334        let error = lookup_instruction_field(&idl, "buy", "usr").expect_err("lookup should fail");
335        match error {
336            IdlSearchError::NotFound { suggestions, .. } => {
337                assert_eq!(suggestions[0].candidate, "user");
338            }
339            other => panic!("expected NotFound, got {other:?}"),
340        }
341    }
342
343    #[test]
344    fn test_lookup_account_success() {
345        use crate::parse::parse_idl_file;
346        use std::path::PathBuf;
347
348        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
349        let idl = parse_idl_file(&path).expect("should parse");
350
351        let account = lookup_account(&idl, "BondingCurve").expect("account should exist");
352        assert_eq!(account.name, "BondingCurve");
353    }
354
355    #[test]
356    fn test_lookup_instruction_case_insensitive() {
357        use crate::parse::parse_idl_file;
358        use std::path::PathBuf;
359
360        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
361        let idl = parse_idl_file(&path).expect("should parse");
362
363        // PascalCase SDK name matches snake_case IDL name
364        let instruction = lookup_instruction(&idl, "Buy").expect("should match case-insensitively");
365        assert_eq!(instruction.name, "buy");
366    }
367
368    #[test]
369    fn test_lookup_account_case_insensitive() {
370        use crate::parse::parse_idl_file;
371        use std::path::PathBuf;
372
373        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
374        let idl = parse_idl_file(&path).expect("should parse");
375
376        let account =
377            lookup_account(&idl, "bondingCurve").expect("should match case-insensitively");
378        assert_eq!(account.name, "BondingCurve");
379    }
380}