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