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(&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            // Select all json files
170            return Ok(Filter::All);
171        }
172        if !self.select.is_empty() {
173            // Return json files that match the select regex
174            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        // Exclude defaults
187        Ok(Filter::skip_default())
188    }
189
190    /// Returns an iterator over the JSON files and the contract name in the `artifacts` directory.
191    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                // Ignore the build info JSON.
196                if path.to_str()?.contains("build-info") {
197                    return None;
198                }
199
200                // Ignore the `target` directory in case the user has built the project.
201                if path.iter().any(|comp| comp == "target") {
202                    return None;
203                }
204
205                // We don't want `.metadata.json` files.
206                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                // Best effort identifier cleanup.
214                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    /// Check that the existing bindings match the expected abigen output
237    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    /// Generate the bindings
258    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}