xtask/codegen/
parser_inline_tests.rs

1//! This module greps parser's code for specially formatted comments and turns
2//! them into tests.
3#![allow(clippy::disallowed_types, clippy::print_stdout)]
4
5use std::{
6    collections::HashMap,
7    fs, iter,
8    path::{Path, PathBuf},
9    time::SystemTime,
10};
11
12use anyhow::Result;
13use itertools::Itertools as _;
14
15use crate::{
16    codegen::{CommentBlock, ensure_file_contents, reformat},
17    project_root,
18    util::list_rust_files,
19};
20
21pub(crate) fn generate(check: bool) {
22    let parser_crate_root = project_root().join("crates/parser");
23    let parser_test_data = parser_crate_root.join("test_data");
24    let parser_test_data_inline = parser_test_data.join("parser/inline");
25
26    let tests = tests_from_dir(&parser_crate_root.join("src/grammar"));
27
28    let mut some_file_was_updated = false;
29    some_file_was_updated |=
30        install_tests(&tests.ok, parser_test_data_inline.join("ok"), check).unwrap();
31    some_file_was_updated |=
32        install_tests(&tests.err, parser_test_data_inline.join("err"), check).unwrap();
33
34    if some_file_was_updated {
35        let _ = fs::File::open(parser_crate_root.join("src/tests.rs"))
36            .unwrap()
37            .set_modified(SystemTime::now());
38    }
39
40    let ok_tests = tests.ok.values().sorted_by(|a, b| a.name.cmp(&b.name)).map(|test| {
41        let test_name = quote::format_ident!("{}", test.name);
42        let test_file = format!("test_data/parser/inline/ok/{test_name}.rs");
43        let (test_func, args) = match &test.edition {
44            Some(edition) => {
45                let edition = quote::format_ident!("Edition{edition}");
46                (
47                    quote::format_ident!("run_and_expect_no_errors_with_edition"),
48                    quote::quote! {#test_file, crate::Edition::#edition},
49                )
50            }
51            None => (quote::format_ident!("run_and_expect_no_errors"), quote::quote! {#test_file}),
52        };
53        quote::quote! {
54            #[test]
55            fn #test_name() {
56                #test_func(#args);
57            }
58        }
59    });
60    let err_tests = tests.err.values().sorted_by(|a, b| a.name.cmp(&b.name)).map(|test| {
61        let test_name = quote::format_ident!("{}", test.name);
62        let test_file = format!("test_data/parser/inline/err/{test_name}.rs");
63        let (test_func, args) = match &test.edition {
64            Some(edition) => {
65                let edition = quote::format_ident!("Edition{edition}");
66                (
67                    quote::format_ident!("run_and_expect_errors_with_edition"),
68                    quote::quote! {#test_file, crate::Edition::#edition},
69                )
70            }
71            None => (quote::format_ident!("run_and_expect_errors"), quote::quote! {#test_file}),
72        };
73        quote::quote! {
74            #[test]
75            fn #test_name() {
76                #test_func(#args);
77            }
78        }
79    });
80
81    let output = quote::quote! {
82        mod ok {
83            use crate::tests::*;
84            #(#ok_tests)*
85        }
86        mod err {
87            use crate::tests::*;
88            #(#err_tests)*
89        }
90    };
91
92    let pretty = reformat(output.to_string());
93    ensure_file_contents(
94        crate::flags::CodegenType::ParserTests,
95        parser_test_data.join("generated/runner.rs").as_ref(),
96        &pretty,
97        check,
98    );
99}
100
101fn install_tests(tests: &HashMap<String, Test>, tests_dir: PathBuf, check: bool) -> Result<bool> {
102    if !tests_dir.is_dir() {
103        fs::create_dir_all(&tests_dir)?;
104    }
105    let existing = existing_tests(&tests_dir, TestKind::Ok)?;
106    if let Some((t, (path, _))) = existing.iter().find(|&(t, _)| !tests.contains_key(t)) {
107        panic!("Test `{t}` is deleted: {}", path.display());
108    }
109
110    let mut some_file_was_updated = false;
111
112    for (name, test) in tests {
113        let path = match existing.get(name) {
114            Some((path, _test)) => path.clone(),
115            None => tests_dir.join(name).with_extension("rs"),
116        };
117        if ensure_file_contents(crate::flags::CodegenType::ParserTests, &path, &test.text, check) {
118            some_file_was_updated = true;
119        }
120    }
121
122    Ok(some_file_was_updated)
123}
124
125#[derive(Debug)]
126struct Test {
127    name: String,
128    text: String,
129    kind: TestKind,
130    edition: Option<String>,
131}
132
133#[derive(Copy, Clone, Debug)]
134enum TestKind {
135    Ok,
136    Err,
137}
138
139#[derive(Default, Debug)]
140struct Tests {
141    ok: HashMap<String, Test>,
142    err: HashMap<String, Test>,
143}
144
145fn collect_tests(s: &str) -> Vec<Test> {
146    let mut res = Vec::new();
147    for comment_block in CommentBlock::extract_untagged(s) {
148        let first_line = &comment_block.contents[0];
149        let (name, kind) = if let Some(name) = first_line.strip_prefix("test ") {
150            (name.to_owned(), TestKind::Ok)
151        } else if let Some(name) = first_line.strip_prefix("test_err ") {
152            (name.to_owned(), TestKind::Err)
153        } else {
154            continue;
155        };
156        let (name, edition) = match *name.split(' ').collect_vec().as_slice() {
157            [name, edition] => {
158                assert!(!edition.contains(' '));
159                (name.to_owned(), Some(edition.to_owned()))
160            }
161            [name] => (name.to_owned(), None),
162            _ => panic!("invalid test name: {name:?}"),
163        };
164        let text: String = edition
165            .as_ref()
166            .map(|edition| format!("// {edition}"))
167            .into_iter()
168            .chain(comment_block.contents[1..].iter().cloned())
169            .chain(iter::once(String::new()))
170            .collect::<Vec<_>>()
171            .join("\n");
172        assert!(!text.trim().is_empty() && text.ends_with('\n'));
173        res.push(Test { name, edition, text, kind })
174    }
175    res
176}
177
178fn tests_from_dir(dir: &Path) -> Tests {
179    let mut res = Tests::default();
180    for entry in list_rust_files(dir) {
181        process_file(&mut res, entry.as_path());
182    }
183    let grammar_rs = dir.parent().unwrap().join("grammar.rs");
184    process_file(&mut res, &grammar_rs);
185    return res;
186
187    fn process_file(res: &mut Tests, path: &Path) {
188        let text = fs::read_to_string(path).unwrap();
189
190        for test in collect_tests(&text) {
191            if let TestKind::Ok = test.kind {
192                if let Some(old_test) = res.ok.insert(test.name.clone(), test) {
193                    panic!("Duplicate test: {}", old_test.name);
194                }
195            } else if let Some(old_test) = res.err.insert(test.name.clone(), test) {
196                panic!("Duplicate test: {}", old_test.name);
197            }
198        }
199    }
200}
201
202fn existing_tests(dir: &Path, ok: TestKind) -> Result<HashMap<String, (PathBuf, Test)>> {
203    let mut res = HashMap::new();
204    for file in fs::read_dir(dir)? {
205        let path = file?.path();
206        let rust_file = path.extension().and_then(|ext| ext.to_str()) == Some("rs");
207
208        if rust_file {
209            let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap();
210            let text = fs::read_to_string(&path)?;
211            let edition =
212                text.lines().next().and_then(|it| it.strip_prefix("// ")).map(ToOwned::to_owned);
213            let test = Test { name: name.clone(), text, kind: ok, edition };
214            if let Some(old) = res.insert(name, (path, test)) {
215                println!("Duplicate test: {old:?}");
216            }
217        }
218    }
219    Ok(res)
220}
221
222#[test]
223fn test() {
224    generate(true);
225}