use crate::error::IdlSearchError;
use crate::types::IdlSpec;
use crate::types::{IdlAccount, IdlEvent, IdlInstruction, IdlTypeDef};
use strsim::levenshtein;
#[derive(Debug, Clone)]
pub struct Suggestion {
pub candidate: String,
pub distance: usize,
}
#[derive(Debug, Clone)]
pub enum IdlSection {
Instruction,
Account,
Type,
Error,
Event,
Constant,
}
#[derive(Debug, Clone)]
pub enum MatchType {
Exact,
CaseInsensitive,
Contains,
Fuzzy(usize),
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub name: String,
pub section: IdlSection,
pub match_type: MatchType,
pub path: Option<String>,
pub parent_name: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstructionFieldKind {
Account,
Arg,
}
#[derive(Debug, Clone, Copy)]
pub struct InstructionFieldLookup<'a> {
pub instruction: &'a IdlInstruction,
pub kind: InstructionFieldKind,
}
fn build_not_found_error(input: &str, section: String, available: Vec<String>) -> IdlSearchError {
let candidate_refs: Vec<&str> = available.iter().map(String::as_str).collect();
let suggestions = suggest_similar(input, &candidate_refs, 3);
IdlSearchError::NotFound {
input: input.to_string(),
section,
suggestions,
available,
}
}
pub fn lookup_instruction<'a>(
idl: &'a IdlSpec,
instruction_name: &str,
) -> Result<&'a IdlInstruction, IdlSearchError> {
let available: Vec<String> = idl.instructions.iter().map(|ix| ix.name.clone()).collect();
idl.instructions
.iter()
.find(|ix| ix.name.eq_ignore_ascii_case(instruction_name))
.ok_or_else(|| {
build_not_found_error(instruction_name, "instructions".to_string(), available)
})
}
pub fn lookup_account<'a>(
idl: &'a IdlSpec,
account_name: &str,
) -> Result<&'a IdlAccount, IdlSearchError> {
let available: Vec<String> = idl
.accounts
.iter()
.map(|account| account.name.clone())
.collect();
idl.accounts
.iter()
.find(|account| account.name.eq_ignore_ascii_case(account_name))
.ok_or_else(|| build_not_found_error(account_name, "accounts".to_string(), available))
}
pub fn lookup_type<'a>(
idl: &'a IdlSpec,
type_name: &str,
) -> Result<&'a IdlTypeDef, IdlSearchError> {
let available: Vec<String> = idl.types.iter().map(|ty| ty.name.clone()).collect();
idl.types
.iter()
.find(|ty| ty.name.eq_ignore_ascii_case(type_name))
.ok_or_else(|| build_not_found_error(type_name, "types".to_string(), available))
}
pub fn lookup_event<'a>(
idl: &'a IdlSpec,
event_name: &str,
) -> Result<&'a IdlEvent, IdlSearchError> {
let available: Vec<String> = idl.events.iter().map(|event| event.name.clone()).collect();
idl.events
.iter()
.find(|event| event.name.eq_ignore_ascii_case(event_name))
.ok_or_else(|| build_not_found_error(event_name, "events".to_string(), available))
}
pub fn lookup_event_field(
idl: &IdlSpec,
event_name: &str,
field_name: &str,
) -> Result<(), IdlSearchError> {
let _event = lookup_event(idl, event_name)?;
let fields = idl
.resolve_event_fields(event_name)
.map(|resolved| resolved.fields)
.unwrap_or_default();
if fields.is_empty() {
return Ok(());
}
if fields
.iter()
.any(|field| field.name.eq_ignore_ascii_case(field_name))
{
return Ok(());
}
let available: Vec<String> = fields.iter().map(|field| field.name.clone()).collect();
Err(build_not_found_error(
field_name,
format!("event fields for '{}'", event_name),
available,
))
}
pub fn lookup_instruction_field<'a>(
idl: &'a IdlSpec,
instruction_name: &str,
field_name: &str,
) -> Result<InstructionFieldLookup<'a>, IdlSearchError> {
let instruction = lookup_instruction(idl, instruction_name)?;
if instruction
.accounts
.iter()
.any(|account| account.name.eq_ignore_ascii_case(field_name))
{
return Ok(InstructionFieldLookup {
instruction,
kind: InstructionFieldKind::Account,
});
}
if instruction
.args
.iter()
.any(|arg| arg.name.eq_ignore_ascii_case(field_name))
{
return Ok(InstructionFieldLookup {
instruction,
kind: InstructionFieldKind::Arg,
});
}
let mut available: Vec<String> = instruction
.accounts
.iter()
.map(|acc| acc.name.clone())
.collect();
available.extend(instruction.args.iter().map(|arg| arg.name.clone()));
Err(build_not_found_error(
field_name,
format!("instruction fields for '{}'", instruction.name),
available,
))
}
pub fn suggest_similar(name: &str, candidates: &[&str], max_distance: usize) -> Vec<Suggestion> {
let name_lower = name.to_lowercase();
let mut suggestions: Vec<Suggestion> = candidates
.iter()
.filter_map(|&candidate| {
if candidate == name {
return None;
}
let candidate_lower = candidate.to_lowercase();
if candidate_lower == name_lower {
return Some(Suggestion {
candidate: candidate.to_string(),
distance: 0,
});
}
if candidate_lower.contains(&name_lower) || name_lower.contains(&candidate_lower) {
return Some(Suggestion {
candidate: candidate.to_string(),
distance: 1,
});
}
let dist = levenshtein(name, candidate);
if dist <= max_distance {
Some(Suggestion {
candidate: candidate.to_string(),
distance: dist,
})
} else {
None
}
})
.collect();
suggestions.sort_by_key(|s| s.distance);
suggestions
}
pub fn search_idl(idl: &IdlSpec, query: &str) -> Vec<SearchResult> {
let mut results = Vec::new();
let q = query.to_lowercase();
for ix in &idl.instructions {
if ix.name.to_lowercase().contains(&q) {
results.push(SearchResult {
name: ix.name.clone(),
section: IdlSection::Instruction,
match_type: MatchType::Contains,
path: None,
parent_name: None,
});
}
}
for acc in &idl.accounts {
if acc.name.to_lowercase().contains(&q) {
results.push(SearchResult {
name: acc.name.clone(),
section: IdlSection::Account,
match_type: MatchType::Contains,
path: None,
parent_name: None,
});
}
}
for ty in &idl.types {
if ty.name.to_lowercase().contains(&q) {
results.push(SearchResult {
name: ty.name.clone(),
section: IdlSection::Type,
match_type: MatchType::Contains,
path: None,
parent_name: None,
});
}
}
for err in &idl.errors {
if err.name.to_lowercase().contains(&q) {
results.push(SearchResult {
name: err.name.clone(),
section: IdlSection::Error,
match_type: MatchType::Contains,
path: None,
parent_name: None,
});
}
}
for ev in &idl.events {
let name_match = ev.name.to_lowercase().contains(&q);
let docs_match = ev.docs.iter().any(|doc| doc.to_lowercase().contains(&q));
if name_match || docs_match {
results.push(SearchResult {
name: ev.name.clone(),
section: IdlSection::Event,
match_type: MatchType::Contains,
path: None,
parent_name: None,
});
}
let resolved = idl.resolve_event_fields_for(ev);
for field in resolved.fields {
if field.name.to_lowercase().contains(&q) {
results.push(SearchResult {
name: field.name.clone(),
section: IdlSection::Event,
match_type: MatchType::Contains,
path: Some(format!("{}.{}", ev.name, field.name)),
parent_name: Some(ev.name.clone()),
});
}
}
}
for c in &idl.constants {
if c.name.to_lowercase().contains(&q) {
results.push(SearchResult {
name: c.name.clone(),
section: IdlSection::Constant,
match_type: MatchType::Contains,
path: None,
parent_name: None,
});
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fuzzy_suggestions() {
let candidates = ["initialize", "close", "deposit"];
let suggestions = suggest_similar("initlize", &candidates, 3);
assert!(!suggestions.is_empty());
assert_eq!(suggestions[0].candidate, "initialize");
}
#[test]
fn test_fuzzy_case_insensitive() {
let candidates = ["Initialize", "close"];
let suggestions = suggest_similar("initialize", &candidates, 3);
assert!(!suggestions.is_empty());
assert_eq!(suggestions[0].candidate, "Initialize");
assert_eq!(suggestions[0].distance, 0);
}
#[test]
fn test_fuzzy_no_exact_match() {
let candidates = ["initialize"];
let suggestions = suggest_similar("initialize", &candidates, 3);
assert!(suggestions.is_empty(), "exact matches should be excluded");
}
#[test]
fn test_fuzzy_substring() {
let candidates = ["swap_exact_in", "close"];
let suggestions = suggest_similar("swap", &candidates, 3);
assert!(!suggestions.is_empty());
assert_eq!(suggestions[0].candidate, "swap_exact_in");
}
#[test]
fn test_search_idl() {
use crate::parse::parse_idl_file;
use std::path::PathBuf;
let path =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/meteora_dlmm.json");
let idl = parse_idl_file(&path).expect("should parse");
let results = search_idl(&idl, "swap");
assert!(!results.is_empty(), "should find results for 'swap'");
}
#[test]
fn test_search_idl_finds_event_fields() {
use crate::parse::parse_idl_content;
let idl = parse_idl_content(
r#"{
"name": "fake",
"instructions": [],
"accounts": [],
"types": [
{
"name": "TradeExecuted",
"type": {
"kind": "struct",
"fields": [
{ "name": "periodStartTs", "type": "i64" },
{ "name": "amount", "type": "u64" }
]
}
}
],
"events": [
{
"name": "TradeExecuted",
"discriminator": [1],
"data": { "name": "TradeExecuted" }
}
],
"errors": [],
"constants": []
}"#,
)
.expect("test IDL should parse");
let results = search_idl(&idl, "periodStartTs");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "periodStartTs");
assert_eq!(results[0].parent_name.as_deref(), Some("TradeExecuted"));
assert_eq!(
results[0].path.as_deref(),
Some("TradeExecuted.periodStartTs")
);
}
#[test]
fn test_lookup_instruction_with_suggestion() {
use crate::parse::parse_idl_file;
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
let idl = parse_idl_file(&path).expect("should parse");
let error = lookup_instruction(&idl, "initialise").expect_err("lookup should fail");
match error {
IdlSearchError::NotFound { suggestions, .. } => {
assert_eq!(suggestions[0].candidate, "initialize");
}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn test_lookup_instruction_field_with_suggestion() {
use crate::parse::parse_idl_file;
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
let idl = parse_idl_file(&path).expect("should parse");
let error = lookup_instruction_field(&idl, "buy", "usr").expect_err("lookup should fail");
match error {
IdlSearchError::NotFound { suggestions, .. } => {
assert_eq!(suggestions[0].candidate, "user");
}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn test_lookup_event_field_uses_matching_type_definition() {
use crate::parse::parse_idl_content;
let idl = parse_idl_content(
r#"{
"name": "fake",
"instructions": [],
"accounts": [],
"types": [
{
"name": "TradeExecuted",
"type": {
"kind": "struct",
"fields": [
{ "name": "user", "type": "string" },
{ "name": "amount", "type": "u64" }
]
}
}
],
"events": [
{
"name": "TradeExecuted",
"discriminator": [1]
}
],
"errors": [],
"constants": []
}"#,
)
.expect("test IDL should parse");
lookup_event_field(&idl, "TradeExecuted", "amount")
.expect("matching type definition should provide event fields");
}
#[test]
fn test_lookup_event_field_with_suggestion() {
use crate::parse::parse_idl_content;
let idl = parse_idl_content(
r#"{
"name": "fake",
"instructions": [],
"accounts": [],
"types": [
{
"name": "TradeExecuted",
"type": {
"kind": "struct",
"fields": [
{ "name": "user", "type": "string" },
{ "name": "amount", "type": "u64" }
]
}
}
],
"events": [
{
"name": "TradeExecuted",
"discriminator": [1]
}
],
"errors": [],
"constants": []
}"#,
)
.expect("test IDL should parse");
let error =
lookup_event_field(&idl, "TradeExecuted", "ammount").expect_err("lookup should fail");
match error {
IdlSearchError::NotFound { suggestions, .. } => {
assert_eq!(suggestions[0].candidate, "amount");
}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn test_lookup_account_success() {
use crate::parse::parse_idl_file;
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
let idl = parse_idl_file(&path).expect("should parse");
let account = lookup_account(&idl, "BondingCurve").expect("account should exist");
assert_eq!(account.name, "BondingCurve");
}
#[test]
fn test_lookup_instruction_case_insensitive() {
use crate::parse::parse_idl_file;
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
let idl = parse_idl_file(&path).expect("should parse");
let instruction = lookup_instruction(&idl, "Buy").expect("should match case-insensitively");
assert_eq!(instruction.name, "buy");
}
#[test]
fn test_lookup_account_case_insensitive() {
use crate::parse::parse_idl_file;
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json");
let idl = parse_idl_file(&path).expect("should parse");
let account =
lookup_account(&idl, "bondingCurve").expect("should match case-insensitively");
assert_eq!(account.name, "BondingCurve");
}
}