1use 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 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 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 #[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 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 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
396fn 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
426fn 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
476fn 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 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}