tinymist_query/testing/
mod.rs1use 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
19pub struct TestSuites {
21 pub origin_files: Vec<(Source, Module)>,
23 pub tests: Vec<TestCase>,
25 pub examples: Vec<Source>,
27}
28impl TestSuites {
29 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum TestCaseKind {
67 Test,
69 Panic,
71 Bench,
73 Example,
75}
76
77pub struct TestCase {
79 pub name: EcoString,
81 pub location: FileId,
83 pub function: Func,
85 pub kind: TestCaseKind,
87}
88
89pub 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 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}