1use crate::manifest::{Manifest, ManifestEntry}; use crate::natspec::{
26 extract::SourceItemKind, parse_natspec_comment, NatSpecKind, TextRange,
27};
28use anyhow::{Context, Result};
30use serde::{Deserialize, Serialize};
31use std::{
32 collections::HashMap,
33 fs,
34 path::Path,
35};
36use tracing::debug;
37#[cfg(test)]
38use std::path::PathBuf;
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct BindingConfig {
42 pub key: String,
43 pub contract_name: Option<String>,
44 pub address: Option<String>,
45 pub chain_id: Option<u64>,
46 pub notes: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
50pub struct BindingFile {
51 bindings: Vec<BindingConfig>,
52}
53
54impl BindingFile {
55 pub fn new(bindings: Vec<BindingConfig>) -> Self {
56 Self { bindings }
57 }
58}
59
60#[derive(Debug, Clone, Default)]
61pub struct BindingRegistry {
62 pub bindings: HashMap<String, BindingConfig>, }
64
65impl BindingRegistry {
66 pub fn load(path: &Path) -> Result<Self> {
67 let file_content = fs::read_to_string(path)
68 .with_context(|| format!("Failed to read binding file: {}", path.display()))?;
69 let binding_file: BindingFile = serde_yaml::from_str(&file_content).with_context(|| {
70 format!(
71 "Failed to deserialize binding file from YAML: {}",
72 path.display()
73 )
74 })?;
75
76 let mut bindings_map = HashMap::new();
77 for config in binding_file.bindings {
78 if bindings_map.contains_key(&config.key) {
79 debug!(
80 "Warning: Duplicate binding key '{}' found in {}. The last definition will be used.",
81 config.key,
82 path.display()
83 );
84 }
85 bindings_map.insert(config.key.clone(), config);
86 }
87
88 Ok(BindingRegistry {
89 bindings: bindings_map,
90 })
91 }
92
93 pub fn get_binding(&self, key: &str) -> Option<&BindingConfig> {
94 self.bindings.get(key)
95 }
96
97 pub fn populate_from_manifest(&mut self, manifest: &Manifest) {
100 for entry in &manifest.entries {
101 if !entry.is_natspec || entry.item_kind != SourceItemKind::Contract {
102 continue;
103 }
104
105 if let Ok(natspec) = parse_natspec_comment(&entry.text) {
106 for item in natspec.items {
107 if let NatSpecKind::Custom { tag } = item.kind {
108 if tag == "binds-to" {
109 let binding_key = item.comment.trim();
110 if binding_key.is_empty() {
111 debug!(
112 "Warning: Found '@custom:binds-to' with empty key in Natspec for item {:?} in file {}",
113 entry.item_name, entry.file_path.display()
114 );
115 continue;
116 }
117
118 if let Some(contract_name_val) = &entry.item_name {
119 let config = self
120 .bindings
121 .entry(binding_key.to_string())
122 .or_insert_with(|| {
123 debug!(
124 "Natspec: Creating new binding for key '{}' from contract '{}'",
125 binding_key, contract_name_val
126 );
127 BindingConfig {
128 key: binding_key.to_string(),
129 contract_name: None, address: None,
131 chain_id: None,
132 notes: Some(format!(
133 "Binding key '{}' initially found on contract '{}' via Natspec.",
134 binding_key, contract_name_val
135 )),
136 }
137 });
138
139 if config.contract_name.is_some()
140 && config.contract_name.as_deref() != Some(contract_name_val)
141 {
142 debug!(
143 "Warning: Overwriting Natspec-derived binding for key '{}'. \
144 Previous contract_name: {:?}, New (from contract '{}'): '{}'. \
145 File: {}",
146 binding_key,
147 config.contract_name,
148 contract_name_val,
149 contract_name_val,
150 entry.file_path.display()
151 );
152 } else if config.contract_name.is_none() {
153 debug!(
154 "Natspec: Setting contract_name for key '{}' to '{}' from contract '{}'",
155 binding_key, contract_name_val, contract_name_val
156 );
157 }
158
159
160 config.contract_name = Some(contract_name_val.clone());
161 config.notes = Some(format!(
163 "Binding key '{}' concretely implemented by contract '{}' (derived from Natspec on the contract itself in {}).{}",
164 binding_key,
165 contract_name_val,
166 entry.file_path.display(),
167 config.notes.as_ref().map_or("".to_string(), |n| format!(" Previous notes: {}", n))
168 ));
169 }
170 break; }
172 }
173 }
174 }
175 }
176 }
177}
178
179pub struct InterfaceResolver<'m, 'r> {
180 manifest: &'m Manifest,
181 registry: &'r BindingRegistry,
182}
183
184impl<'m, 'r> InterfaceResolver<'m, 'r> {
185 pub fn new(manifest: &'m Manifest, registry: &'r BindingRegistry) -> Self {
186 Self { manifest, registry }
187 }
188
189 pub fn resolve_for_entry(&self, entry: &ManifestEntry) -> Result<Option<&'r BindingConfig>> {
190 if !entry.is_natspec {
191 return Ok(None);
192 }
193
194 match parse_natspec_comment(&entry.text) {
195 Ok(natspec) => {
196 for item in natspec.items {
197 if let NatSpecKind::Custom { tag } = item.kind {
198 if tag == "binds-to" {
199 let binding_key = item.comment.trim();
200 if !binding_key.is_empty() {
201 return Ok(self.registry.get_binding(binding_key));
202 } else {
203 debug!(
204 "Warning: Found '@custom:binds-to' with empty key in Natspec for item {:?} in file {}",
205 entry.item_name, entry.file_path.display()
206 );
207 }
208 }
209 }
210 }
211 Ok(None)
212 }
213 Err(e) => Err(anyhow::anyhow!(
214 "Failed to parse Natspec for item {:?} (file: {}): {}",
215 entry.item_name.as_deref().unwrap_or("<unknown>"),
216 entry.file_path.display(),
217 e.to_string()
218 )),
219 }
220 }
221
222 pub fn resolve_for_item_span(
223 &self,
224 target_item_span: &TextRange,
225 ) -> Result<Option<&'r BindingConfig>> {
226 for entry in &self.manifest.entries {
227 if entry.item_span == *target_item_span {
228 return self.resolve_for_entry(entry);
229 }
230 }
231 Ok(None)
232 }
233
234 pub fn resolve_for_item_name_kind(
235 &self,
236 item_name_to_find: &str,
237 item_kind_to_find: SourceItemKind,
238 file_path_hint: Option<&Path>,
239 ) -> Result<Option<&'r BindingConfig>> {
240 for entry in &self.manifest.entries {
241 if entry.item_kind == item_kind_to_find
242 && entry.item_name.as_deref() == Some(item_name_to_find)
243 {
244 if let Some(hint_path) = file_path_hint {
245 if entry.file_path != hint_path {
246 continue;
247 }
248 }
249 return self.resolve_for_entry(entry);
250 }
251 }
252 Ok(None)
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::manifest::ManifestEntry; use crate::natspec::extract::SourceItemKind;
261 use crate::natspec::{TextIndex, TextRange};
262 use std::fs::File;
263 use std::io::Write;
264 use tempfile::tempdir;
265
266 fn create_binding_file(dir: &Path, content: &str) -> PathBuf {
267 let file_path = dir.join("binding.yaml");
268 let mut file = File::create(&file_path).unwrap();
269 writeln!(file, "{}", content).unwrap();
270 file_path
271 }
272
273 fn default_text_range() -> TextRange {
274 TextRange {
275 start: TextIndex {
276 utf8: 0,
277 line: 0,
278 column: 0,
279 },
280 end: TextIndex {
281 utf8: 0,
282 line: 0,
283 column: 0,
284 },
285 }
286 }
287
288 #[test]
289 fn test_load_binding_registry() -> Result<()> {
290 let tmp_dir = tempdir()?;
291 let yaml_content = r#"
292bindings:
293 - key: "USDC_Mainnet"
294 contract_name: "FiatTokenProxy"
295 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
296 chain_id: 1
297 notes: "USDC on Ethereum Mainnet"
298 - key: "MyService_Test"
299 contract_name: "MyServiceImpl"
300 address: "0x1234567890abcdef1234567890abcdef12345678"
301 chain_id: 4
302 notes: "MyService on Rinkeby (test)"
303"#;
304 let binding_file_path = create_binding_file(tmp_dir.path(), yaml_content);
305 let registry = BindingRegistry::load(&binding_file_path)?;
306
307 assert!(registry.get_binding("USDC_Mainnet").is_some());
308 assert_eq!(
309 registry.get_binding("USDC_Mainnet").unwrap().address,
310 Some("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string())
311 );
312 assert_eq!(
313 registry.get_binding("USDC_Mainnet").unwrap().chain_id,
314 Some(1)
315 );
316
317 assert!(registry.get_binding("MyService_Test").is_some());
318 assert_eq!(
319 registry
320 .get_binding("MyService_Test")
321 .unwrap()
322 .contract_name,
323 Some("MyServiceImpl".to_string())
324 );
325
326 assert!(registry.get_binding("NonExistentKey").is_none());
327 Ok(())
328 }
329
330 #[test]
331 fn test_load_binding_registry_duplicate_keys() -> Result<()> {
332 let tmp_dir = tempdir()?;
333 let yaml_content = r#"
334bindings:
335 - key: "DuplicateKey"
336 address: "0x111"
337 - key: "UniqueKey"
338 address: "0x222"
339 - key: "DuplicateKey" # This should overwrite the first one
340 address: "0x333"
341 notes: "This is the one"
342"#;
343 let binding_file_path = create_binding_file(tmp_dir.path(), yaml_content);
344 let registry = BindingRegistry::load(&binding_file_path)?;
345
346 assert_eq!(registry.bindings.len(), 2); let duplicate_entry = registry.get_binding("DuplicateKey").unwrap();
348 assert_eq!(duplicate_entry.address, Some("0x333".to_string()));
349 assert_eq!(duplicate_entry.notes, Some("This is the one".to_string()));
350 Ok(())
351 }
352
353 #[test]
354 fn test_resolve_for_entry_success() -> Result<()> {
355 let registry = BindingRegistry {
356 bindings: HashMap::from([(
357 "BoundContract_Key".to_string(),
358 BindingConfig {
359 key: "BoundContract_Key".to_string(),
360 contract_name: Some("BoundContractImpl".to_string()),
361 address: Some("0xabc".to_string()),
362 chain_id: Some(1),
363 notes: None,
364 },
365 )]),
366 };
367 let manifest = Manifest::default(); let entry = ManifestEntry {
370 file_path: PathBuf::from("test.sol"),
371 text: "/// @custom:binds-to BoundContract_Key".to_string(),
372 raw_comment_span: default_text_range(),
373 item_kind: SourceItemKind::Contract,
374 item_name: Some("MyContract".to_string()),
375 item_span: default_text_range(),
376 is_natspec: true,
377 };
378
379 let resolver = InterfaceResolver::new(&manifest, ®istry);
380 let binding_config = resolver.resolve_for_entry(&entry)?.unwrap();
381
382 assert_eq!(binding_config.key, "BoundContract_Key");
383 assert_eq!(
384 binding_config.contract_name,
385 Some("BoundContractImpl".to_string())
386 );
387 Ok(())
388 }
389
390 #[test]
391 fn test_resolve_for_entry_multiline_natspec() -> Result<()> {
392 let registry = BindingRegistry {
393 bindings: HashMap::from([(
394 "MultiKey".to_string(),
395 BindingConfig {
396 key: "MultiKey".to_string(),
397 contract_name: Some("MultiImpl".to_string()),
398 address: None,
399 chain_id: None,
400 notes: None,
401 },
402 )]),
403 };
404 let manifest = Manifest::default();
405 let entry = ManifestEntry {
406 file_path: PathBuf::from("test.sol"),
407 text: "/**\n * @notice Some notice\n * @custom:binds-to MultiKey\n * @dev Some dev comment\n */".to_string(),
408 raw_comment_span: default_text_range(),
409 item_kind: SourceItemKind::Function,
410 item_name: Some("doSomething".to_string()),
411 item_span: default_text_range(),
412 is_natspec: true,
413 };
414 let resolver = InterfaceResolver::new(&manifest, ®istry);
415 let binding_config = resolver.resolve_for_entry(&entry)?.unwrap();
416 assert_eq!(binding_config.key, "MultiKey");
417 Ok(())
418 }
419
420 #[test]
421 fn test_resolve_for_entry_no_binding_tag() -> Result<()> {
422 let registry = BindingRegistry::default();
423 let manifest = Manifest::default();
424 let entry = ManifestEntry {
425 file_path: PathBuf::from("test.sol"),
426 text: "/// @notice Just a regular comment".to_string(),
427 raw_comment_span: default_text_range(),
428 item_kind: SourceItemKind::Contract,
429 item_name: Some("MyContract".to_string()),
430 item_span: default_text_range(),
431 is_natspec: true,
432 };
433
434 let resolver = InterfaceResolver::new(&manifest, ®istry);
435 assert!(resolver.resolve_for_entry(&entry)?.is_none());
436 Ok(())
437 }
438
439 #[test]
440 fn test_resolve_for_entry_not_natspec() -> Result<()> {
441 let registry = BindingRegistry::default();
442 let manifest = Manifest::default();
443 let entry = ManifestEntry {
444 file_path: PathBuf::from("test.sol"),
445 text: "// Regular comment, @custom:binds-to SomeKey".to_string(),
446 raw_comment_span: default_text_range(),
447 item_kind: SourceItemKind::Contract,
448 item_name: Some("MyContract".to_string()),
449 item_span: default_text_range(),
450 is_natspec: false, };
452
453 let resolver = InterfaceResolver::new(&manifest, ®istry);
454 assert!(resolver.resolve_for_entry(&entry)?.is_none());
455 Ok(())
456 }
457
458 #[test]
459 fn test_resolve_for_entry_key_not_in_registry() -> Result<()> {
460 let registry = BindingRegistry::default(); let manifest = Manifest::default();
462 let entry = ManifestEntry {
463 file_path: PathBuf::from("test.sol"),
464 text: "/// @custom:binds-to NonExistentKey".to_string(),
465 raw_comment_span: default_text_range(),
466 item_kind: SourceItemKind::Contract,
467 item_name: Some("MyContract".to_string()),
468 item_span: default_text_range(),
469 is_natspec: true,
470 };
471
472 let resolver = InterfaceResolver::new(&manifest, ®istry);
473 assert!(resolver.resolve_for_entry(&entry)?.is_none());
474 Ok(())
475 }
476
477 #[test]
478 fn test_resolve_for_entry_natspec_parse_error() {
479 let registry = BindingRegistry::default();
480 let manifest = Manifest::default();
481 let entry = ManifestEntry {
482 file_path: PathBuf::from("test.sol"),
483 text: "/*** Invalid Natspec @custom:binds-to SomeKey".to_string(), raw_comment_span: default_text_range(),
485 item_kind: SourceItemKind::Contract,
486 item_name: Some("MyContract".to_string()),
487 item_span: default_text_range(),
488 is_natspec: true,
489 };
490 let resolver = InterfaceResolver::new(&manifest, ®istry);
491 let result = resolver.resolve_for_entry(&entry);
492 assert!(result.is_err());
493 assert!(result
494 .unwrap_err()
495 .to_string()
496 .contains("Failed to parse Natspec"));
497 }
498
499 #[test]
500 fn test_resolve_for_item_span_found() -> Result<()> {
501 let registry = BindingRegistry {
502 bindings: HashMap::from([(
503 "SpanKey".to_string(),
504 BindingConfig {
505 key: "SpanKey".to_string(),
506 address: Some("0xspan".to_string()),
507 contract_name: None,
508 chain_id: None,
509 notes: None,
510 },
511 )]),
512 };
513 let item_span_to_find = TextRange {
514 start: TextIndex {
515 utf8: 10,
516 line: 1,
517 column: 0,
518 },
519 end: TextIndex {
520 utf8: 20,
521 line: 1,
522 column: 10,
523 },
524 };
525 let mut manifest = Manifest::default();
526 manifest.add_entry(ManifestEntry {
527 file_path: PathBuf::from("a.sol"),
528 text: "/// @custom:binds-to SpanKey".to_string(),
529 raw_comment_span: default_text_range(),
530 item_kind: SourceItemKind::Function,
531 item_name: Some("funcA".to_string()),
532 item_span: item_span_to_find.clone(),
533 is_natspec: true,
534 });
535 manifest.add_entry(ManifestEntry {
536 file_path: PathBuf::from("b.sol"),
538 text: "/// @custom:binds-to OtherKey".to_string(),
539 raw_comment_span: default_text_range(),
540 item_kind: SourceItemKind::Contract,
541 item_name: Some("ContractB".to_string()),
542 item_span: default_text_range(),
543 is_natspec: true,
544 });
545
546 let resolver = InterfaceResolver::new(&manifest, ®istry);
547 let binding = resolver.resolve_for_item_span(&item_span_to_find)?.unwrap();
548 assert_eq!(binding.key, "SpanKey");
549 assert_eq!(binding.address, Some("0xspan".to_string()));
550 Ok(())
551 }
552
553 #[test]
554 fn test_resolve_for_item_name_kind_found() -> Result<()> {
555 let registry = BindingRegistry {
556 bindings: HashMap::from([(
557 "NameKindKey".to_string(),
558 BindingConfig {
559 key: "NameKindKey".to_string(),
560 contract_name: Some("ImplForName".to_string()),
561 address: None,
562 chain_id: None,
563 notes: None,
564 },
565 )]),
566 };
567 let mut manifest = Manifest::default();
568 manifest.add_entry(ManifestEntry {
569 file_path: PathBuf::from("c.sol"),
570 text: "/// @custom:binds-to NameKindKey".to_string(),
571 raw_comment_span: default_text_range(),
572 item_kind: SourceItemKind::Interface,
573 item_name: Some("IMyInterface".to_string()),
574 item_span: default_text_range(),
575 is_natspec: true,
576 });
577
578 let resolver = InterfaceResolver::new(&manifest, ®istry);
579 let binding = resolver
580 .resolve_for_item_name_kind("IMyInterface", SourceItemKind::Interface, None)?
581 .unwrap();
582 assert_eq!(binding.key, "NameKindKey");
583 assert_eq!(binding.contract_name, Some("ImplForName".to_string()));
584
585 let binding_with_hint = resolver
587 .resolve_for_item_name_kind(
588 "IMyInterface",
589 SourceItemKind::Interface,
590 Some(&PathBuf::from("c.sol")),
591 )?
592 .unwrap();
593 assert_eq!(binding_with_hint.key, "NameKindKey");
594
595 let no_binding_wrong_hint = resolver.resolve_for_item_name_kind(
597 "IMyInterface",
598 SourceItemKind::Interface,
599 Some(&PathBuf::from("wrong.sol")),
600 )?;
601 assert!(no_binding_wrong_hint.is_none());
602 Ok(())
603 }
604
605 #[test]
606 fn test_populate_from_manifest_new_and_update() {
607 let mut registry = BindingRegistry::default();
608 let mut manifest = Manifest::default();
609
610 manifest.add_entry(ManifestEntry {
612 file_path: PathBuf::from("contracts/ConcreteA.sol"),
613 text: "/// @custom:binds-to KeyA".to_string(),
614 raw_comment_span: default_text_range(),
615 item_kind: SourceItemKind::Contract,
616 item_name: Some("ConcreteA".to_string()),
617 item_span: default_text_range(),
618 is_natspec: true,
619 });
620
621 manifest.add_entry(ManifestEntry {
623 file_path: PathBuf::from("contracts/ConcreteB.sol"),
624 text: "/** @custom:binds-to KeyB */".to_string(),
625 raw_comment_span: default_text_range(),
626 item_kind: SourceItemKind::Contract,
627 item_name: Some("ConcreteB".to_string()),
628 item_span: default_text_range(),
629 is_natspec: true,
630 });
631
632 registry.bindings.insert(
635 "KeyA".to_string(),
636 BindingConfig {
637 key: "KeyA".to_string(),
638 contract_name: None, address: None,
640 chain_id: None,
641 notes: Some("Initially from IKeyA interface Natspec".to_string()),
642 },
643 );
644 manifest.add_entry(ManifestEntry {
646 file_path: PathBuf::from("contracts/ConcreteC.sol"),
647 text: "/// @custom:binds-to KeyA".to_string(), raw_comment_span: default_text_range(),
649 item_kind: SourceItemKind::Contract,
650 item_name: Some("ConcreteC".to_string()), item_span: default_text_range(),
652 is_natspec: true,
653 });
654
655
656 manifest.add_entry(ManifestEntry {
658 file_path: PathBuf::from("interfaces/IfaceD.sol"),
659 text: "/// @custom:binds-to KeyD".to_string(),
660 raw_comment_span: default_text_range(),
661 item_kind: SourceItemKind::Interface, item_name: Some("IfaceD".to_string()),
663 item_span: default_text_range(),
664 is_natspec: true,
665 });
666
667 manifest.add_entry(ManifestEntry {
669 file_path: PathBuf::from("contracts/ConcreteE.sol"),
670 text: "// @custom:binds-to KeyE".to_string(),
671 raw_comment_span: default_text_range(),
672 item_kind: SourceItemKind::Contract,
673 item_name: Some("ConcreteE".to_string()),
674 item_span: default_text_range(),
675 is_natspec: false, });
677
678
679 registry.populate_from_manifest(&manifest);
680
681 assert_eq!(registry.bindings.len(), 2); let binding_a = registry.get_binding("KeyA").unwrap();
687 assert_eq!(binding_a.contract_name, Some("ConcreteC".to_string()));
688 assert!(binding_a.notes.as_ref().unwrap().contains("ConcreteC"));
689 assert!(binding_a.notes.as_ref().unwrap().contains("Initially from IKeyA interface Natspec"));
690
691
692 let binding_b = registry.get_binding("KeyB").unwrap();
694 assert_eq!(binding_b.contract_name, Some("ConcreteB".to_string()));
695 assert!(binding_b.notes.as_ref().unwrap().contains("ConcreteB"));
696
697 assert!(registry.get_binding("KeyD").is_none());
699 assert!(registry.get_binding("KeyE").is_none());
700 }
701}