tinymist_debug/
cov.rs

1//! Tinymist coverage support for Typst.
2use 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
22/// The coverage result.
23pub struct CoverageResult {
24    /// The coverage meta.
25    pub meta: FxHashMap<FileId, Arc<InstrumentMeta>>,
26    /// The coverage map.
27    pub regions: FxHashMap<FileId, CovRegion>,
28}
29
30impl CoverageResult {
31    /// Converts the coverage result to JSON.
32    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    /// Summarizes the coverage result.
79    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
157/// The coverage result in the format of the VSCode coverage data.
158pub type VscodeCoverage = HashMap<String, Vec<VscodeFileCoverageDetail>>;
159
160/// Converts the coverage result to the VSCode coverage data.
161#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
162pub struct VscodeFileCoverageDetail {
163    /// Whether the location is being executed
164    pub executed: bool,
165    /// The location of the coverage.
166    pub location: LspRange,
167}
168
169#[derive(Default)]
170pub struct CovInstr {
171    /// The coverage map.
172    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/// The coverage map.
193#[derive(Default)]
194pub struct CoverageMap {
195    last_hit: Option<(FileId, CovRegion)>,
196    /// The coverage map.
197    pub regions: FxHashMap<FileId, CovRegion>,
198}
199
200/// The coverage region
201#[derive(Default, Clone)]
202pub struct CovRegion {
203    /// The hits
204    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}