1use crate::error::IdlSearchError;
4use crate::types::IdlSpec;
5use crate::types::{IdlAccount, IdlInstruction, IdlTypeDef};
6use strsim::levenshtein;
7
8#[derive(Debug, Clone)]
10pub struct Suggestion {
11 pub candidate: String,
12 pub distance: usize,
13}
14
15#[derive(Debug, Clone)]
17pub enum IdlSection {
18 Instruction,
19 Account,
20 Type,
21 Error,
22 Event,
23 Constant,
24}
25
26#[derive(Debug, Clone)]
28pub enum MatchType {
29 Exact,
30 CaseInsensitive,
31 Contains,
32 Fuzzy(usize),
33}
34
35#[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 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 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 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
151pub 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 if candidate == name {
164 return None;
165 }
166 let candidate_lower = candidate.to_lowercase();
167 if candidate_lower == name_lower {
169 return Some(Suggestion {
170 candidate: candidate.to_string(),
171 distance: 0,
172 });
173 }
174 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 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
197pub 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 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}