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};
15
16use crate::LocalContext;
17
18pub struct TestSuites {
20 pub origin_files: Vec<(Source, Module)>,
22 pub tests: Vec<TestCase>,
24 pub examples: Vec<Source>,
26}
27impl TestSuites {
28 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum TestCaseKind {
66 Test,
68 Panic,
70 Bench,
72 Example,
74}
75
76pub struct TestCase {
78 pub name: EcoString,
80 pub location: FileId,
82 pub function: Func,
84 pub kind: TestCaseKind,
86}
87
88pub 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 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}