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