1use alloy_json_abi::ToSolConfig;
2use alloy_primitives::map::{HashMap, HashSet, hash_map::Entry};
3use clap::{Parser, ValueHint};
4use eyre::Result;
5use forge_sol_macro_gen::{MultiSolMacroGen, SolMacroGen};
6use foundry_cli::{opts::BuildOpts, utils::LoadConfig};
7use foundry_common::{
8 compile::{ProjectCompiler, compile_abi_project},
9 fs::json_files,
10};
11use foundry_compilers::{
12 Graph, Project,
13 artifacts::{Contract, output_selection::OutputSelection},
14 cache::CompilerCache,
15 compilers::{Compiler, CompilerOutput, CompilerSettings, CompilerVersion},
16 error::SolcError,
17 multi::{MultiCompiler, MultiCompilerParser, MultiCompilerSettings},
18};
19use foundry_config::{Config, impl_figment_convert};
20use heck::ToSnakeCase;
21use regex::Regex;
22use semver::Version;
23use solar::ast::{Item, ItemKind};
24use std::{
25 collections::{BTreeMap, BTreeSet},
26 fs,
27 path::{Path, PathBuf},
28};
29
30impl_figment_convert!(BindArgs, build);
31
32const DEFAULT_CRATE_NAME: &str = "foundry-contracts";
33const DEFAULT_CRATE_VERSION: &str = "0.1.0";
34
35type BindingFiles = Vec<BindingFile>;
36
37struct BindingFile {
38 name: String,
40 path: PathBuf,
41 source: PathBuf,
43}
44
45#[derive(Clone, Debug, Parser)]
47pub struct BindArgs {
48 #[arg(
50 long = "bindings-path",
51 short,
52 value_hint = ValueHint::DirPath,
53 value_name = "PATH"
54 )]
55 pub bindings: Option<PathBuf>,
56
57 #[arg(long)]
59 pub select: Vec<regex::Regex>,
60
61 #[arg(long, conflicts_with_all = &["select", "skip"])]
65 pub select_all: bool,
66
67 #[arg(long, default_value = DEFAULT_CRATE_NAME, value_name = "NAME")]
72 crate_name: String,
73
74 #[arg(long, default_value = DEFAULT_CRATE_VERSION, value_name = "VERSION")]
79 crate_version: String,
80
81 #[arg(long, default_value = "", value_name = "DESCRIPTION")]
85 crate_description: String,
86
87 #[arg(long, value_name = "LICENSE", default_value = "")]
91 crate_license: String,
92
93 #[arg(long)]
95 module: bool,
96
97 #[arg(long)]
102 overwrite: bool,
103
104 #[arg(long)]
106 single_file: bool,
107
108 #[arg(long)]
110 skip_cargo_toml: bool,
111
112 #[arg(long)]
114 skip_build: bool,
115
116 #[arg(long)]
118 skip_extra_derives: bool,
119
120 #[arg(long, hide = true)]
122 alloy: bool,
123
124 #[arg(long)]
126 alloy_version: Option<String>,
127
128 #[arg(long, conflicts_with = "alloy_version")]
130 alloy_rev: Option<String>,
131
132 #[arg(long, hide = true)]
134 ethers: bool,
135
136 #[command(flatten)]
137 build: BuildOpts,
138}
139
140impl BindArgs {
141 pub fn run(self) -> Result<()> {
142 if self.ethers {
143 eyre::bail!("`--ethers` bindings have been removed. Use `--alloy` (default) instead.");
144 }
145
146 let config = if self.skip_build {
147 self.load_config()?
148 } else {
149 self.load_config_with_dependencies()?
150 };
151 let artifacts = config.out.clone();
152 let (files, enum_definitions) = if self.skip_build {
153 self.cached_bindings(&config)?
154 } else {
155 let mut project = config.project()?;
156 let contexts = resolved_artifact_contexts(&project, artifact_graph(&project, None)?)?;
158 let output = compile_abi_project(
159 &mut project,
160 ProjectCompiler::new().external_compilers(&config),
161 )?;
162 let files =
163 self.filter_artifact_paths(output.artifact_ids().filter_map(|(id, _)| {
164 (id.path.starts_with(artifacts.join(".external"))
165 || contexts
166 .get(&id.source)
167 .is_some_and(|contexts| contexts.contains(&(id.version, id.profile))))
168 .then_some((id.path, Some(id.source)))
169 }))?;
170 (files, enum_definitions(output.parser()))
171 };
172
173 let bindings_root = self.bindings.clone().unwrap_or_else(|| artifacts.join("bindings"));
174 let sol_config = ToSolConfig::new().enum_definitions(enum_definitions);
175 let mut bindings = self.get_solmacrogen(&files)?;
177
178 if bindings_root.exists() {
179 if !self.overwrite {
180 sh_status!("Bindings found. Checking for consistency.")?;
181 bindings.generate_bindings(!self.skip_extra_derives, &sol_config)?;
182 return self.check_existing_bindings(&bindings, &bindings_root);
183 }
184
185 trace!(?artifacts, "Removing existing bindings");
186 fs::remove_dir_all(&bindings_root)?;
187 }
188
189 self.generate_bindings(&mut bindings, &bindings_root, &sol_config)?;
190
191 sh_status!("Bindings have been generated to {}", bindings_root.display())?;
192 Ok(())
193 }
194
195 fn get_filter(&self) -> Result<Filter> {
196 if self.select_all {
197 return Ok(Filter::All);
199 }
200 if !self.select.is_empty() {
201 return Ok(Filter::Select(self.select.clone()));
203 }
204
205 if let Some(skip) = self.build.skip.as_ref().filter(|s| !s.is_empty()) {
206 return Ok(Filter::Skip(
207 skip.clone()
208 .into_iter()
209 .map(|s| Regex::new(s.file_pattern()))
210 .collect::<Result<Vec<_>, _>>()?,
211 ));
212 }
213
214 Ok(Filter::skip_default())
216 }
217
218 fn cached_bindings(
220 &self,
221 config: &Config,
222 ) -> Result<(BindingFiles, BTreeMap<String, Vec<String>>)> {
223 let paths = config.project_paths();
224 let cache = CompilerCache::<MultiCompilerSettings>::read_joined(&paths).ok();
225 let graph = Graph::<MultiCompilerParser>::resolve(&paths);
226 let mut definitions = if let (Some(cache), Ok(graph)) = (&cache, &graph)
227 && cache.all_artifacts_exist()
228 && graph.nodes.iter().all(|node| {
229 cache
230 .entry(node.path())
231 .is_some_and(|entry| entry.content_hash == node.unpack().1.content_hash())
232 }) {
233 enum_definitions(graph.parser())
234 } else {
235 BTreeMap::default()
236 };
237 let files = if let Some(cache) = cache.as_ref().filter(|_| config.cache) {
238 let mut files = self.cached_artifact_paths(config, cache, graph)?;
239 files.extend(json_files(&config.out.join(".external")));
241 files
242 } else {
243 json_files(&config.out).collect()
245 };
246 let sources = cache
249 .iter()
250 .flat_map(|cache| &cache.files)
251 .flat_map(|(source, entry)| {
252 entry.artifacts().map(move |artifact| (&artifact.path, source))
253 })
254 .collect::<HashMap<_, _>>();
255 let files = self.filter_artifact_paths(files.into_iter().map(|path| {
256 let source = sources.get(&path).map(|source| (*source).clone());
257 (path, source)
258 }))?;
259 if !config.cache
261 && !definitions.is_empty()
262 && files.iter().any(|file| {
263 !file.path.starts_with(config.out.join(".external"))
264 && !sources.contains_key(&file.path)
265 })
266 {
267 definitions.clear();
268 }
269 Ok((files, definitions))
270 }
271
272 fn cached_artifact_paths(
274 &self,
275 config: &Config,
276 cache: &CompilerCache<MultiCompilerSettings>,
277 graph: Result<Graph<MultiCompilerParser>, SolcError>,
278 ) -> Result<Vec<PathBuf>> {
279 let filter = self.get_filter()?;
280 let selected = cache
281 .files
282 .iter()
283 .filter(|(_, entry)| {
284 entry.artifacts.keys().any(|name| filter.is_match(&binding_name(name)))
285 })
286 .map(|(source, _)| source.clone())
287 .collect::<HashSet<_>>();
288 if selected.is_empty() {
289 return Ok(Vec::new());
290 }
291 let mut files = Vec::new();
292 let compiler = CachedCompiler::new(config, cache);
293 let mut project = config.create_project_with_compiler(|| Ok(compiler), false, true)?;
294 project.update_output_selection(|selection| {
295 *selection = OutputSelection::common_output_selection(["abi".to_string()]);
296 });
297 let graph = if project.sparse_output.is_none() {
299 graph.map_err(Into::into)
300 } else {
301 artifact_graph(&project, None)
302 };
303 let contexts =
305 graph.and_then(|graph| resolved_artifact_contexts(&project, graph)).or_else(|_| {
306 let graph = artifact_graph(&project, Some(&selected))?;
307 resolved_artifact_contexts(&project, graph)
308 })?;
309 let compatible_profiles = project
310 .settings_profiles()
311 .filter_map(|(profile, settings)| {
312 cache
313 .profiles
314 .get(profile)
315 .is_some_and(|cached| settings.can_use_cached(cached))
316 .then_some(profile)
317 })
318 .collect::<HashSet<_>>();
319 for (source, contexts) in contexts {
320 if let Some(entry) = cache.entry(&source) {
321 let vyper =
322 matches!(source.extension().and_then(|s| s.to_str()), Some("vy" | "vyi"));
323 let (unavailable, compiler, hint) = if vyper {
324 (project.compiler.vyper_unknown, "Vyper", "configure `vyper.path`")
325 } else {
326 (project.compiler.solc_unknown, "configured solc", "specify `--use <version>`")
327 };
328 for (name, versions) in
329 entry.artifacts.iter().filter(|(name, _)| filter.is_match(&binding_name(name)))
330 {
331 eyre::ensure!(
332 !unavailable || versions.len() <= 1,
333 "Cannot determine compiler version for {}:{name}: {compiler} is unavailable and multiple versions are cached; {hint}",
334 source.display(),
335 );
336 for (version, profile) in &contexts {
337 let artifact = entry.find_artifact(name, version, profile)
338 .filter(|artifact| artifact.path.is_file())
339 .ok_or_else(|| eyre::eyre!(
340 "No cached artifact for {}:{name} with compiler {version} and profile `{profile}`; run `forge build` first",
341 source.display(),
342 ))?;
343 eyre::ensure!(
344 compatible_profiles.contains(profile.as_str()),
345 "Cached compiler settings for {}:{name} and profile `{profile}` do not match; run `forge build` first",
346 source.display(),
347 );
348 files.push(artifact.path.clone());
349 }
350 }
351 }
352 }
353 Ok(files)
354 }
355
356 fn filter_artifact_paths(
358 &self,
359 paths: impl IntoIterator<Item = (PathBuf, Option<PathBuf>)>,
360 ) -> Result<BindingFiles> {
361 let filter = self.get_filter()?;
362 let mut files = paths
363 .into_iter()
364 .filter_map(|(path, source)| {
365 if path.to_str()?.contains("build-info")
366 || path.iter().any(|component| component == "target")
367 {
368 return None;
369 }
370 let stem = path.file_stem()?.to_str()?;
371 if stem.ends_with(".metadata") {
372 return None;
373 }
374 let name = stem.split('.').next().unwrap().to_owned();
375 filter.is_match(&binding_name(&name)).then(|| BindingFile {
376 name,
377 source: source.unwrap_or_else(|| path.parent().unwrap().to_path_buf()),
378 path,
379 })
380 })
381 .collect::<Vec<_>>();
382 files.sort_by(|a, b| a.path.cmp(&b.path));
383 Ok(files)
384 }
385
386 fn get_solmacrogen(&self, files: &[BindingFile]) -> Result<MultiSolMacroGen> {
387 let mut identities = HashSet::<_>::default();
388 let mut names = HashMap::<_, &BindingFile>::default();
389 let mut modules = HashMap::<_, _>::default();
390 let mut instances = Vec::new();
391 for file in files {
392 if !identities.insert((&file.source, &file.name)) {
393 continue;
394 }
395 let name = binding_name(&file.name);
396 match names.entry(name.clone()) {
397 Entry::Occupied(entry) => {
398 let previous = entry.get();
399 sh_warn!(
400 "Binding name collision for `{name}`: keeping contract `{}` ({}) and skipping contract `{}` ({})",
401 previous.name,
402 previous.path.display(),
403 file.name,
404 file.path.display(),
405 )?;
406 continue;
407 }
408 Entry::Vacant(entry) => {
409 entry.insert(file);
410 }
411 }
412 if !self.single_file {
413 let module = name.to_snake_case();
414 let entrypoint = if self.module { "mod" } else { "lib" };
415 eyre::ensure!(
416 module != entrypoint,
417 "Binding module name collision for `{module}`: contract `{}` ({}) conflicts with generated entry point `{entrypoint}.rs`",
418 file.name,
419 file.path.display(),
420 );
421 if let Some(previous) = modules.insert(module.clone(), file) {
422 eyre::bail!(
423 "Binding module name collision for `{module}`: contract `{}` ({}) and contract `{}` ({})",
424 previous.name,
425 previous.path.display(),
426 file.name,
427 file.path.display(),
428 );
429 }
430 }
431 trace!(path = ?file.path, "parsing SolMacroGen from file");
432 instances.push(SolMacroGen::new(file.path.clone(), name));
433 }
434
435 let multi = MultiSolMacroGen::new(instances);
436 eyre::ensure!(!multi.instances.is_empty(), "No contract artifacts found");
437 Ok(multi)
438 }
439
440 fn check_existing_bindings(
442 &self,
443 bindings: &MultiSolMacroGen,
444 bindings_root: &Path,
445 ) -> Result<()> {
446 sh_status!("Checking bindings for {} contracts", bindings.instances.len())?;
447 bindings.check_consistency(
448 &self.crate_name,
449 &self.crate_version,
450 bindings_root,
451 self.single_file,
452 !self.skip_cargo_toml,
453 self.module,
454 self.alloy_version.clone(),
455 self.alloy_rev.clone(),
456 )?;
457 sh_status!("OK.")?;
458 Ok(())
459 }
460
461 fn generate_bindings(
463 &self,
464 bindings: &mut MultiSolMacroGen,
465 bindings_root: &Path,
466 sol_config: &ToSolConfig,
467 ) -> Result<()> {
468 sh_status!("Generating bindings for {} contracts", bindings.instances.len())?;
469
470 if self.module {
471 trace!(single_file = self.single_file, "generating module");
472 bindings.write_to_module(
473 bindings_root,
474 self.single_file,
475 !self.skip_extra_derives,
476 sol_config,
477 )?;
478 } else {
479 trace!(single_file = self.single_file, "generating crate");
480 bindings.write_to_crate(
481 &self.crate_name,
482 &self.crate_version,
483 &self.crate_description,
484 &self.crate_license,
485 bindings_root,
486 self.single_file,
487 self.alloy_version.clone(),
488 self.alloy_rev.clone(),
489 !self.skip_extra_derives,
490 sol_config,
491 )?;
492 }
493
494 Ok(())
495 }
496}
497
498fn enum_definitions(parser: &MultiCompilerParser) -> BTreeMap<String, Vec<String>> {
499 parser.solc().compiler().enter(|compiler| {
500 let mut definitions = BTreeMap::default();
501 let mut ambiguous = HashSet::default();
502 for source in compiler.sources().iter() {
503 if let Some(ast) = &source.ast {
504 collect_enum_definitions(ast.items.iter(), None, &mut definitions, &mut ambiguous);
505 }
506 }
507 definitions
508 })
509}
510
511fn collect_enum_definitions<'ast>(
512 items: impl Iterator<Item = &'ast Item<'ast>>,
513 owner: Option<&str>,
514 definitions: &mut BTreeMap<String, Vec<String>>,
515 ambiguous: &mut HashSet<String>,
516) {
517 for item in items {
518 match &item.kind {
519 ItemKind::Enum(enum_item) => {
520 let name = enum_item.name.to_string();
521 let key = owner.map_or_else(|| name.clone(), |owner| format!("{owner}.{name}"));
522 let variants = enum_item.variants.iter().map(ToString::to_string).collect();
523 if definitions.get(&key).is_some_and(|existing| existing != &variants) {
524 definitions.remove(&key);
525 ambiguous.insert(key);
526 } else if !ambiguous.contains(&key) {
527 definitions.insert(key, variants);
528 }
529 }
530 ItemKind::Contract(contract) => {
531 let owner = contract.name.to_string();
532 collect_enum_definitions(
533 contract.body.iter(),
534 Some(&owner),
535 definitions,
536 ambiguous,
537 );
538 }
539 _ => {}
540 }
541 }
542}
543
544pub enum Filter {
545 All,
546 Select(Vec<regex::Regex>),
547 Skip(Vec<regex::Regex>),
548}
549
550impl Filter {
551 pub fn is_match(&self, name: &str) -> bool {
552 match self {
553 Self::All => true,
554 Self::Select(regexes) => regexes.iter().any(|regex| regex.is_match(name)),
555 Self::Skip(regexes) => !regexes.iter().any(|regex| regex.is_match(name)),
556 }
557 }
558
559 pub fn skip_default() -> Self {
560 let skip = [
561 ".*Test.*",
562 ".*Script",
563 "console[2]?",
564 "CommonBase",
565 "Components",
566 "[Ss]td(Chains|Math|Error|Json|Utils|Cheats|Style|Invariant|Assertions|Toml|Storage(Safe)?)",
567 "[Vv]m.*",
568 "IMulticall3",
569 ]
570 .iter()
571 .map(|pattern| regex::Regex::new(pattern).unwrap())
572 .collect::<Vec<_>>();
573
574 Self::Skip(skip)
575 }
576}
577
578fn artifact_graph<C: Compiler<CompilerContract = Contract>>(
580 project: &Project<C>,
581 selected: Option<&HashSet<PathBuf>>,
582) -> Result<Graph<C::Parser>> {
583 let mut sources = project.paths.read_input_files()?;
584 if let Some(filter) = &project.sparse_output {
585 sources.retain(|source, _| filter.is_match(source));
586 }
587 if let Some(selected) = selected {
588 let dependencies = selected
589 .iter()
590 .filter(|source| !sources.contains_key(*source))
591 .map(PathBuf::as_path)
592 .collect::<HashSet<_>>();
593 let mut roots = selected.clone();
594 if !dependencies.is_empty() {
595 let graph = Graph::<C::Parser>::resolve_sources(&project.paths, sources.clone())?;
598 roots.extend(
599 graph
600 .input_nodes()
601 .filter(|node| {
602 graph
603 .imports(node.path())
604 .iter()
605 .any(|import| dependencies.contains(import))
606 })
607 .map(|node| node.path().to_path_buf()),
608 );
609 }
610 sources.retain(|source, _| roots.contains(source));
611 }
612 Ok(Graph::resolve_sources(&project.paths, sources)?)
613}
614
615fn resolved_artifact_contexts<C: Compiler<CompilerContract = Contract>>(
617 project: &Project<C>,
618 graph: Graph<C::Parser>,
619) -> Result<BTreeMap<PathBuf, BTreeSet<(Version, String)>>> {
620 let resolved = graph.into_sources_by_version(project)?;
621 let mut contexts = BTreeMap::<_, BTreeSet<_>>::new();
622 for (version, sources, (profile, _)) in resolved.sources.into_values().flatten() {
623 for source in sources.keys() {
624 if resolved.primary_profiles.get(source).is_none_or(|primary| *primary == profile) {
625 contexts
626 .entry(source.clone())
627 .or_default()
628 .insert((version.clone(), profile.to_string()));
629 }
630 }
631 }
632 Ok(contexts)
633}
634
635fn binding_name(name: &str) -> String {
636 name.replace(char::is_whitespace, "").replace(['-', '$'], "_")
637}
638
639#[derive(Clone)]
641struct CachedCompiler {
642 solc: Vec<CompilerVersion>,
643 vyper: Vec<CompilerVersion>,
644 solc_unknown: bool,
645 vyper_unknown: bool,
646}
647
648impl CachedCompiler {
649 fn new(config: &Config, cache: &CompilerCache<MultiCompilerSettings>) -> Self {
650 let cached_versions = |vyper| {
651 cache
652 .files
653 .iter()
654 .filter(|(source, _)| {
655 matches!(source.extension().and_then(|s| s.to_str()), Some("vy" | "vyi"))
656 == vyper
657 })
658 .flat_map(|(_, entry)| {
659 entry.artifacts.values().flat_map(|versions| versions.keys().cloned())
660 })
661 .collect::<BTreeSet<_>>()
662 .into_iter()
663 .map(CompilerVersion::Installed)
664 .collect::<Vec<_>>()
665 };
666 let mut solc_unknown = false;
667 let solc = match &config.solc {
668 Some(request) => {
669 if let Ok(version) = request.try_version() {
670 vec![CompilerVersion::Installed(Version::new(
671 version.major,
672 version.minor,
673 version.patch,
674 ))]
675 } else {
676 solc_unknown = true;
677 cached_versions(false)
678 }
679 }
680 None => cached_versions(false),
682 };
683 let mut vyper_unknown = false;
684 let vyper = if let Ok(Some(compiler)) = config.vyper_compiler() {
685 compiler.available_versions(&Default::default())
686 } else {
687 vyper_unknown = true;
688 cached_versions(true)
689 };
690 Self { solc, vyper, solc_unknown, vyper_unknown }
691 }
692}
693
694impl Compiler for CachedCompiler {
695 type Input = <MultiCompiler as Compiler>::Input;
696 type CompilationError = <MultiCompiler as Compiler>::CompilationError;
697 type CompilerContract = <MultiCompiler as Compiler>::CompilerContract;
698 type Parser = <MultiCompiler as Compiler>::Parser;
699 type Settings = <MultiCompiler as Compiler>::Settings;
700 type Language = <MultiCompiler as Compiler>::Language;
701
702 fn compile(
703 &self,
704 _input: &Self::Input,
705 ) -> std::result::Result<
706 CompilerOutput<Self::CompilationError, Self::CompilerContract>,
707 SolcError,
708 > {
709 Err(SolcError::msg("cached artifact selection cannot compile sources"))
710 }
711
712 fn available_versions(&self, language: &Self::Language) -> Vec<CompilerVersion> {
713 if language.is_solc() { self.solc.clone() } else { self.vyper.clone() }
714 }
715}