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