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