Skip to main content

forge/cmd/
bind.rs

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/// CLI arguments for `forge bind`.
31#[derive(Clone, Debug, Parser)]
32pub struct BindArgs {
33    /// Path to where the contract artifacts are stored.
34    #[arg(
35        long = "bindings-path",
36        short,
37        value_hint = ValueHint::DirPath,
38        value_name = "PATH"
39    )]
40    pub bindings: Option<PathBuf>,
41
42    /// Create bindings only for contracts whose names match the specified filter(s)
43    #[arg(long)]
44    pub select: Vec<regex::Regex>,
45
46    /// Explicitly generate bindings for all contracts
47    ///
48    /// By default all contracts ending with `Test` or `Script` are excluded.
49    #[arg(long, conflicts_with_all = &["select", "skip"])]
50    pub select_all: bool,
51
52    /// The name of the Rust crate to generate.
53    ///
54    /// This should be a valid crates.io crate name,
55    /// however, this is not currently validated by this command.
56    #[arg(long, default_value = DEFAULT_CRATE_NAME, value_name = "NAME")]
57    crate_name: String,
58
59    /// The version of the Rust crate to generate.
60    ///
61    /// This should be a standard semver version string,
62    /// however, this is not currently validated by this command.
63    #[arg(long, default_value = DEFAULT_CRATE_VERSION, value_name = "VERSION")]
64    crate_version: String,
65
66    /// The description of the Rust crate to generate.
67    ///
68    /// This will be added to the package.description field in Cargo.toml.
69    #[arg(long, default_value = "", value_name = "DESCRIPTION")]
70    crate_description: String,
71
72    /// The license of the Rust crate to generate.
73    ///
74    /// This will be added to the package.license field in Cargo.toml.
75    #[arg(long, value_name = "LICENSE", default_value = "")]
76    crate_license: String,
77
78    /// Generate the bindings as a module instead of a crate.
79    #[arg(long)]
80    module: bool,
81
82    /// Overwrite existing generated bindings.
83    ///
84    /// By default, the command will check that the bindings are correct, and then exit. If
85    /// --overwrite is passed, it will instead delete and overwrite the bindings.
86    #[arg(long)]
87    overwrite: bool,
88
89    /// Generate bindings as a single file.
90    #[arg(long)]
91    single_file: bool,
92
93    /// Skip Cargo.toml consistency checks.
94    #[arg(long)]
95    skip_cargo_toml: bool,
96
97    /// Skips running forge build before generating binding
98    #[arg(long)]
99    skip_build: bool,
100
101    /// Don't add any additional derives to generated bindings
102    #[arg(long)]
103    skip_extra_derives: bool,
104
105    /// Generate bindings for the `alloy` library, instead of `ethers`.
106    #[arg(long, hide = true)]
107    alloy: bool,
108
109    /// Specify the `alloy` version on Crates.
110    #[arg(long)]
111    alloy_version: Option<String>,
112
113    /// Specify the `alloy` revision on GitHub.
114    #[arg(long, conflicts_with = "alloy_version")]
115    alloy_rev: Option<String>,
116
117    /// Generate bindings for the `ethers` library (removed), instead of `alloy`.
118    #[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            // Select all json files
177            return Ok(Filter::All);
178        }
179        if !self.select.is_empty() {
180            // Return json files that match the select regex
181            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        // Exclude defaults
194        Ok(Filter::skip_default())
195    }
196
197    /// Returns an iterator over the JSON files and the contract name in the `artifacts` directory.
198    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                // Ignore the build info JSON.
203                if path.to_str()?.contains("build-info") {
204                    return None;
205                }
206
207                // Ignore the `target` directory in case the user has built the project.
208                if path.iter().any(|comp| comp == "target") {
209                    return None;
210                }
211
212                // We don't want `.metadata.json` files.
213                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                // Best effort identifier cleanup.
221                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    /// Check that the existing bindings match the expected abigen output
244    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    /// Generate the bindings
265    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}