|  | //! 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); | 
|  | } |