1use std::{fs::File, io::Read, path::Path};
2
3use proc_macro2::Span;
4use quote::format_ident;
5use syn::{ext::IdentExt, parse_quote};
6
7use crate::{
8 extract_docs, ir::ParsedTestCase, utils::add_indent, ParsedResult, SavvyEnum, SavvyFn,
9 SavvyImpl, SavvyStruct,
10};
11
12fn is_savvified(attrs: &[syn::Attribute]) -> bool {
13 attrs.iter().any(|attr| attr == &parse_quote!(#[savvy]))
14}
15
16fn is_savvified_init(attrs: &[syn::Attribute]) -> bool {
17 attrs
18 .iter()
19 .any(|attr| attr == &parse_quote!(#[savvy_init]))
20}
21
22pub fn read_file(path: &Path) -> String {
23 if !path.exists() {
24 eprintln!("{} does not exist", path.to_string_lossy());
25 std::process::exit(1);
26 }
27
28 let mut file = match File::open(path) {
29 Ok(file) => file,
30 Err(_) => {
31 eprintln!("Failed to read the specified file");
32 std::process::exit(2);
33 }
34 };
35
36 let mut content = String::new();
37 if file.read_to_string(&mut content).is_err() {
38 eprintln!("Failed to read the specified file");
39 std::process::exit(2);
40 };
41
42 content
43}
44
45pub fn parse_file(path: &Path, mod_path: &[String]) -> ParsedResult {
46 let location = &path.to_string_lossy();
47 let file_content = read_file(path);
48
49 let module_level_docs: Vec<&str> = file_content
54 .lines()
55 .filter(|x| x.trim().starts_with("//!"))
56 .map(|x| x.split_at(3).1.trim())
57 .collect();
58
59 let tests = parse_doctests(&module_level_docs, "module-level doc", location);
60
61 let mut result = ParsedResult {
62 base_path: path
63 .parent()
64 .expect("Should have a parent dir")
65 .to_path_buf(),
66 bare_fns: Vec::new(),
67 impls: Vec::new(),
68 structs: Vec::new(),
69 enums: Vec::new(),
70 mod_path: mod_path.to_vec(),
71 child_mods: Vec::new(),
72 tests,
73 };
74
75 match syn::parse_str::<syn::File>(&file_content) {
76 Ok(file) => {
77 for item in file.items {
78 result.parse_item(&item, location)
79 }
80 }
81 Err(e) => {
82 eprintln!("Failed to parse the specified file: {location}\n");
83 eprintln!("Error:\n{e}\n");
84 eprintln!("Code:\n{file_content}\n");
85 std::process::exit(3);
86 }
87 };
88
89 result
90}
91
92impl ParsedResult {
93 fn parse_item(&mut self, item: &syn::Item, location: &str) {
94 match item {
95 syn::Item::Fn(item_fn) => {
96 if is_savvified(item_fn.attrs.as_slice()) {
97 self.bare_fns
98 .push(SavvyFn::from_fn(item_fn, false).expect("Failed to parse function"))
99 }
100
101 if is_savvified_init(item_fn.attrs.as_slice()) {
102 self.bare_fns
103 .push(SavvyFn::from_fn(item_fn, true).expect("Failed to parse function"))
104 }
105
106 let label = format!("fn {}", item_fn.sig.ident);
107
108 self.tests.append(&mut parse_doctests(
109 &extract_docs(&item_fn.attrs),
110 &label,
111 location,
112 ))
113 }
114
115 syn::Item::Impl(item_impl) => {
116 if is_savvified(item_impl.attrs.as_slice()) {
117 self.impls
118 .push(SavvyImpl::new(item_impl).expect("Failed to parse impl"))
119 }
120
121 let self_ty = match item_impl.self_ty.as_ref() {
122 syn::Type::Path(p) => p.path.segments.last().unwrap().ident.to_string(),
123 _ => "(unknown)".to_string(),
124 };
125 let label = format!("impl {self_ty}");
126
127 item_impl
128 .items
129 .iter()
130 .for_each(|x| self.parse_impl_item(x, &label, location));
131
132 self.tests.append(&mut parse_doctests(
133 &extract_docs(&item_impl.attrs),
134 &label,
135 location,
136 ))
137 }
138
139 syn::Item::Struct(item_struct) => {
140 if is_savvified(item_struct.attrs.as_slice()) {
141 self.structs
142 .push(SavvyStruct::new(item_struct).expect("Failed to parse struct"))
143 }
144
145 let label = format!("struct {}", item_struct.ident);
146
147 self.tests.append(&mut parse_doctests(
148 &extract_docs(&item_struct.attrs),
149 &label,
150 location,
151 ))
152 }
153
154 syn::Item::Enum(item_enum) => {
155 if is_savvified(item_enum.attrs.as_slice()) {
156 self.enums
157 .push(SavvyEnum::new(item_enum).expect("Failed to parse enum"))
158 }
159
160 let label = format!("enum {}", item_enum.ident);
161
162 self.tests.append(&mut parse_doctests(
163 &extract_docs(&item_enum.attrs),
164 &label,
165 location,
166 ))
167 }
168
169 syn::Item::Mod(item_mod) => {
170 let is_test_mod = item_mod
171 .attrs
172 .iter()
173 .any(|attr| attr == &parse_quote!(#[cfg(feature = "savvy-test")]));
174
175 match (&item_mod.content, is_test_mod) {
176 (None, false) => {
177 self.child_mods.push(item_mod.ident.unraw().to_string());
178 }
179 (None, true) => {}
180 (Some((_, items)), false) => {
181 items.iter().for_each(|i| self.parse_item(i, location));
182 }
183 (Some(_), true) => {
184 let label = self.mod_path.join("::");
185 let mut cur_mod_path = self.mod_path.clone();
186 cur_mod_path.push(item_mod.ident.unraw().to_string());
187
188 self.tests.push(transform_test_mod(
189 item_mod,
190 &label,
191 location,
192 &cur_mod_path,
193 ))
194 }
195 }
196 }
197
198 syn::Item::Macro(item_macro) => {
199 let ident = match &item_macro.ident {
200 Some(i) => i.to_string(),
201 None => "unknown".to_string(),
202 };
203 let label = format!("macro {ident}");
204
205 self.tests.append(&mut parse_doctests(
206 &extract_docs(&item_macro.attrs),
207 &label,
208 location,
209 ))
210 }
211
212 _ => {}
213 };
214 }
215
216 fn parse_impl_item(&mut self, item: &syn::ImplItem, label: &str, location: &str) {
217 let (attrs, label) = match item {
218 syn::ImplItem::Const(c) => (&c.attrs, format!("{}::{}", label, c.ident)),
219 syn::ImplItem::Fn(f) => (&f.attrs, format!("{}::{}", label, f.sig.ident)),
220 syn::ImplItem::Type(t) => (&t.attrs, format!("{}::{}", label, t.ident)),
221 syn::ImplItem::Macro(m) => (
222 &m.attrs,
223 format!("{}::{}", label, m.mac.path.segments.last().unwrap().ident),
224 ),
225 syn::ImplItem::Verbatim(_) => return,
226 _ => return,
227 };
228
229 self.tests
230 .append(&mut parse_doctests(&extract_docs(attrs), &label, location))
231 }
232}
233
234fn parse_doctests<T: AsRef<str>>(lines: &[T], label: &str, location: &str) -> Vec<ParsedTestCase> {
235 let mut out: Vec<ParsedTestCase> = Vec::new();
236
237 let mut in_code_block = false;
238 let mut ignore = false;
239 let mut code_block: Vec<String> = Vec::new();
240 let mut spaces = 0;
241 for line_orig in lines {
242 let line = line_orig.as_ref();
243
244 if line.trim().starts_with("```") {
245 if !in_code_block {
246 spaces = line.len() - line.trim().len();
249
250 in_code_block = true;
251 let code_attr = line.trim().strip_prefix("```").unwrap().trim();
252 ignore = match code_attr {
253 "ignore" | "no_run" | "text" => true,
254 "" => false,
255 _ => {
256 eprintln!("[WARN] Ignoring unsupported code block attribute: {code_attr}");
257 true
258 }
259 }
260 } else {
261 if !ignore {
264 let orig_code = code_block.join("\n");
265 let code_parsed =
266 match syn::parse_str::<syn::Block>(&format!("{{ {orig_code} }}")) {
267 Ok(block) => block.stmts,
268 Err(e) => {
269 eprintln!("Failed to parse the specified file: {location}\n");
270 eprintln!("Error:\n{e}\n");
271 eprintln!("Code:\n{orig_code}\n");
272 std::process::exit(3);
273 }
274 };
275
276 let test_fn = wrap_with_test_function(
277 &orig_code,
278 &code_parsed,
279 &format_ident!("doctest"),
280 label,
281 location,
282 true,
283 );
284
285 out.push(ParsedTestCase {
286 orig_code,
287 label: label.to_string(),
288 location: location.to_string(),
289 code: unparse(&test_fn),
290 });
291 }
292
293 code_block.truncate(0);
294
295 in_code_block = false;
297 ignore = false;
298 spaces = 0;
299 }
300 continue;
301 }
302
303 if in_code_block {
304 let line = if line.len() <= spaces {
305 ""
306 } else {
307 line.split_at(spaces).1
308 };
309
310 let line = if line.trim_start().starts_with('#') {
315 line.trim_start_matches(|c: char| c.is_whitespace() || c == '#')
316 } else {
317 line
318 };
319
320 code_block.push(line.to_string());
321 }
322 }
323
324 out
325}
326
327#[cfg(feature = "use_formatter")]
328fn unparse<T: quote::ToTokens>(item: &T) -> String {
329 let code_parsed: syn::File = parse_quote!(#item);
330 prettyplease::unparse(&code_parsed).replace(r#"\n"#, "\n")
332}
333
334#[cfg(not(feature = "use_formatter"))]
335fn unparse<T: quote::ToTokens>(item: &T) -> String {
336 quote::quote!(#item).to_string()
337}
338
339fn transform_test_mod(
340 item_mod: &syn::ItemMod,
341 label: &str,
342 location: &str,
343 mod_path: &[String],
344) -> ParsedTestCase {
345 let mut item_mod = item_mod.clone();
346
347 item_mod
349 .attrs
350 .retain(|attr| attr != &parse_quote!(#[cfg(feature = "savvy-test")]));
351
352 item_mod.ident = format_ident!("__UNIQUE_PREFIX__mod_{}", item_mod.ident);
353
354 if let Some((_, items)) = &mut item_mod.content {
355 items.insert(
356 0,
357 parse_quote!(
358 use savvy::savvy;
359 ),
360 );
361
362 for item in items {
363 if let syn::Item::Fn(item_fn) = item {
364 let orig_code = unparse(&item_fn);
365 let orig_len = item_fn.attrs.len();
366
367 item_fn.attrs.retain(|attr| attr != &parse_quote!(#[test]));
368
369 if item_fn.attrs.len() < orig_len {
371 item_fn.attrs.push(parse_quote!(#[savvy]));
372 item_fn.sig.ident = format_ident!("__UNIQUE_PREFIX__fn_{}", item_fn.sig.ident);
373
374 *item_fn = wrap_with_test_function(
375 &orig_code,
376 &item_fn.block.stmts,
377 &item_fn.sig.ident,
378 label,
379 location,
380 false,
381 );
382 }
383 }
384 }
385 }
386
387 let (_last, rest) = mod_path.split_last().unwrap();
388 let code = unparse(&item_mod)
389 .replace("super::", &format!("{}::", rest.join("::")))
391 .replace("crate::", &format!("{}::", mod_path.first().unwrap()))
392 .replace("super ::", &format!("{}::", rest.join("::")))
394 .replace("crate ::", &format!("{}::", mod_path.first().unwrap()))
395 .replace("savvy_show_error", "crate::savvy_show_error");
397
398 ParsedTestCase {
399 label: label.to_string(),
400 orig_code: "".to_string(),
401 location: location.to_string(),
402 code,
403 }
404}
405
406pub fn generate_test_code(parsed_results: &Vec<ParsedResult>) -> String {
407 let header: syn::File = parse_quote! {
408 #[allow(unused_imports)]
409 use savvy::savvy;
410
411 pub(crate) fn savvy_show_error(code: &str, label: &str, location: &str, panic_info: &std::panic::PanicHookInfo) {
412 let mut msg: Vec<String> = Vec::new();
413 let orig_msg = panic_info.to_string();
414 let mut lines = orig_msg.lines();
415
416 lines.next(); for line in lines {
419 msg.push(format!(" {}", line));
420 }
421
422 let error = msg.join("\n");
423
424 savvy::r_eprintln!(
425 "
426
427Location:
428 {label} (file: {location})
429
430Code:
431{code}
432
433Error:
434{error}
435 ");
436 }
437 };
438
439 let mut out = unparse(&header);
440 out.push_str("\n\n");
441
442 let mut i = 0;
443 for result in parsed_results {
444 for test in &result.tests {
445 i += 1;
446 out.push_str(
447 &test
448 .code
449 .replace("__UNIQUE_PREFIX__", &format!("test_{i}_")),
450 );
451 out.push_str("\n\n");
452 }
453 }
454
455 out
456}
457
458fn wrap_with_test_function(
459 orig_code: &str,
460 code_parsed: &[syn::Stmt],
461 orig_ident: &syn::Ident,
462 label: &str,
463 location: &str,
464 is_doctest: bool,
465) -> syn::ItemFn {
466 let test_type = if is_doctest { "doctest" } else { "test" };
467 let msg_lit = syn::LitStr::new(
468 &format!("running {test_type} of {label} (file: {location}) ..."),
469 Span::call_site(),
470 );
471
472 let label_lit = syn::LitStr::new(label, Span::call_site());
473 let location_lit = syn::LitStr::new(location, Span::call_site());
474 let code_lit = syn::LitStr::new(&add_indent(orig_code, 4), Span::call_site());
475 let ident = format_ident!("__UNIQUE_PREFIX__{}", orig_ident);
476
477 let mut code = code_parsed.to_vec();
478 if !code.is_empty() {
479 match code.last().unwrap() {
481 syn::Stmt::Expr(_, None) => {}
482 _ => {
483 let last_line: syn::Expr = parse_quote!(Ok(()));
484 code.push(syn::Stmt::Expr(last_line, None));
485 }
486 }
487 }
488
489 parse_quote! {
492 #[savvy]
493 fn #ident() -> savvy::Result<()> {
494 eprint!(#msg_lit);
495
496 std::panic::set_hook(Box::new(|panic_info| savvy_show_error(#code_lit, #label_lit, #location_lit, panic_info)));
497
498 let test = || -> savvy::Result<()> {
499 #(#code)*
500 };
501 let result = std::panic::catch_unwind(|| test().expect("some error"));
502
503 match result {
504 Ok(_) => {
505 eprintln!("ok");
506 Ok(())
507 }
508 Err(_) => Err(savvy::savvy_err!("test failed")),
509 }
510 }
511 }
512}