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 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 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 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 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 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 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 #[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 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 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}