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