| //! This module greps parser's code for specially formatted comments and turns |
| //! them into tests. |
| #![allow(clippy::disallowed_types, clippy::print_stdout)] |
| |
| use std::{ |
| collections::HashMap, |
| fs, iter, |
| path::{Path, PathBuf}, |
| time::SystemTime, |
| }; |
| |
| use anyhow::Result; |
| use itertools::Itertools as _; |
| |
| use crate::{ |
| codegen::{CommentBlock, ensure_file_contents, reformat}, |
| project_root, |
| util::list_rust_files, |
| }; |
| |
| pub(crate) fn generate(check: bool) { |
| let parser_crate_root = project_root().join("crates/parser"); |
| let parser_test_data = parser_crate_root.join("test_data"); |
| let parser_test_data_inline = parser_test_data.join("parser/inline"); |
| |
| let tests = tests_from_dir(&parser_crate_root.join("src/grammar")); |
| |
| let mut some_file_was_updated = false; |
| some_file_was_updated |= |
| install_tests(&tests.ok, parser_test_data_inline.join("ok"), check).unwrap(); |
| some_file_was_updated |= |
| install_tests(&tests.err, parser_test_data_inline.join("err"), check).unwrap(); |
| |
| if some_file_was_updated { |
| let _ = fs::File::open(parser_crate_root.join("src/tests.rs")) |
| .unwrap() |
| .set_modified(SystemTime::now()); |
| } |
| |
| let ok_tests = tests.ok.values().sorted_by(|a, b| a.name.cmp(&b.name)).map(|test| { |
| let test_name = quote::format_ident!("{}", test.name); |
| let test_file = format!("test_data/parser/inline/ok/{test_name}.rs"); |
| let (test_func, args) = match &test.edition { |
| Some(edition) => { |
| let edition = quote::format_ident!("Edition{edition}"); |
| ( |
| quote::format_ident!("run_and_expect_no_errors_with_edition"), |
| quote::quote! {#test_file, crate::Edition::#edition}, |
| ) |
| } |
| None => (quote::format_ident!("run_and_expect_no_errors"), quote::quote! {#test_file}), |
| }; |
| quote::quote! { |
| #[test] |
| fn #test_name() { |
| #test_func(#args); |
| } |
| } |
| }); |
| let err_tests = tests.err.values().sorted_by(|a, b| a.name.cmp(&b.name)).map(|test| { |
| let test_name = quote::format_ident!("{}", test.name); |
| let test_file = format!("test_data/parser/inline/err/{test_name}.rs"); |
| let (test_func, args) = match &test.edition { |
| Some(edition) => { |
| let edition = quote::format_ident!("Edition{edition}"); |
| ( |
| quote::format_ident!("run_and_expect_errors_with_edition"), |
| quote::quote! {#test_file, crate::Edition::#edition}, |
| ) |
| } |
| None => (quote::format_ident!("run_and_expect_errors"), quote::quote! {#test_file}), |
| }; |
| quote::quote! { |
| #[test] |
| fn #test_name() { |
| #test_func(#args); |
| } |
| } |
| }); |
| |
| let output = quote::quote! { |
| mod ok { |
| use crate::tests::*; |
| #(#ok_tests)* |
| } |
| mod err { |
| use crate::tests::*; |
| #(#err_tests)* |
| } |
| }; |
| |
| let pretty = reformat(output.to_string()); |
| ensure_file_contents( |
| crate::flags::CodegenType::ParserTests, |
| parser_test_data.join("generated/runner.rs").as_ref(), |
| &pretty, |
| check, |
| ); |
| } |
| |
| fn install_tests(tests: &HashMap<String, Test>, tests_dir: PathBuf, check: bool) -> Result<bool> { |
| if !tests_dir.is_dir() { |
| fs::create_dir_all(&tests_dir)?; |
| } |
| let existing = existing_tests(&tests_dir, TestKind::Ok)?; |
| if let Some((t, (path, _))) = existing.iter().find(|&(t, _)| !tests.contains_key(t)) { |
| panic!("Test `{t}` is deleted: {}", path.display()); |
| } |
| |
| let mut some_file_was_updated = false; |
| |
| for (name, test) in tests { |
| let path = match existing.get(name) { |
| Some((path, _test)) => path.clone(), |
| None => tests_dir.join(name).with_extension("rs"), |
| }; |
| if ensure_file_contents(crate::flags::CodegenType::ParserTests, &path, &test.text, check) { |
| some_file_was_updated = true; |
| } |
| } |
| |
| Ok(some_file_was_updated) |
| } |
| |
| #[derive(Debug)] |
| struct Test { |
| name: String, |
| text: String, |
| kind: TestKind, |
| edition: Option<String>, |
| } |
| |
| #[derive(Copy, Clone, Debug)] |
| enum TestKind { |
| Ok, |
| Err, |
| } |
| |
| #[derive(Default, Debug)] |
| struct Tests { |
| ok: HashMap<String, Test>, |
| err: HashMap<String, Test>, |
| } |
| |
| fn collect_tests(s: &str) -> Vec<Test> { |
| let mut res = Vec::new(); |
| for comment_block in CommentBlock::extract_untagged(s) { |
| let first_line = &comment_block.contents[0]; |
| let (name, kind) = if let Some(name) = first_line.strip_prefix("test ") { |
| (name.to_owned(), TestKind::Ok) |
| } else if let Some(name) = first_line.strip_prefix("test_err ") { |
| (name.to_owned(), TestKind::Err) |
| } else { |
| continue; |
| }; |
| let (name, edition) = match *name.split(' ').collect_vec().as_slice() { |
| [name, edition] => { |
| assert!(!edition.contains(' ')); |
| (name.to_owned(), Some(edition.to_owned())) |
| } |
| [name] => (name.to_owned(), None), |
| _ => panic!("invalid test name: {name:?}"), |
| }; |
| let text: String = edition |
| .as_ref() |
| .map(|edition| format!("// {edition}")) |
| .into_iter() |
| .chain(comment_block.contents[1..].iter().cloned()) |
| .chain(iter::once(String::new())) |
| .collect::<Vec<_>>() |
| .join("\n"); |
| assert!(!text.trim().is_empty() && text.ends_with('\n')); |
| res.push(Test { name, edition, text, kind }) |
| } |
| res |
| } |
| |
| fn tests_from_dir(dir: &Path) -> Tests { |
| let mut res = Tests::default(); |
| for entry in list_rust_files(dir) { |
| process_file(&mut res, entry.as_path()); |
| } |
| let grammar_rs = dir.parent().unwrap().join("grammar.rs"); |
| process_file(&mut res, &grammar_rs); |
| return res; |
| |
| fn process_file(res: &mut Tests, path: &Path) { |
| let text = fs::read_to_string(path).unwrap(); |
| |
| for test in collect_tests(&text) { |
| if let TestKind::Ok = test.kind { |
| if let Some(old_test) = res.ok.insert(test.name.clone(), test) { |
| panic!("Duplicate test: {}", old_test.name); |
| } |
| } else if let Some(old_test) = res.err.insert(test.name.clone(), test) { |
| panic!("Duplicate test: {}", old_test.name); |
| } |
| } |
| } |
| } |
| |
| fn existing_tests(dir: &Path, ok: TestKind) -> Result<HashMap<String, (PathBuf, Test)>> { |
| let mut res = HashMap::new(); |
| for file in fs::read_dir(dir)? { |
| let path = file?.path(); |
| let rust_file = path.extension().and_then(|ext| ext.to_str()) == Some("rs"); |
| |
| if rust_file { |
| let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap(); |
| let text = fs::read_to_string(&path)?; |
| let edition = |
| text.lines().next().and_then(|it| it.strip_prefix("// ")).map(ToOwned::to_owned); |
| let test = Test { name: name.clone(), text, kind: ok, edition }; |
| if let Some(old) = res.insert(name, (path, test)) { |
| println!("Duplicate test: {old:?}"); |
| } |
| } |
| } |
| Ok(res) |
| } |
| |
| #[test] |
| fn test() { |
| generate(true); |
| } |