forge_sol_macro_gen/
sol_macro_gen.rs

1//! SolMacroGen and MultiSolMacroGen
2//!
3//! This type encapsulates the logic for expansion of a Rust TokenStream from Solidity tokens. It
4//! uses the `expand` method from `alloy_sol_macro_expander` underneath.
5//!
6//! It holds info such as `path` to the ABI file, `name` of the file and the rust binding being
7//! generated, and lastly the `expansion` itself, i.e the Rust binding for the provided ABI.
8//!
9//! It contains methods to read the json abi, generate rust bindings from the abi and ultimately
10//! write the bindings to a crate or modules.
11
12use alloy_sol_macro_expander::expand::expand;
13use alloy_sol_macro_input::{SolInput, SolInputKind};
14use eyre::{Context, OptionExt, Result};
15use foundry_common::fs;
16use proc_macro2::{Span, TokenStream};
17use std::{
18    fmt::Write,
19    path::{Path, PathBuf},
20    str::FromStr,
21};
22
23pub struct SolMacroGen {
24    pub path: PathBuf,
25    pub name: String,
26    pub expansion: Option<TokenStream>,
27}
28
29impl SolMacroGen {
30    pub fn new(path: PathBuf, name: String) -> Self {
31        Self { path, name, expansion: None }
32    }
33
34    pub fn get_sol_input(&self) -> Result<SolInput> {
35        let path = self.path.to_string_lossy().into_owned();
36        let name = proc_macro2::Ident::new(&self.name, Span::call_site());
37        let tokens = quote::quote! {
38            #name,
39            #path
40        };
41
42        let sol_input: SolInput = syn::parse2(tokens).wrap_err("failed to parse input")?;
43
44        Ok(sol_input)
45    }
46}
47
48pub struct MultiSolMacroGen {
49    pub artifacts_path: PathBuf,
50    pub instances: Vec<SolMacroGen>,
51}
52
53impl MultiSolMacroGen {
54    pub fn new(artifacts_path: &Path, instances: Vec<SolMacroGen>) -> Self {
55        Self { artifacts_path: artifacts_path.to_path_buf(), instances }
56    }
57
58    pub fn populate_expansion(&mut self, bindings_path: &Path) -> Result<()> {
59        for instance in &mut self.instances {
60            let path = bindings_path.join(format!("{}.rs", instance.name.to_lowercase()));
61            let expansion = fs::read_to_string(path).wrap_err("Failed to read file")?;
62
63            let tokens = TokenStream::from_str(&expansion)
64                .map_err(|e| eyre::eyre!("Failed to parse TokenStream: {e}"))?;
65            instance.expansion = Some(tokens);
66        }
67        Ok(())
68    }
69
70    pub fn generate_bindings(&mut self, all_derives: bool) -> Result<()> {
71        for instance in &mut self.instances {
72            Self::generate_binding(instance, all_derives).wrap_err_with(|| {
73                format!(
74                    "failed to generate bindings for {}:{}",
75                    instance.path.display(),
76                    instance.name
77                )
78            })?;
79        }
80
81        Ok(())
82    }
83
84    fn generate_binding(instance: &mut SolMacroGen, all_derives: bool) -> Result<()> {
85        let input = instance.get_sol_input()?.normalize_json()?;
86
87        let SolInput { attrs: _, path: _, kind } = input;
88
89        let tokens = match kind {
90            SolInputKind::Sol(mut file) => {
91                let sol_attr: syn::Attribute = syn::parse_quote! {
92                    #[sol(rpc, alloy_sol_types = alloy::sol_types, alloy_contract = alloy::contract, all_derives = #all_derives)]
93                };
94                file.attrs.push(sol_attr);
95                expand(file).wrap_err("failed to expand")?
96            }
97            _ => unreachable!(),
98        };
99
100        instance.expansion = Some(tokens);
101        Ok(())
102    }
103
104    #[allow(clippy::too_many_arguments)]
105    pub fn write_to_crate(
106        &mut self,
107        name: &str,
108        version: &str,
109        description: &str,
110        license: &str,
111        bindings_path: &Path,
112        single_file: bool,
113        alloy_version: Option<String>,
114        alloy_rev: Option<String>,
115        all_derives: bool,
116    ) -> Result<()> {
117        self.generate_bindings(all_derives)?;
118
119        let src = bindings_path.join("src");
120        let _ = fs::create_dir_all(&src);
121
122        // Write Cargo.toml
123        let cargo_toml_path = bindings_path.join("Cargo.toml");
124        let mut toml_contents = format!(
125            r#"[package]
126name = "{name}"
127version = "{version}"
128edition = "2021"
129"#
130        );
131
132        if !description.is_empty() {
133            toml_contents.push_str(&format!("description = \"{description}\"\n"));
134        }
135
136        if !license.is_empty() {
137            let formatted_licenses: Vec<String> =
138                license.split(',').map(Self::parse_license_alias).collect();
139
140            let formatted_license = formatted_licenses.join(" OR ");
141            toml_contents.push_str(&format!("license = \"{formatted_license}\"\n"));
142        }
143
144        toml_contents.push_str("\n[dependencies]\n");
145
146        let alloy_dep = Self::get_alloy_dep(alloy_version, alloy_rev);
147        write!(toml_contents, "{alloy_dep}")?;
148
149        fs::write(cargo_toml_path, toml_contents).wrap_err("Failed to write Cargo.toml")?;
150
151        let mut lib_contents = String::new();
152        write!(
153            &mut lib_contents,
154            r#"#![allow(unused_imports, clippy::all, rustdoc::all)]
155        //! This module contains the sol! generated bindings for solidity contracts.
156        //! This is autogenerated code.
157        //! Do not manually edit these files.
158        //! These files may be overwritten by the codegen system at any time.
159        "#
160        )?;
161
162        // Write src
163        let parse_error = |name: &str| {
164            format!("failed to parse generated tokens as an AST for {name};\nthis is likely a bug")
165        };
166        for instance in &self.instances {
167            let contents = instance.expansion.as_ref().unwrap();
168
169            let name = instance.name.to_lowercase();
170            let path = src.join(format!("{name}.rs"));
171            let file = syn::parse2(contents.clone())
172                .wrap_err_with(|| parse_error(&format!("{}:{}", path.display(), name)))?;
173            let contents = prettyplease::unparse(&file);
174            if single_file {
175                write!(&mut lib_contents, "{contents}")?;
176            } else {
177                fs::write(path, contents).wrap_err("failed to write to file")?;
178                write_mod_name(&mut lib_contents, &name)?;
179            }
180        }
181
182        let lib_path = src.join("lib.rs");
183        let lib_file = syn::parse_file(&lib_contents).wrap_err_with(|| parse_error("lib.rs"))?;
184        let lib_contents = prettyplease::unparse(&lib_file);
185        fs::write(lib_path, lib_contents).wrap_err("Failed to write lib.rs")?;
186
187        Ok(())
188    }
189
190    /// Attempts to detect the appropriate license.
191    pub fn parse_license_alias(license: &str) -> String {
192        match license.trim().to_lowercase().as_str() {
193            "mit" => "MIT".to_string(),
194            "apache" | "apache2" | "apache20" | "apache2.0" => "Apache-2.0".to_string(),
195            "gpl" | "gpl3" => "GPL-3.0".to_string(),
196            "lgpl" | "lgpl3" => "LGPL-3.0".to_string(),
197            "agpl" | "agpl3" => "AGPL-3.0".to_string(),
198            "bsd" | "bsd3" => "BSD-3-Clause".to_string(),
199            "bsd2" => "BSD-2-Clause".to_string(),
200            "mpl" | "mpl2" => "MPL-2.0".to_string(),
201            "isc" => "ISC".to_string(),
202            "unlicense" => "Unlicense".to_string(),
203            _ => license.trim().to_string(),
204        }
205    }
206
207    pub fn write_to_module(
208        &mut self,
209        bindings_path: &Path,
210        single_file: bool,
211        all_derives: bool,
212    ) -> Result<()> {
213        self.generate_bindings(all_derives)?;
214
215        let _ = fs::create_dir_all(bindings_path);
216
217        let mut mod_contents = r#"#![allow(unused_imports, clippy::all, rustdoc::all)]
218        //! This module contains the sol! generated bindings for solidity contracts.
219        //! This is autogenerated code.
220        //! Do not manually edit these files.
221        //! These files may be overwritten by the codegen system at any time.
222        "#
223        .to_string();
224
225        for instance in &self.instances {
226            let name = instance.name.to_lowercase();
227            if !single_file {
228                // Module
229                write_mod_name(&mut mod_contents, &name)?;
230                let mut contents = String::new();
231
232                write!(contents, "{}", instance.expansion.as_ref().unwrap())?;
233                let file = syn::parse_file(&contents)?;
234
235                let contents = prettyplease::unparse(&file);
236                fs::write(bindings_path.join(format!("{name}.rs")), contents)
237                    .wrap_err("Failed to write file")?;
238            } else {
239                // Single File
240                let mut contents = String::new();
241                write!(contents, "{}\n\n", instance.expansion.as_ref().unwrap())?;
242                write!(mod_contents, "{contents}")?;
243            }
244        }
245
246        let mod_path = bindings_path.join("mod.rs");
247        let mod_file = syn::parse_file(&mod_contents)?;
248        let mod_contents = prettyplease::unparse(&mod_file);
249
250        fs::write(mod_path, mod_contents).wrap_err("Failed to write mod.rs")?;
251
252        Ok(())
253    }
254
255    /// Checks that the generated bindings are up to date with the latest version of
256    /// `sol!`.
257    ///
258    /// Returns `Ok(())` if the generated bindings are up to date, otherwise it returns
259    /// `Err(_)`.
260    #[expect(clippy::too_many_arguments)]
261    pub fn check_consistency(
262        &self,
263        name: &str,
264        version: &str,
265        crate_path: &Path,
266        single_file: bool,
267        check_cargo_toml: bool,
268        is_mod: bool,
269        alloy_version: Option<String>,
270        alloy_rev: Option<String>,
271    ) -> Result<()> {
272        if check_cargo_toml {
273            self.check_cargo_toml(name, version, crate_path, alloy_version, alloy_rev)?;
274        }
275
276        let mut super_contents = String::new();
277        write!(
278            &mut super_contents,
279            r#"#![allow(unused_imports, clippy::all, rustdoc::all)]
280            //! This module contains the sol! generated bindings for solidity contracts.
281            //! This is autogenerated code.
282            //! Do not manually edit these files.
283            //! These files may be overwritten by the codegen system at any time.
284            "#
285        )?;
286        if !single_file {
287            for instance in &self.instances {
288                let name = instance.name.to_lowercase();
289                let path = if is_mod {
290                    crate_path.join(format!("{name}.rs"))
291                } else {
292                    crate_path.join(format!("src/{name}.rs"))
293                };
294                let tokens = instance
295                    .expansion
296                    .as_ref()
297                    .ok_or_eyre(format!("TokenStream for {path:?} does not exist"))?
298                    .to_string();
299
300                self.check_file_contents(&path, &tokens)?;
301                write_mod_name(&mut super_contents, &name)?;
302            }
303
304            let super_path =
305                if is_mod { crate_path.join("mod.rs") } else { crate_path.join("src/lib.rs") };
306            self.check_file_contents(&super_path, &super_contents)?;
307        }
308
309        Ok(())
310    }
311
312    fn check_file_contents(&self, file_path: &Path, expected_contents: &str) -> Result<()> {
313        eyre::ensure!(
314            file_path.is_file() && file_path.exists(),
315            "{} is not a file",
316            file_path.display()
317        );
318        let file_contents = &fs::read_to_string(file_path).wrap_err("Failed to read file")?;
319
320        // Format both
321        let file_contents = syn::parse_file(file_contents)?;
322        let formatted_file = prettyplease::unparse(&file_contents);
323
324        let expected_contents = syn::parse_file(expected_contents)?;
325        let formatted_exp = prettyplease::unparse(&expected_contents);
326
327        eyre::ensure!(
328            formatted_file == formatted_exp,
329            "File contents do not match expected contents for {file_path:?}"
330        );
331        Ok(())
332    }
333
334    fn check_cargo_toml(
335        &self,
336        name: &str,
337        version: &str,
338        crate_path: &Path,
339        alloy_version: Option<String>,
340        alloy_rev: Option<String>,
341    ) -> Result<()> {
342        eyre::ensure!(crate_path.is_dir(), "Crate path must be a directory");
343
344        let cargo_toml_path = crate_path.join("Cargo.toml");
345
346        eyre::ensure!(cargo_toml_path.is_file(), "Cargo.toml must exist");
347        let cargo_toml_contents =
348            fs::read_to_string(cargo_toml_path).wrap_err("Failed to read Cargo.toml")?;
349
350        let name_check = format!("name = \"{name}\"");
351        let version_check = format!("version = \"{version}\"");
352        let alloy_dep_check = Self::get_alloy_dep(alloy_version, alloy_rev);
353        let toml_consistent = cargo_toml_contents.contains(&name_check) &&
354            cargo_toml_contents.contains(&version_check) &&
355            cargo_toml_contents.contains(&alloy_dep_check);
356        eyre::ensure!(
357            toml_consistent,
358            r#"The contents of Cargo.toml do not match the expected output of the latest `sol!` version.
359                This indicates that the existing bindings are outdated and need to be generated again."#
360        );
361
362        Ok(())
363    }
364
365    /// Returns the `alloy` dependency string for the Cargo.toml file.
366    /// If `alloy_version` is provided, it will use that version from crates.io.
367    /// If `alloy_rev` is provided, it will use that revision from the GitHub repository.
368    fn get_alloy_dep(alloy_version: Option<String>, alloy_rev: Option<String>) -> String {
369        if let Some(alloy_version) = alloy_version {
370            format!(
371                r#"alloy = {{ version = "{alloy_version}", features = ["sol-types", "contract"] }}"#,
372            )
373        } else if let Some(alloy_rev) = alloy_rev {
374            format!(
375                r#"alloy = {{ git = "https://github.com/alloy-rs/alloy", rev = "{alloy_rev}", features = ["sol-types", "contract"] }}"#,
376            )
377        } else {
378            r#"alloy = { git = "https://github.com/alloy-rs/alloy", features = ["sol-types", "contract"] }"#.to_string()
379        }
380    }
381}
382
383fn write_mod_name(contents: &mut String, name: &str) -> Result<()> {
384    if syn::parse_str::<syn::Ident>(&format!("pub mod {name};")).is_ok() {
385        write!(contents, "pub mod {name};")?;
386    } else {
387        write!(contents, "pub mod r#{name};")?;
388    }
389    Ok(())
390}