tinymist_query/testing/
mod.rs

1//! Extracts test suites from the document.
2
3use ecow::EcoString;
4use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
5use tinymist_std::error::prelude::*;
6use tinymist_std::typst::TypstDocument;
7use tinymist_world::vfs::FileId;
8use typst::{
9    World,
10    foundations::{Func, Label, Module, Selector, Value},
11    introspection::MetadataElem,
12    syntax::Source,
13    utils::PicoStr,
14};
15
16use crate::LocalContext;
17
18/// Test suites extracted from the document.
19pub struct TestSuites {
20    /// Files from the current workspace.
21    pub origin_files: Vec<(Source, Module)>,
22    /// Test cases in the current workspace.
23    pub tests: Vec<TestCase>,
24    /// Example documents in the current workspace.
25    pub examples: Vec<Source>,
26}
27impl TestSuites {
28    /// Rechecks the test suites.
29    pub fn recheck(&self, world: &dyn World) -> TestSuites {
30        let tests = self
31            .tests
32            .iter()
33            .filter_map(|test| {
34                let source = world.source(test.location).ok()?;
35                let module = typst_shim::eval::eval_compat(world, &source).ok()?;
36                let symbol = module.scope().get(&test.name)?;
37                let Value::Func(function) = symbol.read() else {
38                    return None;
39                };
40                Some(TestCase {
41                    name: test.name.clone(),
42                    location: test.location,
43                    function: function.clone(),
44                    kind: test.kind,
45                })
46            })
47            .collect();
48
49        let examples = self
50            .examples
51            .iter()
52            .filter_map(|source| world.source(source.id()).ok())
53            .collect();
54
55        TestSuites {
56            origin_files: self.origin_files.clone(),
57            tests,
58            examples,
59        }
60    }
61}
62
63/// Kind of the test case.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum TestCaseKind {
66    /// A normal test case.
67    Test,
68    /// A test case that should panic.
69    Panic,
70    /// A benchmark test case.
71    Bench,
72    /// An example test case.
73    Example,
74}
75
76/// A test case.
77pub struct TestCase {
78    /// Name of the test case.
79    pub name: EcoString,
80    /// Location of the test case.
81    pub location: FileId,
82    /// entry of the test case.
83    pub function: Func,
84    /// Kind of the test case.
85    pub kind: TestCaseKind,
86}
87
88/// Extracts the test suites in the document
89pub fn test_suites(ctx: &mut LocalContext, doc: &TypstDocument) -> Result<TestSuites> {
90    let main_id = ctx.world.main();
91    let main_workspace = main_id.package();
92
93    crate::log_debug_ct!(
94        "test workspace: {:?}, files: {:?}",
95        main_workspace,
96        ctx.depended_source_files()
97    );
98    let files = ctx
99        .depended_source_files()
100        .par_iter()
101        .filter(|fid| fid.package() == main_workspace)
102        .map(|fid| {
103            let source = ctx
104                .source_by_id(*fid)
105                .context_ut("failed to get source by id")?;
106            let module = ctx.module_by_id(*fid)?;
107            Ok((source, module))
108        })
109        .collect::<Result<Vec<_>>>()?;
110
111    let config = extract_test_configuration(doc)?;
112
113    let mut worker = TestSuitesWorker {
114        files: &files,
115        config,
116        tests: Vec::new(),
117        examples: Vec::new(),
118    };
119
120    worker.discover_tests()?;
121
122    Ok(TestSuites {
123        tests: worker.tests,
124        examples: worker.examples,
125        origin_files: files,
126    })
127}
128
129#[derive(Debug, Clone)]
130struct TestConfig {
131    test_pattern: EcoString,
132    bench_pattern: EcoString,
133    panic_pattern: EcoString,
134    example_pattern: EcoString,
135}
136
137#[derive(Debug, Clone, Default, serde::Deserialize)]
138struct UserTestConfig {
139    test_pattern: Option<EcoString>,
140    bench_pattern: Option<EcoString>,
141    panic_pattern: Option<EcoString>,
142    example_pattern: Option<EcoString>,
143}
144
145fn extract_test_configuration(doc: &TypstDocument) -> Result<TestConfig> {
146    let selector = Label::new(PicoStr::intern("test-config"));
147    let metadata = doc.introspector().query(&Selector::Label(selector));
148    if metadata.len() > 1 {
149        // todo: attach source locations.
150        bail!("multiple test configurations found");
151    }
152
153    let config = if let Some(metadata) = metadata.first() {
154        let metadata = metadata
155            .to_packed::<MetadataElem>()
156            .context("test configuration is not a metadata element")?;
157
158        let value =
159            serde_json::to_value(&metadata.value).context("failed to serialize metadata")?;
160        serde_json::from_value(value).context("failed to deserialize metadata")?
161    } else {
162        UserTestConfig::default()
163    };
164
165    Ok(TestConfig {
166        test_pattern: config.test_pattern.unwrap_or_else(|| "test-".into()),
167        bench_pattern: config.bench_pattern.unwrap_or_else(|| "bench-".into()),
168        panic_pattern: config.panic_pattern.unwrap_or_else(|| "panic-on-".into()),
169        example_pattern: config.example_pattern.unwrap_or_else(|| "example-".into()),
170    })
171}
172
173struct TestSuitesWorker<'a> {
174    files: &'a [(Source, Module)],
175    config: TestConfig,
176    tests: Vec<TestCase>,
177    examples: Vec<Source>,
178}
179
180impl TestSuitesWorker<'_> {
181    fn match_test(&self, name: &str) -> Option<TestCaseKind> {
182        if name.starts_with(self.config.test_pattern.as_str()) {
183            Some(TestCaseKind::Test)
184        } else if name.starts_with(self.config.bench_pattern.as_str()) {
185            Some(TestCaseKind::Bench)
186        } else if name.starts_with(self.config.panic_pattern.as_str()) {
187            Some(TestCaseKind::Panic)
188        } else if name.starts_with(self.config.example_pattern.as_str()) {
189            Some(TestCaseKind::Example)
190        } else {
191            None
192        }
193    }
194
195    fn discover_tests(&mut self) -> Result<()> {
196        for (source, module) in self.files.iter() {
197            let vpath = source.id().vpath().as_rooted_path();
198            let file_name = vpath.file_name().and_then(|s| s.to_str()).unwrap_or("");
199            if file_name.starts_with(self.config.example_pattern.as_str()) {
200                self.examples.push(source.clone());
201                continue;
202            }
203
204            for (name, symbol) in module.scope().iter() {
205                crate::log_debug_ct!("symbol({name:?}): {symbol:?}");
206                let Value::Func(function) = symbol.read() else {
207                    continue;
208                };
209
210                let span = symbol.span();
211                let id = span.id();
212                if Some(source.id()) != id {
213                    continue;
214                }
215
216                if let Some(kind) = self.match_test(name.as_str()) {
217                    self.tests.push(TestCase {
218                        name: name.clone(),
219                        location: source.id(),
220                        function: function.clone(),
221                        kind,
222                    });
223                }
224            }
225        }
226
227        Ok(())
228    }
229}