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(&paths, self.get_json_files(&artifacts)?.map(|(_, path)| path))
140 } else {
141 let mut project = config.project()?;
142 let output = compile_abi_project(&mut project, ProjectCompiler::new())?;
143 enum_definitions(output.parser())
144 };
145
146 let bindings_root = self.bindings.clone().unwrap_or_else(|| artifacts.join("bindings"));
147 let sol_config = ToSolConfig::new().enum_definitions(enum_definitions);
148
149 if bindings_root.exists() {
150 if !self.overwrite {
151 sh_status!("Bindings found. Checking for consistency.")?;
152 let mut bindings = self.get_solmacrogen(&artifacts)?;
153 bindings.generate_bindings(!self.skip_extra_derives, &sol_config)?;
154 return self.check_existing_bindings(&bindings, &bindings_root);
155 }
156
157 trace!(?artifacts, "Removing existing bindings");
158 fs::remove_dir_all(&bindings_root)?;
159 }
160
161 self.generate_bindings(&artifacts, &bindings_root, &sol_config)?;
162
163 sh_status!("Bindings have been generated to {}", bindings_root.display())?;
164 Ok(())
165 }
166
167 fn get_filter(&self) -> Result<Filter> {
168 if self.select_all {
169 return Ok(Filter::All);
171 }
172 if !self.select.is_empty() {
173 return Ok(Filter::Select(self.select.clone()));
175 }
176
177 if let Some(skip) = self.build.skip.as_ref().filter(|s| !s.is_empty()) {
178 return Ok(Filter::Skip(
179 skip.clone()
180 .into_iter()
181 .map(|s| Regex::new(s.file_pattern()))
182 .collect::<Result<Vec<_>, _>>()?,
183 ));
184 }
185
186 Ok(Filter::skip_default())
188 }
189
190 fn get_json_files(&self, artifacts: &Path) -> Result<impl Iterator<Item = (String, PathBuf)>> {
192 let filter = self.get_filter()?;
193 Ok(json_files(artifacts)
194 .filter_map(|path| {
195 if path.to_str()?.contains("build-info") {
197 return None;
198 }
199
200 if path.iter().any(|comp| comp == "target") {
202 return None;
203 }
204
205 let stem = path.file_stem()?.to_str()?;
207 if stem.ends_with(".metadata") {
208 return None;
209 }
210
211 let name = stem.split('.').next().unwrap();
212
213 let name = name.replace(char::is_whitespace, "").replace(['-', '$'], "_");
215
216 Some((name, path))
217 })
218 .filter(move |(name, _path)| filter.is_match(name)))
219 }
220
221 fn get_solmacrogen(&self, artifacts: &Path) -> Result<MultiSolMacroGen> {
222 let mut dup = HashSet::<String>::default();
223 let instances = self
224 .get_json_files(artifacts)?
225 .filter_map(|(name, path)| {
226 trace!(?path, "parsing SolMacroGen from file");
227 dup.insert(name.clone()).then(|| SolMacroGen::new(path, name))
228 })
229 .collect::<Vec<_>>();
230
231 let multi = MultiSolMacroGen::new(instances);
232 eyre::ensure!(!multi.instances.is_empty(), "No contract artifacts found");
233 Ok(multi)
234 }
235
236 fn check_existing_bindings(
238 &self,
239 bindings: &MultiSolMacroGen,
240 bindings_root: &Path,
241 ) -> Result<()> {
242 sh_status!("Checking bindings for {} contracts", bindings.instances.len())?;
243 bindings.check_consistency(
244 &self.crate_name,
245 &self.crate_version,
246 bindings_root,
247 self.single_file,
248 !self.skip_cargo_toml,
249 self.module,
250 self.alloy_version.clone(),
251 self.alloy_rev.clone(),
252 )?;
253 sh_status!("OK.")?;
254 Ok(())
255 }
256
257 fn generate_bindings(
259 &self,
260 artifacts: &Path,
261 bindings_root: &Path,
262 sol_config: &ToSolConfig,
263 ) -> Result<()> {
264 let mut bindings = self.get_solmacrogen(artifacts)?;
265 sh_status!("Generating bindings for {} contracts", bindings.instances.len())?;
266
267 if self.module {
268 trace!(single_file = self.single_file, "generating module");
269 bindings.write_to_module(
270 bindings_root,
271 self.single_file,
272 !self.skip_extra_derives,
273 sol_config,
274 )?;
275 } else {
276 trace!(single_file = self.single_file, "generating crate");
277 bindings.write_to_crate(
278 &self.crate_name,
279 &self.crate_version,
280 &self.crate_description,
281 &self.crate_license,
282 bindings_root,
283 self.single_file,
284 self.alloy_version.clone(),
285 self.alloy_rev.clone(),
286 !self.skip_extra_derives,
287 sol_config,
288 )?;
289 }
290
291 Ok(())
292 }
293}
294
295fn cached_enum_definitions(
296 paths: &ProjectPathsConfig,
297 artifacts: impl Iterator<Item = PathBuf>,
298) -> BTreeMap<String, Vec<String>> {
299 let Ok(graph) = Graph::<MultiCompilerParser>::resolve(paths) else {
300 return BTreeMap::default();
301 };
302 let Ok(cache) = CompilerCache::<MultiCompilerSettings>::read_joined(paths) else {
303 return BTreeMap::default();
304 };
305 let sources_are_fresh = graph.nodes.iter().all(|node| {
306 cache
307 .entry(node.path())
308 .is_some_and(|entry| entry.content_hash == node.unpack().1.content_hash())
309 });
310 if !sources_are_fresh || !cache.all_artifacts_exist() {
311 return BTreeMap::default();
312 }
313 let cached_artifacts = cache
314 .entries()
315 .flat_map(|entry| entry.artifacts.values())
316 .flat_map(|versions| versions.values())
317 .flat_map(|profiles| profiles.values())
318 .map(|artifact| artifact.path.clone())
319 .collect::<HashSet<_>>();
320 if artifacts.into_iter().any(|artifact| !cached_artifacts.contains(&artifact)) {
321 return BTreeMap::default();
322 }
323 enum_definitions(graph.parser())
324}
325
326fn enum_definitions(parser: &MultiCompilerParser) -> BTreeMap<String, Vec<String>> {
327 parser.solc().compiler().enter(|compiler| {
328 let mut definitions = BTreeMap::default();
329 let mut ambiguous = HashSet::default();
330 for source in compiler.sources().iter() {
331 if let Some(ast) = &source.ast {
332 collect_enum_definitions(ast.items.iter(), None, &mut definitions, &mut ambiguous);
333 }
334 }
335 definitions
336 })
337}
338
339fn collect_enum_definitions<'ast>(
340 items: impl Iterator<Item = &'ast Item<'ast>>,
341 owner: Option<&str>,
342 definitions: &mut BTreeMap<String, Vec<String>>,
343 ambiguous: &mut HashSet<String>,
344) {
345 for item in items {
346 match &item.kind {
347 ItemKind::Enum(enum_item) => {
348 let name = enum_item.name.to_string();
349 let key = owner.map_or_else(|| name.clone(), |owner| format!("{owner}.{name}"));
350 let variants = enum_item.variants.iter().map(ToString::to_string).collect();
351 if definitions.get(&key).is_some_and(|existing| existing != &variants) {
352 definitions.remove(&key);
353 ambiguous.insert(key);
354 } else if !ambiguous.contains(&key) {
355 definitions.insert(key, variants);
356 }
357 }
358 ItemKind::Contract(contract) => {
359 let owner = contract.name.to_string();
360 collect_enum_definitions(
361 contract.body.iter(),
362 Some(&owner),
363 definitions,
364 ambiguous,
365 );
366 }
367 _ => {}
368 }
369 }
370}
371
372pub enum Filter {
373 All,
374 Select(Vec<regex::Regex>),
375 Skip(Vec<regex::Regex>),
376}
377
378impl Filter {
379 pub fn is_match(&self, name: &str) -> bool {
380 match self {
381 Self::All => true,
382 Self::Select(regexes) => regexes.iter().any(|regex| regex.is_match(name)),
383 Self::Skip(regexes) => !regexes.iter().any(|regex| regex.is_match(name)),
384 }
385 }
386
387 pub fn skip_default() -> Self {
388 let skip = [
389 ".*Test.*",
390 ".*Script",
391 "console[2]?",
392 "CommonBase",
393 "Components",
394 "[Ss]td(Chains|Math|Error|Json|Utils|Cheats|Style|Invariant|Assertions|Toml|Storage(Safe)?)",
395 "[Vv]m.*",
396 "IMulticall3",
397 ]
398 .iter()
399 .map(|pattern| regex::Regex::new(pattern).unwrap())
400 .collect::<Vec<_>>();
401
402 Self::Skip(skip)
403 }
404}