Skip to main content

forge/cmd/
bind.rs

1use alloy_primitives::map::HashSet;
2use clap::{Parser, ValueHint};
3use eyre::Result;
4use forge_sol_macro_gen::{MultiSolMacroGen, SolMacroGen};
5use foundry_cli::{opts::BuildOpts, utils::LoadConfig};
6use foundry_common::{compile::ProjectCompiler, fs::json_files};
7use foundry_config::impl_figment_convert;
8use regex::Regex;
9use std::{
10    collections::BTreeSet,
11    fs,
12    path::{Path, PathBuf},
13};
14
15impl_figment_convert!(BindArgs, build);
16
17const DEFAULT_CRATE_NAME: &str = "foundry-contracts";
18const DEFAULT_CRATE_VERSION: &str = "0.1.0";
19
20/// CLI arguments for `forge bind`.
21#[derive(Clone, Debug, Parser)]
22pub struct BindArgs {
23    /// Path to where the contract artifacts are stored.
24    #[arg(
25        long = "bindings-path",
26        short,
27        value_hint = ValueHint::DirPath,
28        value_name = "PATH"
29    )]
30    pub bindings: Option<PathBuf>,
31
32    /// Create bindings only for contracts whose names match the specified filter(s)
33    #[arg(long)]
34    pub select: Vec<regex::Regex>,
35
36    /// Explicitly generate bindings for all contracts
37    ///
38    /// By default all contracts ending with `Test` or `Script` are excluded.
39    #[arg(long, conflicts_with_all = &["select", "skip"])]
40    pub select_all: bool,
41
42    /// The name of the Rust crate to generate.
43    ///
44    /// This should be a valid crates.io crate name,
45    /// however, this is not currently validated by this command.
46    #[arg(long, default_value = DEFAULT_CRATE_NAME, value_name = "NAME")]
47    crate_name: String,
48
49    /// The version of the Rust crate to generate.
50    ///
51    /// This should be a standard semver version string,
52    /// however, this is not currently validated by this command.
53    #[arg(long, default_value = DEFAULT_CRATE_VERSION, value_name = "VERSION")]
54    crate_version: String,
55
56    /// The description of the Rust crate to generate.
57    ///
58    /// This will be added to the package.description field in Cargo.toml.
59    #[arg(long, default_value = "", value_name = "DESCRIPTION")]
60    crate_description: String,
61
62    /// The license of the Rust crate to generate.
63    ///
64    /// This will be added to the package.license field in Cargo.toml.
65    #[arg(long, value_name = "LICENSE", default_value = "")]
66    crate_license: String,
67
68    /// Generate the bindings as a module instead of a crate.
69    #[arg(long)]
70    module: bool,
71
72    /// Overwrite existing generated bindings.
73    ///
74    /// By default, the command will check that the bindings are correct, and then exit. If
75    /// --overwrite is passed, it will instead delete and overwrite the bindings.
76    #[arg(long)]
77    overwrite: bool,
78
79    /// Generate bindings as a single file.
80    #[arg(long)]
81    single_file: bool,
82
83    /// Skip Cargo.toml consistency checks.
84    #[arg(long)]
85    skip_cargo_toml: bool,
86
87    /// Skips running forge build before generating binding
88    #[arg(long)]
89    skip_build: bool,
90
91    /// Don't add any additional derives to generated bindings
92    #[arg(long)]
93    skip_extra_derives: bool,
94
95    /// Generate bindings for the `alloy` library, instead of `ethers`.
96    #[arg(long, hide = true)]
97    alloy: bool,
98
99    /// Specify the `alloy` version on Crates.
100    #[arg(long)]
101    alloy_version: Option<String>,
102
103    /// Specify the `alloy` revision on GitHub.
104    #[arg(long, conflicts_with = "alloy_version")]
105    alloy_rev: Option<String>,
106
107    /// Generate bindings for the `ethers` library (removed), instead of `alloy`.
108    #[arg(long, hide = true)]
109    ethers: bool,
110
111    #[command(flatten)]
112    build: BuildOpts,
113}
114
115impl BindArgs {
116    pub fn run(mut self) -> Result<()> {
117        if self.ethers {
118            eyre::bail!("`--ethers` bindings have been removed. Use `--alloy` (default) instead.");
119        }
120        if !self.skip_build {
121            let project = self.build.project()?;
122            let old_builds: BTreeSet<String> = {
123                if let Ok(entries) = std::fs::read_dir(&project.paths.build_infos) {
124                    entries
125                        .filter_map(|x| x.ok())
126                        .filter_map(|f| {
127                            let path = f.path();
128                            if path.is_file() {
129                                path.file_stem().map(|x| x.to_string_lossy().into_owned())
130                            } else {
131                                None
132                            }
133                        })
134                        .collect()
135                } else {
136                    BTreeSet::new()
137                }
138            };
139            let output = ProjectCompiler::new().compile(&project)?;
140
141            let new_builds: BTreeSet<String> =
142                output.builds().map(|(key, _)| key).cloned().collect();
143
144            // if build_infos got overwritten => different compiler was used.
145            // in case of other possible errors(e.g. modified file we go as usual)
146            if old_builds.is_disjoint(&new_builds) {
147                self.overwrite = true;
148            }
149        }
150
151        let config = self.load_config()?;
152        let project = config.project()?;
153
154        let artifacts = project.artifacts_path();
155        let bindings_root = self.bindings.clone().unwrap_or_else(|| artifacts.join("bindings"));
156
157        if bindings_root.exists() {
158            if !self.overwrite {
159                sh_println!("Bindings found. Checking for consistency.")?;
160                return self.check_existing_bindings(artifacts, &bindings_root);
161            }
162
163            trace!(?artifacts, "Removing existing bindings");
164            fs::remove_dir_all(&bindings_root)?;
165        }
166
167        self.generate_bindings(artifacts, &bindings_root)?;
168
169        sh_println!("Bindings have been generated to {}", bindings_root.display())?;
170        Ok(())
171    }
172
173    fn get_filter(&self) -> Result<Filter> {
174        if self.select_all {
175            // Select all json files
176            return Ok(Filter::All);
177        }
178        if !self.select.is_empty() {
179            // Return json files that match the select regex
180            return Ok(Filter::Select(self.select.clone()));
181        }
182
183        if let Some(skip) = self.build.skip.as_ref().filter(|s| !s.is_empty()) {
184            return Ok(Filter::Skip(
185                skip.clone()
186                    .into_iter()
187                    .map(|s| Regex::new(s.file_pattern()))
188                    .collect::<Result<Vec<_>, _>>()?,
189            ));
190        }
191
192        // Exclude defaults
193        Ok(Filter::skip_default())
194    }
195
196    /// Returns an iterator over the JSON files and the contract name in the `artifacts` directory.
197    fn get_json_files(&self, artifacts: &Path) -> Result<impl Iterator<Item = (String, PathBuf)>> {
198        let filter = self.get_filter()?;
199        Ok(json_files(artifacts)
200            .filter_map(|path| {
201                // Ignore the build info JSON.
202                if path.to_str()?.contains("build-info") {
203                    return None;
204                }
205
206                // Ignore the `target` directory in case the user has built the project.
207                if path.iter().any(|comp| comp == "target") {
208                    return None;
209                }
210
211                // We don't want `.metadata.json` files.
212                let stem = path.file_stem()?.to_str()?;
213                if stem.ends_with(".metadata") {
214                    return None;
215                }
216
217                let name = stem.split('.').next().unwrap();
218
219                // Best effort identifier cleanup.
220                let name = name.replace(char::is_whitespace, "").replace('-', "_");
221
222                Some((name, path))
223            })
224            .filter(move |(name, _path)| filter.is_match(name)))
225    }
226
227    fn get_solmacrogen(&self, artifacts: &Path) -> Result<MultiSolMacroGen> {
228        let mut dup = HashSet::<String>::default();
229        let instances = self
230            .get_json_files(artifacts)?
231            .filter_map(|(name, path)| {
232                trace!(?path, "parsing SolMacroGen from file");
233                if dup.insert(name.clone()) { Some(SolMacroGen::new(path, name)) } else { None }
234            })
235            .collect::<Vec<_>>();
236
237        let multi = MultiSolMacroGen::new(artifacts, instances);
238        eyre::ensure!(!multi.instances.is_empty(), "No contract artifacts found");
239        Ok(multi)
240    }
241
242    /// Check that the existing bindings match the expected abigen output
243    fn check_existing_bindings(&self, artifacts: &Path, bindings_root: &Path) -> Result<()> {
244        let mut bindings = self.get_solmacrogen(artifacts)?;
245        bindings.generate_bindings(!self.skip_extra_derives)?;
246        sh_println!("Checking bindings for {} contracts", bindings.instances.len())?;
247        bindings.check_consistency(
248            &self.crate_name,
249            &self.crate_version,
250            bindings_root,
251            self.single_file,
252            !self.skip_cargo_toml,
253            self.module,
254            self.alloy_version.clone(),
255            self.alloy_rev.clone(),
256        )?;
257        sh_println!("OK.")?;
258        Ok(())
259    }
260
261    /// Generate the bindings
262    fn generate_bindings(&self, artifacts: &Path, bindings_root: &Path) -> Result<()> {
263        let mut solmacrogen = self.get_solmacrogen(artifacts)?;
264        sh_println!("Generating bindings for {} contracts", solmacrogen.instances.len())?;
265
266        if !self.module {
267            trace!(single_file = self.single_file, "generating crate");
268            solmacrogen.write_to_crate(
269                &self.crate_name,
270                &self.crate_version,
271                &self.crate_description,
272                &self.crate_license,
273                bindings_root,
274                self.single_file,
275                self.alloy_version.clone(),
276                self.alloy_rev.clone(),
277                !self.skip_extra_derives,
278            )?;
279        } else {
280            trace!(single_file = self.single_file, "generating module");
281            solmacrogen.write_to_module(
282                bindings_root,
283                self.single_file,
284                !self.skip_extra_derives,
285            )?;
286        }
287
288        Ok(())
289    }
290}
291
292pub enum Filter {
293    All,
294    Select(Vec<regex::Regex>),
295    Skip(Vec<regex::Regex>),
296}
297
298impl Filter {
299    pub fn is_match(&self, name: &str) -> bool {
300        match self {
301            Self::All => true,
302            Self::Select(regexes) => regexes.iter().any(|regex| regex.is_match(name)),
303            Self::Skip(regexes) => !regexes.iter().any(|regex| regex.is_match(name)),
304        }
305    }
306
307    pub fn skip_default() -> Self {
308        let skip = [
309            ".*Test.*",
310            ".*Script",
311            "console[2]?",
312            "CommonBase",
313            "Components",
314            "[Ss]td(Chains|Math|Error|Json|Utils|Cheats|Style|Invariant|Assertions|Toml|Storage(Safe)?)",
315            "[Vv]m.*",
316            "IMulticall3",
317        ]
318        .iter()
319        .map(|pattern| regex::Regex::new(pattern).unwrap())
320        .collect::<Vec<_>>();
321
322        Self::Skip(skip)
323    }
324}