1use alloy_json_abi::ToSolConfig;
2use alloy_primitives::map::HashSet;
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, ProjectPathsConfig,
13 cache::CompilerCache,
14 multi::{MultiCompilerParser, MultiCompilerSettings},
15};
16use foundry_config::impl_figment_convert;
17use regex::Regex;
18use solar::ast::{Item, ItemKind};
19use std::{
20 collections::BTreeMap,
21 fs,
22 path::{Path, PathBuf},
23};
24
25impl_figment_convert!(BindArgs, build);
26
27const DEFAULT_CRATE_NAME: &str = "foundry-contracts";
28const DEFAULT_CRATE_VERSION: &str = "0.1.0";
29
30#[derive(Clone, Debug, Parser)]
32pub struct BindArgs {
33 #[arg(
35 long = "bindings-path",
36 short,
37 value_hint = ValueHint::DirPath,
38 value_name = "PATH"
39 )]
40 pub bindings: Option<PathBuf>,
41
42 #[arg(long)]
44 pub select: Vec<regex::Regex>,
45
46 #[arg(long, conflicts_with_all = &["select", "skip"])]
50 pub select_all: bool,
51
52 #[arg(long, default_value = DEFAULT_CRATE_NAME, value_name = "NAME")]
57 crate_name: String,
58
59 #[arg(long, default_value = DEFAULT_CRATE_VERSION, value_name = "VERSION")]
64 crate_version: String,
65
66 #[arg(long, default_value = "", value_name = "DESCRIPTION")]
70 crate_description: String,
71
72 #[arg(long, value_name = "LICENSE", default_value = "")]
76 crate_license: String,
77
78 #[arg(long)]
80 module: bool,
81
82 #[arg(long)]
87 overwrite: bool,
88
89 #[arg(long)]
91 single_file: bool,
92
93 #[arg(long)]
95 skip_cargo_toml: bool,
96
97 #[arg(long)]
99 skip_build: bool,
100
101 #[arg(long)]
103 skip_extra_derives: bool,
104
105 #[arg(long, hide = true)]
107 alloy: bool,
108
109 #[arg(long)]
111 alloy_version: Option<String>,
112
113 #[arg(long, conflicts_with = "alloy_version")]
115 alloy_rev: Option<String>,
116
117 #[arg(long, hide = true)]
119 ethers: bool,
120
121 #[command(flatten)]
122 build: BuildOpts,
123}
124
125impl BindArgs {
126 pub fn run(self) -> Result<()> {
127 if self.ethers {
128 eyre::bail!("`--ethers` bindings have been removed. Use `--alloy` (default) instead.");
129 }
130
131 let config = if self.skip_build {
132 self.load_config()?
133 } else {
134 self.load_config_with_dependencies()?
135 };
136 let artifacts = config.out.clone();
137 let enum_definitions = if self.skip_build {
138 let paths = config.project_paths();
139 cached_enum_definitions(
140 &paths,
141 &artifacts,
142 self.get_json_files(&artifacts)?.map(|(_, path)| path),
143 )
144 } else {
145 let mut project = config.project()?;
146 let output = compile_abi_project(
147 &mut project,
148 ProjectCompiler::new().external_compilers(&config),
149 )?;
150 enum_definitions(output.parser())
151 };
152
153 let bindings_root = self.bindings.clone().unwrap_or_else(|| artifacts.join("bindings"));
154 let sol_config = ToSolConfig::new().enum_definitions(enum_definitions);
155
156 if bindings_root.exists() {
157 if !self.overwrite {
158 sh_status!("Bindings found. Checking for consistency.")?;
159 let mut bindings = self.get_solmacrogen(&artifacts)?;
160 bindings.generate_bindings(!self.skip_extra_derives, &sol_config)?;
161 return self.check_existing_bindings(&bindings, &bindings_root);
162 }
163
164 trace!(?artifacts, "Removing existing bindings");
165 fs::remove_dir_all(&bindings_root)?;
166 }
167
168 self.generate_bindings(&artifacts, &bindings_root, &sol_config)?;
169
170 sh_status!("Bindings have been generated to {}", bindings_root.display())?;
171 Ok(())
172 }
173
174 fn get_filter(&self) -> Result<Filter> {
175 if self.select_all {
176 return Ok(Filter::All);
178 }
179 if !self.select.is_empty() {
180 return Ok(Filter::Select(self.select.clone()));
182 }
183
184 if let Some(skip) = self.build.skip.as_ref().filter(|s| !s.is_empty()) {
185 return Ok(Filter::Skip(
186 skip.clone()
187 .into_iter()
188 .map(|s| Regex::new(s.file_pattern()))
189 .collect::<Result<Vec<_>, _>>()?,
190 ));
191 }
192
193 Ok(Filter::skip_default())
195 }
196
197 fn get_json_files(&self, artifacts: &Path) -> Result<impl Iterator<Item = (String, PathBuf)>> {
199 let filter = self.get_filter()?;
200 Ok(json_files(artifacts)
201 .filter_map(|path| {
202 if path.to_str()?.contains("build-info") {
204 return None;
205 }
206
207 if path.iter().any(|comp| comp == "target") {
209 return None;
210 }
211
212 let stem = path.file_stem()?.to_str()?;
214 if stem.ends_with(".metadata") {
215 return None;
216 }
217
218 let name = stem.split('.').next().unwrap();
219
220 let name = name.replace(char::is_whitespace, "").replace(['-', '$'], "_");
222
223 Some((name, path))
224 })
225 .filter(move |(name, _path)| filter.is_match(name)))
226 }
227
228 fn get_solmacrogen(&self, artifacts: &Path) -> Result<MultiSolMacroGen> {
229 let mut dup = HashSet::<String>::default();
230 let instances = self
231 .get_json_files(artifacts)?
232 .filter_map(|(name, path)| {
233 trace!(?path, "parsing SolMacroGen from file");
234 dup.insert(name.clone()).then(|| SolMacroGen::new(path, name))
235 })
236 .collect::<Vec<_>>();
237
238 let multi = MultiSolMacroGen::new(instances);
239 eyre::ensure!(!multi.instances.is_empty(), "No contract artifacts found");
240 Ok(multi)
241 }
242
243 fn check_existing_bindings(
245 &self,
246 bindings: &MultiSolMacroGen,
247 bindings_root: &Path,
248 ) -> Result<()> {
249 sh_status!("Checking bindings for {} contracts", bindings.instances.len())?;
250 bindings.check_consistency(
251 &self.crate_name,
252 &self.crate_version,
253 bindings_root,
254 self.single_file,
255 !self.skip_cargo_toml,
256 self.module,
257 self.alloy_version.clone(),
258 self.alloy_rev.clone(),
259 )?;
260 sh_status!("OK.")?;
261 Ok(())
262 }
263
264 fn generate_bindings(
266 &self,
267 artifacts: &Path,
268 bindings_root: &Path,
269 sol_config: &ToSolConfig,
270 ) -> Result<()> {
271 let mut bindings = self.get_solmacrogen(artifacts)?;
272 sh_status!("Generating bindings for {} contracts", bindings.instances.len())?;
273
274 if self.module {
275 trace!(single_file = self.single_file, "generating module");
276 bindings.write_to_module(
277 bindings_root,
278 self.single_file,
279 !self.skip_extra_derives,
280 sol_config,
281 )?;
282 } else {
283 trace!(single_file = self.single_file, "generating crate");
284 bindings.write_to_crate(
285 &self.crate_name,
286 &self.crate_version,
287 &self.crate_description,
288 &self.crate_license,
289 bindings_root,
290 self.single_file,
291 self.alloy_version.clone(),
292 self.alloy_rev.clone(),
293 !self.skip_extra_derives,
294 sol_config,
295 )?;
296 }
297
298 Ok(())
299 }
300}
301
302fn cached_enum_definitions(
303 paths: &ProjectPathsConfig,
304 artifacts_root: &Path,
305 artifacts: impl Iterator<Item = PathBuf>,
306) -> BTreeMap<String, Vec<String>> {
307 let Ok(graph) = Graph::<MultiCompilerParser>::resolve(paths) else {
308 return BTreeMap::default();
309 };
310 let Ok(cache) = CompilerCache::<MultiCompilerSettings>::read_joined(paths) else {
311 return BTreeMap::default();
312 };
313 let sources_are_fresh = graph.nodes.iter().all(|node| {
314 cache
315 .entry(node.path())
316 .is_some_and(|entry| entry.content_hash == node.unpack().1.content_hash())
317 });
318 if !sources_are_fresh || !cache.all_artifacts_exist() {
319 return BTreeMap::default();
320 }
321 let cached_artifacts = cache
322 .entries()
323 .flat_map(|entry| entry.artifacts.values())
324 .flat_map(|versions| versions.values())
325 .flat_map(|profiles| profiles.values())
326 .map(|artifact| artifact.path.clone())
327 .collect::<HashSet<_>>();
328 if artifacts.into_iter().any(|artifact| {
329 !artifact.starts_with(artifacts_root.join(".external"))
330 && !cached_artifacts.contains(&artifact)
331 }) {
332 return BTreeMap::default();
333 }
334 enum_definitions(graph.parser())
335}
336
337fn enum_definitions(parser: &MultiCompilerParser) -> BTreeMap<String, Vec<String>> {
338 parser.solc().compiler().enter(|compiler| {
339 let mut definitions = BTreeMap::default();
340 let mut ambiguous = HashSet::default();
341 for source in compiler.sources().iter() {
342 if let Some(ast) = &source.ast {
343 collect_enum_definitions(ast.items.iter(), None, &mut definitions, &mut ambiguous);
344 }
345 }
346 definitions
347 })
348}
349
350fn collect_enum_definitions<'ast>(
351 items: impl Iterator<Item = &'ast Item<'ast>>,
352 owner: Option<&str>,
353 definitions: &mut BTreeMap<String, Vec<String>>,
354 ambiguous: &mut HashSet<String>,
355) {
356 for item in items {
357 match &item.kind {
358 ItemKind::Enum(enum_item) => {
359 let name = enum_item.name.to_string();
360 let key = owner.map_or_else(|| name.clone(), |owner| format!("{owner}.{name}"));
361 let variants = enum_item.variants.iter().map(ToString::to_string).collect();
362 if definitions.get(&key).is_some_and(|existing| existing != &variants) {
363 definitions.remove(&key);
364 ambiguous.insert(key);
365 } else if !ambiguous.contains(&key) {
366 definitions.insert(key, variants);
367 }
368 }
369 ItemKind::Contract(contract) => {
370 let owner = contract.name.to_string();
371 collect_enum_definitions(
372 contract.body.iter(),
373 Some(&owner),
374 definitions,
375 ambiguous,
376 );
377 }
378 _ => {}
379 }
380 }
381}
382
383pub enum Filter {
384 All,
385 Select(Vec<regex::Regex>),
386 Skip(Vec<regex::Regex>),
387}
388
389impl Filter {
390 pub fn is_match(&self, name: &str) -> bool {
391 match self {
392 Self::All => true,
393 Self::Select(regexes) => regexes.iter().any(|regex| regex.is_match(name)),
394 Self::Skip(regexes) => !regexes.iter().any(|regex| regex.is_match(name)),
395 }
396 }
397
398 pub fn skip_default() -> Self {
399 let skip = [
400 ".*Test.*",
401 ".*Script",
402 "console[2]?",
403 "CommonBase",
404 "Components",
405 "[Ss]td(Chains|Math|Error|Json|Utils|Cheats|Style|Invariant|Assertions|Toml|Storage(Safe)?)",
406 "[Vv]m.*",
407 "IMulticall3",
408 ]
409 .iter()
410 .map(|pattern| regex::Regex::new(pattern).unwrap())
411 .collect::<Vec<_>>();
412
413 Self::Skip(skip)
414 }
415}