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