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