Skip to main content

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 rayon::prelude::*;
18use std::{
19    fmt::Write,
20    path::{Path, PathBuf},
21};
22
23use heck::ToSnakeCase;
24
25const SERDE_ARRAY_IMPL_MAX_LEN: usize = 32;
26const SERDE_WITH_DEP: &str =
27    r#"serde_with = { version = "3.15", default-features = false, features = ["std"] }"#;
28
29pub struct SolMacroGen {
30    pub path: PathBuf,
31    pub name: String,
32    pub expansion: Option<String>,
33    needs_serde_with: bool,
34}
35
36impl SolMacroGen {
37    pub const fn new(path: PathBuf, name: String) -> Self {
38        Self { path, name, expansion: None, needs_serde_with: false }
39    }
40
41    pub fn get_sol_input(&self) -> Result<SolInput> {
42        let path = self.path.to_string_lossy().into_owned();
43        let name = proc_macro2::Ident::new(&self.name, Span::call_site());
44        let tokens = quote::quote! {
45            #[sol(ignore_unlinked)]
46            #name,
47            #path
48        };
49
50        let sol_input: SolInput = syn::parse2(tokens).wrap_err("failed to parse input")?;
51
52        Ok(sol_input)
53    }
54}
55
56pub struct MultiSolMacroGen {
57    pub instances: Vec<SolMacroGen>,
58}
59
60impl MultiSolMacroGen {
61    pub const fn new(instances: Vec<SolMacroGen>) -> Self {
62        Self { instances }
63    }
64
65    pub fn populate_expansion(&mut self, bindings_path: &Path) -> Result<()> {
66        for instance in &mut self.instances {
67            let path = bindings_path.join(format!("{}.rs", instance.name.to_snake_case()));
68            let expansion = fs::read_to_string(path).wrap_err("Failed to read file")?;
69            expansion
70                .parse::<TokenStream>()
71                .map_err(|e| eyre::eyre!("Failed to parse TokenStream: {e}"))?;
72            instance.expansion = Some(expansion);
73        }
74        Ok(())
75    }
76
77    pub fn generate_bindings(&mut self, all_derives: bool) -> Result<()> {
78        self.instances.par_iter_mut().try_for_each(|instance| {
79            Self::generate_binding(instance, all_derives).wrap_err_with(|| {
80                format!(
81                    "failed to generate bindings for {}:{}",
82                    instance.path.display(),
83                    instance.name
84                )
85            })
86        })
87    }
88
89    fn generate_binding(instance: &mut SolMacroGen, all_derives: bool) -> Result<()> {
90        let input = instance.get_sol_input()?.normalize_json()?;
91        let SolInput { attrs: _, path: _, kind } = input;
92
93        let tokens = match kind {
94            SolInputKind::Sol(mut file) => {
95                let sol_attr: syn::Attribute = if all_derives {
96                    syn::parse_quote! {
97                            #[sol(rpc, alloy_sol_types = alloy::sol_types, alloy_contract =
98                    alloy::contract, all_derives = true, extra_derives(serde::Serialize,
99                    serde::Deserialize))]     }
100                } else {
101                    syn::parse_quote! {
102                            #[sol(rpc, alloy_sol_types = alloy::sol_types, alloy_contract =
103                    alloy::contract)]     }
104                };
105                file.attrs.push(sol_attr);
106                expand(file).wrap_err("failed to expand")?
107            }
108            _ => unreachable!(),
109        };
110
111        let (tokens, needs_serde_with) =
112            if all_derives { add_large_array_serde_attrs(tokens)? } else { (tokens, false) };
113
114        let file = syn::parse2(tokens).wrap_err("failed to parse generated tokens as an AST")?;
115        instance.expansion =
116            Some(qualify_shadowed_sibling_module_paths(prettyplease::unparse(&file)));
117        instance.needs_serde_with = needs_serde_with;
118        Ok(())
119    }
120
121    #[allow(clippy::too_many_arguments)]
122    pub fn write_to_crate(
123        &mut self,
124        name: &str,
125        version: &str,
126        description: &str,
127        license: &str,
128        bindings_path: &Path,
129        single_file: bool,
130        alloy_version: Option<String>,
131        alloy_rev: Option<String>,
132        all_derives: bool,
133    ) -> Result<()> {
134        self.generate_bindings(all_derives)?;
135
136        let src = bindings_path.join("src");
137        fs::create_dir_all(&src)?;
138
139        // Write Cargo.toml
140        let cargo_toml_path = bindings_path.join("Cargo.toml");
141        let mut toml_contents = format!(
142            r#"[package]
143name = "{name}"
144version = "{version}"
145edition = "2021"
146"#
147        );
148
149        if !description.is_empty() {
150            toml_contents.push_str(&format!("description = \"{description}\"\n"));
151        }
152
153        if !license.is_empty() {
154            let formatted_licenses: Vec<String> =
155                license.split(',').map(Self::parse_license_alias).collect();
156
157            let formatted_license = formatted_licenses.join(" OR ");
158            toml_contents.push_str(&format!("license = \"{formatted_license}\"\n"));
159        }
160
161        toml_contents.push_str("\n[dependencies]\n");
162
163        let alloy_dep = Self::get_alloy_dep(alloy_version, alloy_rev);
164        write!(toml_contents, "{alloy_dep}")?;
165
166        if all_derives {
167            let serde_dep = r#"serde = { version = "1.0", features = ["derive"] }"#;
168            write!(toml_contents, "\n{serde_dep}")?;
169            if self.instances.iter().any(|instance| instance.needs_serde_with) {
170                write!(toml_contents, "\n{SERDE_WITH_DEP}")?;
171            }
172        }
173
174        fs::write(cargo_toml_path, toml_contents).wrap_err("Failed to write Cargo.toml")?;
175
176        let mut lib_contents = String::new();
177        write!(
178            &mut lib_contents,
179            r#"#![allow(unused_imports, unused_attributes, clippy::all, rustdoc::all)]
180        //! This module contains the sol! generated bindings for solidity contracts.
181        //! This is autogenerated code.
182        //! Do not manually edit these files.
183        //! These files may be overwritten by the codegen system at any time.
184        "#
185        )?;
186
187        for instance in &self.instances {
188            let contents = instance.expansion.as_ref().unwrap();
189            let name = instance.name.to_snake_case();
190            let path = src.join(format!("{name}.rs"));
191            if single_file {
192                write!(&mut lib_contents, "{contents}")?;
193            } else {
194                fs::write(path, contents).wrap_err("failed to write to file")?;
195                write_mod_name(&mut lib_contents, &name)?;
196            }
197        }
198
199        let lib_path = src.join("lib.rs");
200        let lib_file = syn::parse_file(&lib_contents).wrap_err(
201            "failed to parse generated tokens as an AST for lib.rs;\nthis is likely a bug",
202        )?;
203        let lib_contents = prettyplease::unparse(&lib_file);
204        fs::write(lib_path, lib_contents).wrap_err("Failed to write lib.rs")?;
205
206        Ok(())
207    }
208
209    /// Attempts to detect the appropriate license.
210    pub fn parse_license_alias(license: &str) -> String {
211        match license.trim().to_lowercase().as_str() {
212            "mit" => "MIT".to_string(),
213            "apache" | "apache2" | "apache20" | "apache2.0" => "Apache-2.0".to_string(),
214            "gpl" | "gpl3" => "GPL-3.0".to_string(),
215            "lgpl" | "lgpl3" => "LGPL-3.0".to_string(),
216            "agpl" | "agpl3" => "AGPL-3.0".to_string(),
217            "bsd" | "bsd3" => "BSD-3-Clause".to_string(),
218            "bsd2" => "BSD-2-Clause".to_string(),
219            "mpl" | "mpl2" => "MPL-2.0".to_string(),
220            "isc" => "ISC".to_string(),
221            "unlicense" => "Unlicense".to_string(),
222            _ => license.trim().to_string(),
223        }
224    }
225
226    pub fn write_to_module(
227        &mut self,
228        bindings_path: &Path,
229        single_file: bool,
230        all_derives: bool,
231    ) -> Result<()> {
232        self.generate_bindings(all_derives)?;
233
234        fs::create_dir_all(bindings_path)?;
235
236        let mut mod_contents =
237            r#"#![allow(unused_imports, unused_attributes, clippy::all, rustdoc::all)]
238        //! This module contains the sol! generated bindings for solidity contracts.
239        //! This is autogenerated code.
240        //! Do not manually edit these files.
241        //! These files may be overwritten by the codegen system at any time.
242        "#
243            .to_string();
244
245        for instance in &self.instances {
246            let name = instance.name.to_snake_case();
247            if single_file {
248                // Single File
249                let mut contents = String::new();
250                write!(contents, "{}\n\n", instance.expansion.as_ref().unwrap())?;
251                write!(mod_contents, "{contents}")?;
252            } else {
253                // Module
254                write_mod_name(&mut mod_contents, &name)?;
255                fs::write(
256                    bindings_path.join(format!("{name}.rs")),
257                    instance.expansion.as_ref().unwrap(),
258                )
259                .wrap_err("Failed to write file")?;
260            }
261        }
262
263        let mod_path = bindings_path.join("mod.rs");
264        let mod_file = syn::parse_file(&mod_contents)?;
265        let mod_contents = qualify_shadowed_sibling_module_paths(prettyplease::unparse(&mod_file));
266
267        fs::write(mod_path, mod_contents).wrap_err("Failed to write mod.rs")?;
268
269        Ok(())
270    }
271
272    /// Checks that the generated bindings are up to date with the latest version of
273    /// `sol!`.
274    ///
275    /// Returns `Ok(())` if the generated bindings are up to date, otherwise it returns
276    /// `Err(_)`.
277    #[expect(clippy::too_many_arguments)]
278    pub fn check_consistency(
279        &self,
280        name: &str,
281        version: &str,
282        crate_path: &Path,
283        single_file: bool,
284        check_cargo_toml: bool,
285        is_mod: bool,
286        alloy_version: Option<String>,
287        alloy_rev: Option<String>,
288    ) -> Result<()> {
289        if check_cargo_toml && !is_mod {
290            self.check_cargo_toml(name, version, crate_path, alloy_version, alloy_rev)?;
291        }
292
293        let mut super_contents = String::new();
294        write!(
295            &mut super_contents,
296            r#"#![allow(unused_imports, unused_attributes, clippy::all, rustdoc::all)]
297            //! This module contains the sol! generated bindings for solidity contracts.
298            //! This is autogenerated code.
299            //! Do not manually edit these files.
300            //! These files may be overwritten by the codegen system at any time.
301            "#
302        )?;
303        if !single_file {
304            for instance in &self.instances {
305                let name = instance.name.to_snake_case();
306                let path = if is_mod {
307                    crate_path.join(format!("{name}.rs"))
308                } else {
309                    crate_path.join(format!("src/{name}.rs"))
310                };
311                let contents = instance
312                    .expansion
313                    .as_ref()
314                    .ok_or_eyre(format!("TokenStream for {path:?} does not exist"))?;
315
316                self.check_file_contents(&path, contents)?;
317                write_mod_name(&mut super_contents, &name)?;
318            }
319
320            let super_path =
321                if is_mod { crate_path.join("mod.rs") } else { crate_path.join("src/lib.rs") };
322            self.check_file_contents(&super_path, &super_contents)?;
323        }
324
325        Ok(())
326    }
327
328    fn check_file_contents(&self, file_path: &Path, expected_contents: &str) -> Result<()> {
329        eyre::ensure!(file_path.is_file(), "{} is not a file", file_path.display());
330        let file_contents = &fs::read_to_string(file_path).wrap_err("Failed to read file")?;
331
332        // Format both
333        let file_contents = syn::parse_file(file_contents)?;
334        let formatted_file = prettyplease::unparse(&file_contents);
335
336        let expected_contents = syn::parse_file(expected_contents)?;
337        let formatted_exp =
338            qualify_shadowed_sibling_module_paths(prettyplease::unparse(&expected_contents));
339
340        eyre::ensure!(
341            formatted_file == formatted_exp,
342            "File contents do not match expected contents for {file_path:?}"
343        );
344        Ok(())
345    }
346
347    fn check_cargo_toml(
348        &self,
349        name: &str,
350        version: &str,
351        crate_path: &Path,
352        alloy_version: Option<String>,
353        alloy_rev: Option<String>,
354    ) -> Result<()> {
355        eyre::ensure!(crate_path.is_dir(), "Crate path must be a directory");
356
357        let cargo_toml_path = crate_path.join("Cargo.toml");
358
359        eyre::ensure!(cargo_toml_path.is_file(), "Cargo.toml must exist");
360        let cargo_toml_contents =
361            fs::read_to_string(cargo_toml_path).wrap_err("Failed to read Cargo.toml")?;
362
363        let name_check = format!("name = \"{name}\"");
364        let version_check = format!("version = \"{version}\"");
365        let alloy_dep_check = Self::get_alloy_dep(alloy_version, alloy_rev);
366        let serde_with_consistent =
367            !self.instances.iter().any(|instance| instance.needs_serde_with)
368                || cargo_toml_contents.contains(SERDE_WITH_DEP);
369        let toml_consistent = cargo_toml_contents.contains(&name_check)
370            && cargo_toml_contents.contains(&version_check)
371            && cargo_toml_contents.contains(&alloy_dep_check)
372            && serde_with_consistent;
373        eyre::ensure!(
374            toml_consistent,
375            r#"The contents of Cargo.toml do not match the expected output of the latest `sol!` version.
376                This indicates that the existing bindings are outdated and need to be generated again."#
377        );
378
379        Ok(())
380    }
381
382    /// Returns the `alloy` dependency string for the Cargo.toml file.
383    /// If `alloy_version` is provided, it will use that version from crates.io.
384    /// If `alloy_rev` is provided, it will use that revision from the GitHub repository.
385    fn get_alloy_dep(alloy_version: Option<String>, alloy_rev: Option<String>) -> String {
386        if let Some(alloy_version) = alloy_version {
387            format!(
388                r#"alloy = {{ version = "{alloy_version}", features = ["sol-types", "contract"] }}"#,
389            )
390        } else if let Some(alloy_rev) = alloy_rev {
391            format!(
392                r#"alloy = {{ git = "https://github.com/alloy-rs/alloy", rev = "{alloy_rev}", features = ["sol-types", "contract"] }}"#,
393            )
394        } else {
395            r#"alloy = { version = "1.0", features = ["sol-types", "contract"] }"#.to_string()
396        }
397    }
398}
399
400#[derive(Default)]
401struct LargeArraySerdeAttrs {
402    added: bool,
403}
404
405impl syn::visit_mut::VisitMut for LargeArraySerdeAttrs {
406    fn visit_item_struct_mut(&mut self, item: &mut syn::ItemStruct) {
407        for field in &mut item.fields {
408            if let Some(adapter) = large_array_serde_adapter(&field.ty) {
409                let adapter =
410                    syn::LitStr::new(&format!("::serde_with::As::<{adapter}>"), Span::call_site());
411                field.attrs.insert(0, syn::parse_quote!(#[serde(with = #adapter)]));
412                self.added = true;
413            }
414        }
415    }
416}
417
418/// Adds `serde_with` adapters where Serde's built-in array implementations stop.
419fn add_large_array_serde_attrs(tokens: TokenStream) -> Result<(TokenStream, bool)> {
420    let mut file = syn::parse2::<syn::File>(tokens)
421        .wrap_err("failed to parse generated bindings for large array support")?;
422    let mut visitor = LargeArraySerdeAttrs::default();
423    syn::visit_mut::VisitMut::visit_file_mut(&mut visitor, &mut file);
424    Ok((quote::quote!(#file), visitor.added))
425}
426
427fn large_array_serde_adapter(ty: &syn::Type) -> Option<String> {
428    match ty {
429        syn::Type::Array(array) => {
430            let syn::Expr::Lit(expr) = &array.len else { return None };
431            let syn::Lit::Int(length) = &expr.lit else { return None };
432            let element = large_array_serde_adapter(&array.elem);
433            let is_large =
434                length.base10_parse::<usize>().is_ok_and(|len| len > SERDE_ARRAY_IMPL_MAX_LEN);
435            if !is_large && element.is_none() {
436                return None;
437            }
438
439            let element = element.unwrap_or_else(|| "::serde_with::Same".to_string());
440            Some(format!("[{element}; {}]", length.base10_digits()))
441        }
442        syn::Type::Path(type_path) if type_path.qself.is_none() => {
443            let last = type_path.path.segments.last()?;
444            if last.ident == "Vec"
445                && let syn::PathArguments::AngleBracketed(arguments) = &last.arguments
446                && arguments.args.len() == 1
447                && let Some(syn::GenericArgument::Type(element)) = arguments.args.first()
448                && let Some(element) = large_array_serde_adapter(element)
449            {
450                Some(format!("::std::vec::Vec<{element}>"))
451            } else {
452                None
453            }
454        }
455        _ => None,
456    }
457}
458
459fn write_mod_name(contents: &mut String, name: &str) -> Result<()> {
460    if syn::parse_str::<syn::Ident>(name).is_ok() {
461        write!(contents, "pub mod {name};")?;
462    } else {
463        write!(contents, "pub mod r#{name};")?;
464    }
465    Ok(())
466}
467
468/// Qualifies paths to sibling binding modules when a generated item in the current module shadows
469/// that module name.
470///
471/// Alloy names the event enum for a contract module by appending `Events` to the contract name. If
472/// the ABI also contains a sibling contract/interface with that exact name, inherited event
473/// parameter types such as `IExampleContractEvents::SomeEventData` resolve to the local event enum
474/// instead of the sibling module that owns `SomeEventData`. Qualifying those paths with `super::`
475/// keeps the generated binding compiling without changing the upstream `sol!` expansion.
476fn qualify_shadowed_sibling_module_paths(mut contents: String) -> String {
477    let module_names = top_level_module_names(&contents);
478    let enum_names = public_enum_names(&contents);
479
480    for module_name in module_names {
481        if enum_names.iter().any(|enum_name| enum_name == &module_name) {
482            contents = qualify_unqualified_module_paths(&contents, &module_name);
483        }
484    }
485
486    contents
487}
488
489fn qualify_unqualified_module_paths(contents: &str, module_name: &str) -> String {
490    let needle = format!("{module_name}::");
491    let replacement = format!("super::{module_name}::");
492    let mut qualified = String::with_capacity(contents.len());
493    let mut rest = contents;
494
495    while let Some(index) = rest.find(&needle) {
496        let (before, after) = rest.split_at(index);
497        qualified.push_str(before);
498
499        let boundary = before
500            .chars()
501            .next_back()
502            .is_none_or(|c| !matches!(c, '_' | '0'..='9' | 'a'..='z' | 'A'..='Z' | ':'));
503
504        if boundary {
505            qualified.push_str(&replacement);
506        } else {
507            qualified.push_str(&needle);
508        }
509
510        rest = &after[needle.len()..];
511    }
512
513    qualified.push_str(rest);
514    qualified
515}
516
517fn top_level_module_names(contents: &str) -> Vec<String> {
518    contents
519        .split("pub mod ")
520        .skip(1)
521        .filter_map(|rest| rest.split_whitespace().next())
522        .map(|name| name.trim_start_matches("r#").to_string())
523        .collect()
524}
525
526fn public_enum_names(contents: &str) -> Vec<String> {
527    contents
528        .split("pub enum ")
529        .skip(1)
530        .filter_map(|rest| rest.split_whitespace().next())
531        .map(|name| name.trim_start_matches("r#").to_string())
532        .collect()
533}
534
535#[cfg(test)]
536mod tests {
537    use super::large_array_serde_adapter;
538
539    fn adapter(ty: &str) -> Option<String> {
540        large_array_serde_adapter(&syn::parse_str(ty).unwrap())
541    }
542
543    #[test]
544    fn builds_large_array_serde_adapters() {
545        assert_eq!(adapter("[u64; 32]"), None);
546        assert_eq!(adapter("[u64; 33]"), Some("[::serde_with::Same; 33]".to_string()));
547        assert_eq!(adapter("[[u64; 48]; 2]"), Some("[[::serde_with::Same; 48]; 2]".to_string()));
548        assert_eq!(
549            adapter("alloy::sol_types::private::Vec<[u64; 48]>"),
550            Some("::std::vec::Vec<[::serde_with::Same; 48]>".to_string())
551        );
552    }
553}