1use core::fmt;
3use std::collections::HashMap;
4use std::sync::{Arc, LazyLock};
5
6use parking_lot::Mutex;
7use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
8use tinymist_analysis::location::PositionEncoding;
9use tinymist_std::hash::FxHashMap;
10use tinymist_world::debug_loc::LspRange;
11use tinymist_world::vfs::{FileId, WorkspaceResolver};
12use tinymist_world::{CompilerFeat, CompilerWorld};
13use typst::diag::FileResult;
14use typst::foundations::func;
15use typst::syntax::ast::AstNode;
16use typst::syntax::{Source, Span, SyntaxNode, ast};
17use typst::{World, WorldExt};
18use typst_shim::syntax::RootedPathExt;
19
20use crate::instrument::Instrumenter;
21
22pub struct CoverageResult {
24 pub meta: FxHashMap<FileId, Arc<InstrumentMeta>>,
26 pub regions: FxHashMap<FileId, CovRegion>,
28}
29
30impl CoverageResult {
31 pub fn to_json<F: CompilerFeat>(&self, w: &CompilerWorld<F>) -> serde_json::Value {
33 let lsp_position_encoding = PositionEncoding::Utf16;
34
35 let mut result = VscodeCoverage::new();
36
37 for (file_id, region) in &self.regions {
38 let file_path = w
39 .path_for_id(*file_id)
40 .unwrap()
41 .as_path()
42 .to_str()
43 .unwrap()
44 .to_string();
45
46 let mut details = vec![];
47
48 let meta = self.meta.get(file_id).unwrap();
49
50 let Ok(typst_source) = w.source(*file_id) else {
51 continue;
52 };
53
54 let hits = region.hits.lock();
55 for (idx, (span, _kind)) in meta.meta.iter().enumerate() {
56 let Some(typst_range) = w.range(*span) else {
57 continue;
58 };
59
60 let rng = tinymist_analysis::location::to_lsp_range(
61 typst_range,
62 &typst_source,
63 lsp_position_encoding,
64 );
65
66 details.push(VscodeFileCoverageDetail {
67 executed: hits[idx] > 0,
68 location: rng,
69 });
70 }
71
72 result.insert(file_path, details);
73 }
74
75 serde_json::to_value(result).unwrap()
76 }
77
78 pub fn summarize<'a>(&'a self, short: bool, prefix: &'a str) -> SummarizedCoverage<'a> {
80 SummarizedCoverage {
81 prefix,
82 result: self,
83 short,
84 }
85 }
86}
87
88pub struct SummarizedCoverage<'a> {
89 prefix: &'a str,
90 result: &'a CoverageResult,
91 short: bool,
92}
93
94impl SummarizedCoverage<'_> {
95 fn line(
96 &self,
97 f: &mut fmt::Formatter<'_>,
98 name: &str,
99 total: usize,
100 cov: usize,
101 is_summary: bool,
102 ) -> fmt::Result {
103 let pre = self.prefix;
104 let r = if total == 0 {
105 100.0
106 } else {
107 cov as f64 / total as f64 * 100.0
108 };
109 if is_summary {
110 write!(f, "{pre}{name} {cov}/{total} ({r:.2}%)")
111 } else {
112 let r = format!("{r:.2}");
113 writeln!(f, "{pre} {cov:<5} / {total:<5} ({r:>6}%) {name}")
114 }
115 }
116}
117
118impl fmt::Display for SummarizedCoverage<'_> {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 let mut ids = self.result.regions.keys().collect::<Vec<_>>();
121 ids.sort_by(|a, b| {
122 a.get()
123 .package_compat()
124 .map(crate::PackageSpecCmp::from)
125 .cmp(&b.get().package_compat().map(crate::PackageSpecCmp::from))
126 .then_with(|| a.vpath().get_with_slash().cmp(b.vpath().get_with_slash()))
127 });
128
129 let summary = ids
130 .par_iter()
131 .flat_map(|&id| {
132 let region = self.result.regions.get(id)?;
133 let meta = self.result.meta.get(id)?;
134
135 let hits = region.hits.lock();
136 let region_covered = hits.par_iter().filter(|&&x| x > 0).count();
137
138 Some((id, region_covered, meta.meta.len()))
139 })
140 .collect::<Vec<_>>();
141
142 let total = summary.iter().map(|(_, _, l)| l).sum::<usize>();
143 let covered = summary.iter().map(|(_, c, _)| c).sum::<usize>();
144
145 if !self.short {
146 for (id, covered, total) in summary {
147 let id = format!("{:?}", WorkspaceResolver::display(Some(*id)));
148 self.line(f, &id, total, covered, false)?;
149 }
150 }
151 self.line(f, "Coverage Summary", total, covered, true)?;
152
153 Ok(())
154 }
155}
156
157pub type VscodeCoverage = HashMap<String, Vec<VscodeFileCoverageDetail>>;
159
160#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
162pub struct VscodeFileCoverageDetail {
163 pub executed: bool,
165 pub location: LspRange,
167}
168
169#[derive(Default)]
170pub struct CovInstr {
171 pub map: Mutex<FxHashMap<FileId, Arc<InstrumentMeta>>>,
173}
174
175impl Instrumenter for CovInstr {
176 fn instrument(&self, _source: Source) -> FileResult<Source> {
177 let (new, meta) = instrument_coverage(_source)?;
178 let region = CovRegion {
179 hits: Arc::new(Mutex::new(vec![0; meta.meta.len()])),
180 };
181
182 let mut map = self.map.lock();
183 map.insert(new.id(), meta);
184
185 let mut cov_map = COVERAGE_MAP.lock();
186 cov_map.regions.insert(new.id(), region);
187
188 Ok(new)
189 }
190}
191
192#[derive(Default)]
194pub struct CoverageMap {
195 last_hit: Option<(FileId, CovRegion)>,
196 pub regions: FxHashMap<FileId, CovRegion>,
198}
199
200#[derive(Default, Clone)]
202pub struct CovRegion {
203 pub hits: Arc<Mutex<Vec<u8>>>,
205}
206
207pub static COVERAGE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(Mutex::default);
208pub static COVERAGE_MAP: LazyLock<Mutex<CoverageMap>> = LazyLock::new(Mutex::default);
209
210#[func(name = "__cov_pc", title = "Coverage function")]
211pub fn __cov_pc(span: Span, pc: i64) {
212 let Some(fid) = span.id() else {
213 return;
214 };
215 let mut map = COVERAGE_MAP.lock();
216 if let Some(last_hit) = map.last_hit.as_ref()
217 && last_hit.0 == fid
218 {
219 let mut hits = last_hit.1.hits.lock();
220 let c = &mut hits[pc as usize];
221 *c = c.saturating_add(1);
222 return;
223 }
224
225 let region = map.regions.entry(fid).or_default();
226 {
227 let mut hits = region.hits.lock();
228 let c = &mut hits[pc as usize];
229 *c = c.saturating_add(1);
230 }
231 map.last_hit = Some((fid, region.clone()));
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum Kind {
236 OpenBrace,
237 CloseBrace,
238 Show,
239}
240
241#[derive(Default)]
242pub struct InstrumentMeta {
243 pub meta: Vec<(Span, Kind)>,
244}
245
246#[comemo::memoize]
247fn instrument_coverage(source: Source) -> FileResult<(Source, Arc<InstrumentMeta>)> {
248 let node = source.root();
249 let mut worker = InstrumentWorker {
250 meta: InstrumentMeta::default(),
251 instrumented: String::new(),
252 };
253
254 worker.visit_node(node);
255 let new_source: Source = Source::new(source.id(), worker.instrumented);
256
257 Ok((new_source, Arc::new(worker.meta)))
258}
259
260struct InstrumentWorker {
261 meta: InstrumentMeta,
262 instrumented: String,
263}
264
265impl InstrumentWorker {
266 fn instrument_block_child(&mut self, container: &SyntaxNode, b1: Span, b2: Span) {
267 for child in container.children() {
268 if b1 == child.span() || b2 == child.span() {
269 self.instrument_block(child);
270 } else {
271 self.visit_node(child);
272 }
273 }
274 }
275
276 fn visit_node(&mut self, node: &SyntaxNode) {
277 if let Some(expr) = node.cast::<ast::Expr>() {
278 match expr {
279 ast::Expr::CodeBlock(..) => {
280 self.instrument_block(node);
281 return;
282 }
283 ast::Expr::WhileLoop(while_expr) => {
284 self.instrument_block_child(node, while_expr.body().span(), Span::detached());
285 return;
286 }
287 ast::Expr::ForLoop(for_expr) => {
288 self.instrument_block_child(node, for_expr.body().span(), Span::detached());
289 return;
290 }
291 ast::Expr::Conditional(cond_expr) => {
292 self.instrument_block_child(
293 node,
294 cond_expr.if_body().span(),
295 cond_expr
296 .else_body()
297 .map(|expr| expr.span())
298 .unwrap_or(Span::detached()),
299 );
300 return;
301 }
302 ast::Expr::Closure(closure) => {
303 self.instrument_block_child(node, closure.body().span(), Span::detached());
304 return;
305 }
306 ast::Expr::ShowRule(show_rule) => {
307 let transform = show_rule.transform().to_untyped().span();
308 let is_set = matches!(show_rule.transform(), ast::Expr::SetRule(..));
309
310 for child in node.children() {
311 if transform == child.span() {
312 if is_set {
313 self.instrument_show_set(child);
314 } else {
315 self.instrument_show_transform(child);
316 }
317 } else {
318 self.visit_node(child);
319 }
320 }
321 return;
322 }
323 ast::Expr::Contextual(body) => {
324 self.instrumented.push_str("context (");
325 self.visit_node(body.body().to_untyped());
326 self.instrumented.push(')');
327 return;
328 }
329 ast::Expr::Text(..)
330 | ast::Expr::Space(..)
331 | ast::Expr::Linebreak(..)
332 | ast::Expr::Parbreak(..)
333 | ast::Expr::Escape(..)
334 | ast::Expr::Shorthand(..)
335 | ast::Expr::SmartQuote(..)
336 | ast::Expr::Strong(..)
337 | ast::Expr::Emph(..)
338 | ast::Expr::Raw(..)
339 | ast::Expr::Link(..)
340 | ast::Expr::Label(..)
341 | ast::Expr::Ref(..)
342 | ast::Expr::Heading(..)
343 | ast::Expr::ListItem(..)
344 | ast::Expr::EnumItem(..)
345 | ast::Expr::TermItem(..)
346 | ast::Expr::Equation(..)
347 | ast::Expr::Math(..)
348 | ast::Expr::MathText(..)
349 | ast::Expr::MathIdent(..)
350 | ast::Expr::MathShorthand(..)
351 | ast::Expr::MathAlignPoint(..)
352 | ast::Expr::MathDelimited(..)
353 | ast::Expr::MathAttach(..)
354 | ast::Expr::MathPrimes(..)
355 | ast::Expr::MathFrac(..)
356 | ast::Expr::MathRoot(..)
357 | ast::Expr::MathFieldAccess(..)
358 | ast::Expr::MathCall(..)
359 | ast::Expr::Ident(..)
360 | ast::Expr::None(..)
361 | ast::Expr::Auto(..)
362 | ast::Expr::Bool(..)
363 | ast::Expr::Int(..)
364 | ast::Expr::Float(..)
365 | ast::Expr::Numeric(..)
366 | ast::Expr::Str(..)
367 | ast::Expr::ContentBlock(..)
368 | ast::Expr::Parenthesized(..)
369 | ast::Expr::Array(..)
370 | ast::Expr::Dict(..)
371 | ast::Expr::Unary(..)
372 | ast::Expr::Binary(..)
373 | ast::Expr::FieldAccess(..)
374 | ast::Expr::FuncCall(..)
375 | ast::Expr::LetBinding(..)
376 | ast::Expr::DestructAssignment(..)
377 | ast::Expr::SetRule(..)
378 | ast::Expr::ModuleImport(..)
379 | ast::Expr::ModuleInclude(..)
380 | ast::Expr::LoopBreak(..)
381 | ast::Expr::LoopContinue(..)
382 | ast::Expr::FuncReturn(..) => {}
383 }
384 }
385
386 self.visit_node_fallback(node);
387 }
388
389 fn visit_node_fallback(&mut self, node: &SyntaxNode) {
390 let txt = node.leaf_text();
391 if !txt.is_empty() {
392 self.instrumented.push_str(txt);
393 }
394
395 for child in node.children() {
396 self.visit_node(child);
397 }
398 }
399
400 fn make_cov(&mut self, span: Span, kind: Kind) {
401 let it = self.meta.meta.len();
402 self.meta.meta.push((span, kind));
403 self.instrumented.push_str("__cov_pc(");
404 self.instrumented.push_str(&it.to_string());
405 self.instrumented.push_str(");\n");
406 }
407
408 fn instrument_block(&mut self, child: &SyntaxNode) {
409 self.instrumented.push_str("{\n");
410 let (first, last) = {
411 let mut children = child.children();
412 let first = children
413 .next()
414 .map(|s| s.span())
415 .unwrap_or_else(Span::detached);
416 let last = children
417 .last()
418 .map(|s| s.span())
419 .unwrap_or_else(Span::detached);
420
421 (first, last)
422 };
423 self.make_cov(first, Kind::OpenBrace);
424 self.visit_node_fallback(child);
425 self.instrumented.push('\n');
426 self.make_cov(last, Kind::CloseBrace);
427 self.instrumented.push('}');
428 }
429
430 fn instrument_show_set(&mut self, child: &SyntaxNode) {
431 self.instrumented.push_str("__it => {");
432 self.make_cov(child.span(), Kind::Show);
433 self.visit_node(child);
434 self.instrumented.push_str("\n__it; }\n");
435 }
436
437 fn instrument_show_transform(&mut self, child: &SyntaxNode) {
438 self.instrumented.push_str("{\nlet __cov_show_body = ");
439 let s = child.span();
440 self.visit_node(child);
441 self.instrumented.push_str("\n__it => {");
442 self.make_cov(s, Kind::Show);
443 self.instrumented
444 .push_str("if type(__cov_show_body) == function { __cov_show_body(__it); } else { __cov_show_body } } }\n");
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn instr(input: &str) -> String {
453 let source = Source::detached(input);
454 let (new, _meta) = instrument_coverage(source).unwrap();
455 new.text().to_string()
456 }
457
458 #[test]
459 fn test_physica_vector() {
460 let instrumented = instr(include_str!("fixtures/instr_coverage/physica_vector.typ"));
461 insta::assert_snapshot!(instrumented, @r#"
462
463 // A show rule, should be used like:
464 // #show: super-plus-as-dagger
465 // U^+U = U U^+ = I
466 // or in scope:
467 // #[
468 // #show: super-plus-as-dagger
469 // U^+U = U U^+ = I
470 // ]
471 #let super-plus-as-dagger(document) = {
472 __cov_pc(0);
473 {
474 show math.attach: {
475 let __cov_show_body = elem => {
476 __cov_pc(1);
477 {
478 if __eligible(elem.base) and elem.at("t", default: none) == [+] {
479 __cov_pc(2);
480 {
481 $attach(elem.base, t: dagger, b: elem.at("b", default: #none))$
482 }
483 __cov_pc(3);
484 } else {
485 __cov_pc(4);
486 {
487 elem
488 }
489 __cov_pc(5);
490 }
491 }
492 __cov_pc(6);
493 }
494 __it => {__cov_pc(7);
495 if type(__cov_show_body) == function { __cov_show_body(__it); } else { __cov_show_body } } }
496
497
498 document
499 }
500 __cov_pc(8);
501 }
502 "#);
503 }
504
505 #[test]
506 fn test_playground() {
507 let instrumented = instr(include_str!("fixtures/instr_coverage/playground.typ"));
508 insta::assert_snapshot!(instrumented, @"");
509 }
510
511 #[test]
512 fn test_instrument_coverage() {
513 let source = Source::detached("#let a = 1;");
514 let (new, _meta) = instrument_coverage(source).unwrap();
515 insta::assert_snapshot!(new.text(), @"#let a = 1;");
516 }
517
518 #[test]
519 fn test_instrument_coverage_show_content() {
520 let source = Source::detached("#show math.equation: context it => it");
521 let (new, _meta) = instrument_coverage(source).unwrap();
522 insta::assert_snapshot!(new.text(), @"
523 #show math.equation: {
524 let __cov_show_body = context (it => {
525 __cov_pc(0);
526 it
527 __cov_pc(1);
528 })
529 __it => {__cov_pc(2);
530 if type(__cov_show_body) == function { __cov_show_body(__it); } else { __cov_show_body } } }
531 ");
532 }
533
534 #[test]
535 fn test_instrument_inline_block() {
536 let source = Source::detached("#let main-size = {1} + 2 + {3}");
537 let (new, _meta) = instrument_coverage(source).unwrap();
538 insta::assert_snapshot!(new.text(), @"
539 #let main-size = {
540 __cov_pc(0);
541 {1}
542 __cov_pc(1);
543 } + 2 + {
544 __cov_pc(2);
545 {3}
546 __cov_pc(3);
547 }
548 ");
549 }
550
551 #[test]
552 fn test_instrument_if() {
553 let source = Source::detached(
554 "#let main-size = if is-web-target {
555 16pt
556} else {
557 10.5pt
558}",
559 );
560 let (new, _meta) = instrument_coverage(source).unwrap();
561 insta::assert_snapshot!(new.text(), @"
562 #let main-size = if is-web-target {
563 __cov_pc(0);
564 {
565 16pt
566 }
567 __cov_pc(1);
568 } else {
569 __cov_pc(2);
570 {
571 10.5pt
572 }
573 __cov_pc(3);
574 }
575 ");
576 }
577
578 #[test]
579 fn test_instrument_coverage_nested() {
580 let source = Source::detached("#let a = {1};");
581 let (new, _meta) = instrument_coverage(source).unwrap();
582 insta::assert_snapshot!(new.text(), @"
583 #let a = {
584 __cov_pc(0);
585 {1}
586 __cov_pc(1);
587 };
588 ");
589 }
590
591 #[test]
592 fn test_instrument_coverage_functor() {
593 let source = Source::detached("#show: main");
594 let (new, _meta) = instrument_coverage(source).unwrap();
595 insta::assert_snapshot!(new.text(), @"
596 #show: {
597 let __cov_show_body = main
598 __it => {__cov_pc(0);
599 if type(__cov_show_body) == function { __cov_show_body(__it); } else { __cov_show_body } } }
600 ");
601 }
602
603 #[test]
604 fn test_instrument_coverage_set() {
605 let source = Source::detached("#show raw: set text(12pt)");
606 let (new, _meta) = instrument_coverage(source).unwrap();
607 insta::assert_snapshot!(new.text(), @"
608 #show raw: __it => {__cov_pc(0);
609 set text(12pt)
610 __it; }
611 ");
612 }
613}