savvy_bindgen/
parse_file.rs

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    // First, parse doctests in the module level document because they are not
50    // the part of the AST tree (at least I cannot find the way to treat it as a
51    // AST). So, this cannot catch the documents in the form `#[doc = include_str!("path/to/README.md")]`.
52
53    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                // start of the code block
247
248                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                // end of the code block
262
263                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                // reset
296                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            // doctest can use # to the line from the document. But, it still
311            // needs to be evaluated as a complete Rust code.
312            //
313            // cf. https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html#hiding-portions-of-the-example
314            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    // replace() is needed for replacing the linebreaks inside string literals
331    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    // Remove #[cfg(feature = "savvy-test")]
348    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 it's marked with #[test], add tweaks to the function.
370                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 and crate with the actual crate name
390        .replace("super::", &format!("{}::", rest.join("::")))
391        .replace("crate::", &format!("{}::", mod_path.first().unwrap()))
392        // TODO: for some reason, prettyplease adds a space before ::
393        .replace("super ::", &format!("{}::", rest.join("::")))
394        .replace("crate ::", &format!("{}::", mod_path.first().unwrap()))
395        // since savvy_show_error is defined in the parent space, add crate::
396        .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(); // remove location
417
418            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        // Add return value Ok(()) unless the original statement has return value.
480        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    // Note: it's hard to determine the unique function name at this point.
490    //       So, put a placeholder here and replace it in the parent function.
491    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}