• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

xd009642 / tarpaulin / #713

01 Jan 2026 11:44AM UTC coverage: 85.116% (+0.8%) from 84.27%
#713

push

xd009642
Release 0.35.0

124 of 133 new or added lines in 8 files covered. (93.23%)

5 existing lines in 2 files now uncovered.

4695 of 5516 relevant lines covered (85.12%)

252346.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.83
/src/source_analysis/mod.rs
1
use crate::config::{Config, RunType};
2
use crate::path_utils::{get_source_walker, is_source_file};
3
use proc_macro2::{Span, TokenStream};
4
use quote::ToTokens;
5
use regex::Regex;
6
use serde::{Deserialize, Serialize};
7
use std::cell::RefCell;
8
use std::collections::{HashMap, HashSet};
9
use std::ffi::OsStr;
10
use std::fs::File;
11
use std::io::{self, BufRead, BufReader, Read};
12
use std::path::{Path, PathBuf};
13
use std::sync::LazyLock;
14
use syn::spanned::Spanned;
15
use syn::*;
16
use tracing::{debug, trace, warn};
17
use walkdir::WalkDir;
18

19
mod attributes;
20
mod expressions;
21
mod items;
22
mod macros;
23
mod statements;
24
#[cfg(test)]
25
mod tests;
26

27
pub(crate) mod prelude {
28
    pub(crate) use super::*;
29
    pub(crate) use attributes::*;
30
    pub(crate) use macros::*;
31
}
32

33
/// Enumeration representing which lines to ignore
34
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
35
pub enum Lines {
36
    /// Ignore all lines in the file
37
    All,
38
    /// A single line to ignore in the file
39
    Line(usize),
40
}
41

42
/// Represents the results of analysis of a single file. Does not store the file
43
/// in question as this is expected to be maintained by the user.
44
#[derive(Clone, Debug, Default)]
45
pub struct LineAnalysis {
46
    /// This represents lines that should be ignored in coverage
47
    /// but may be identified as coverable in the DWARF tables
48
    pub ignore: HashSet<Lines>,
49
    /// This represents lines that should be included in coverage
50
    /// But may be ignored. Doesn't make sense to cover ALL the lines so this
51
    /// is just an index.
52
    pub cover: HashSet<usize>,
53
    /// Some logical lines may be split between physical lines this shows the
54
    /// mapping from physical line to logical line to prevent false positives
55
    /// from expressions split across physical lines
56
    pub logical_lines: HashMap<usize, usize>,
57
    /// Shows the line length of the provided file
58
    max_line: usize,
59
    pub functions: HashMap<String, (usize, usize)>,
60
}
61

62
/// Provides context to the source analysis stage including the tarpaulin
63
/// config and the source code being analysed.
64
pub(crate) struct Context<'a> {
65
    /// Program config
66
    config: &'a Config,
67
    /// Contents of the source file
68
    file_contents: &'a str,
69
    /// path to the file being analysed
70
    file: &'a Path,
71
    /// Other parts of context are immutable like tarpaulin config and users
72
    /// source code. This is discovered during hence use of interior mutability
73
    ignore_mods: RefCell<HashSet<PathBuf>>,
74
    /// As we traverse the structures we want to grab module names etc so we can get proper names
75
    /// for our functions etc
76
    pub(crate) symbol_stack: RefCell<Vec<String>>,
77
}
78

79
pub(crate) struct StackGuard<'a>(&'a RefCell<Vec<String>>);
80

81
impl<'a> Drop for StackGuard<'a> {
82
    fn drop(&mut self) {
710✔
83
        self.0.borrow_mut().pop();
710✔
84
    }
85
}
86

87
impl<'a> Context<'a> {
88
    pub(crate) fn push_to_symbol_stack(&self, mut ident: String) -> StackGuard<'_> {
710✔
89
        if !(ident.starts_with("<") && ident.ends_with(">")) {
1,420✔
90
            ident = ident.replace(' ', "");
2,112✔
91
        }
92
        self.symbol_stack.borrow_mut().push(ident);
1,420✔
93
        StackGuard(&self.symbol_stack)
710✔
94
    }
95

96
    pub(crate) fn get_qualified_name(&self) -> String {
509✔
97
        let stack = self.symbol_stack.borrow();
1,527✔
98
        let name = stack.join("::");
1,527✔
99
        debug!("Found function: {}", name);
509✔
100
        name
509✔
101
    }
102
}
103

104
/// When the `LineAnalysis` results are mapped to their files there needs to be
105
/// an easy way to get the information back. For the container used implement
106
/// this trait
107
pub trait SourceAnalysisQuery {
108
    /// Returns true if the line in the given file should be ignored
109
    fn should_ignore(&self, path: &Path, l: &usize) -> bool;
110
    /// Takes a path and line number and normalises it to the logical line
111
    /// that should be represented in the statistics
112
    fn normalise(&self, path: &Path, l: usize) -> (PathBuf, usize);
113
}
114

115
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
116
pub(crate) enum SubResult {
117
    /// Expression should be a reachable one (or we don't care to check further)
118
    Ok,
119
    /// Expression definitely reachable - reserved for early returns from functions to stop
120
    /// unreachable expressions wiping them out
121
    Definite,
122
    /// Unreachable expression i.e. unreachable!()
123
    Unreachable,
124
}
125

126
// Addition works for this by forcing anything + definite to definite, otherwise prioritising
127
// unreachable.
128
impl std::ops::AddAssign for SubResult {
129
    fn add_assign(&mut self, other: Self) {
235✔
130
        if *self == Self::Definite || other == Self::Definite {
491✔
131
            *self = Self::Definite;
34✔
132
        } else if *self == Self::Unreachable || other == Self::Unreachable {
439✔
133
            *self = Self::Unreachable;
6✔
134
        } else {
135
            *self = Self::Ok;
195✔
136
        }
137
    }
138
}
139

140
impl std::ops::Add for SubResult {
141
    type Output = Self;
142

143
    fn add(mut self, rhs: Self) -> Self::Output {
29✔
144
        self += rhs;
29✔
145
        self
29✔
146
    }
147
}
148

149
impl SubResult {
150
    pub fn is_reachable(&self) -> bool {
2,988✔
151
        *self != Self::Unreachable
2,988✔
152
    }
153

154
    pub fn is_unreachable(&self) -> bool {
2,933✔
155
        !self.is_reachable()
2,933✔
156
    }
157
}
158

159
impl SourceAnalysisQuery for HashMap<PathBuf, LineAnalysis> {
160
    fn should_ignore(&self, path: &Path, l: &usize) -> bool {
21,414✔
161
        if self.contains_key(path) {
64,242✔
162
            self.get(path).unwrap().should_ignore(*l)
107,070✔
163
        } else {
164
            false
×
165
        }
166
    }
167

168
    fn normalise(&self, path: &Path, l: usize) -> (PathBuf, usize) {
1,216✔
169
        let pb = path.to_path_buf();
3,648✔
170
        match self.get(path) {
2,432✔
171
            Some(s) => match s.logical_lines.get(&l) {
3,648✔
172
                Some(o) => (pb, *o),
16✔
173
                _ => (pb, l),
1,208✔
174
            },
175
            _ => (pb, l),
×
176
        }
177
    }
178
}
179

180
impl LineAnalysis {
181
    /// Creates a new LineAnalysis object
182
    fn new() -> Self {
21✔
183
        Default::default()
21✔
184
    }
185

186
    fn new_from_file(path: &Path) -> io::Result<Self> {
365✔
187
        let file = BufReader::new(File::open(path)?);
1,382✔
188
        Ok(Self {
287✔
189
            max_line: file.lines().count(),
861✔
190
            ..Default::default()
287✔
191
        })
192
    }
193

194
    /// Ignore all lines in the file
195
    pub fn ignore_all(&mut self) {
30✔
196
        self.ignore.clear();
60✔
197
        self.cover.clear();
60✔
198
        self.ignore.insert(Lines::All);
90✔
199
    }
200

201
    /// Ignore all tokens in the given token stream
202
    pub fn ignore_tokens<T>(&mut self, tokens: T)
618✔
203
    where
204
        T: ToTokens,
205
    {
206
        for token in tokens.into_token_stream() {
5,220✔
207
            self.ignore_span(token.span());
5,976✔
208
        }
209
    }
210

211
    /// Adds the lines of the provided span to the ignore set
212
    pub fn ignore_span(&mut self, span: Span) {
2,045✔
213
        // If we're already ignoring everything no need to ignore this span
214
        if !self.ignore.contains(&Lines::All) {
4,090✔
215
            for i in span.start().line..=span.end().line {
6,910✔
216
                self.ignore.insert(Lines::Line(i));
8,496✔
217
                if self.cover.contains(&i) {
8,503✔
218
                    self.cover.remove(&i);
14✔
219
                }
220
            }
221
        }
222
    }
223

224
    /// Cover all tokens in the given tokenstream
225
    pub fn cover_token_stream(&mut self, tokens: TokenStream, contents: Option<&str>) {
×
226
        for token in tokens {
×
227
            self.cover_span(token.span(), contents);
×
228
        }
229
    }
230

231
    /// Adds the lines of the provided span to the cover set
232
    pub fn cover_span(&mut self, span: Span, contents: Option<&str>) {
20✔
233
        // Not checking for Lines::All because I trust we've called cover_span
234
        // for a reason.
235
        let mut useful_lines: HashSet<usize> = HashSet::new();
60✔
236
        if let Some(c) = contents {
40✔
237
            static SINGLE_LINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*//").unwrap());
15✔
238
            const MULTI_START: &str = "/*";
239
            const MULTI_END: &str = "*/";
240
            let len = span.end().line - span.start().line;
60✔
241
            let mut is_comment = false;
40✔
242
            for (i, line) in c.lines().enumerate().skip(span.start().line - 1).take(len) {
266✔
243
                let is_code = if line.contains(MULTI_START) {
189✔
244
                    if !line.contains(MULTI_END) {
2✔
245
                        is_comment = true;
1✔
246
                    }
247
                    false
1✔
248
                } else if is_comment {
62✔
249
                    if line.contains(MULTI_END) {
5✔
250
                        is_comment = false;
1✔
251
                    }
252
                    false
2✔
253
                } else {
254
                    true
60✔
255
                };
256
                if is_code && !SINGLE_LINE.is_match(line) {
239✔
257
                    useful_lines.insert(i + 1);
112✔
258
                }
259
            }
260
        }
261
        for i in span.start().line..=span.end().line {
123✔
262
            if !self.ignore.contains(&Lines::Line(i)) && useful_lines.contains(&i) {
435✔
263
                self.cover.insert(i);
100✔
264
            }
265
        }
266
    }
267

268
    /// Shows whether the line should be ignored by tarpaulin
269
    pub fn should_ignore(&self, line: usize) -> bool {
21,662✔
270
        self.ignore.contains(&Lines::Line(line))
64,986✔
271
            || self.ignore.contains(&Lines::All)
62,367✔
272
            || (self.max_line > 0 && self.max_line < line)
39,979✔
273
    }
274

275
    /// Adds a line to the list of lines to ignore
276
    fn add_to_ignore(&mut self, lines: impl IntoIterator<Item = usize>) {
1,566✔
277
        if !self.ignore.contains(&Lines::All) {
3,132✔
278
            for l in lines {
4,075✔
279
                self.ignore.insert(Lines::Line(l));
7,527✔
280
                if self.cover.contains(&l) {
7,531✔
281
                    self.cover.remove(&l);
8✔
282
                }
283
            }
284
        }
285
    }
286
}
287

288
impl Function {
289
    fn new(name: &str, span: (usize, usize)) -> Self {
422✔
290
        Self {
291
            name: name.to_string(),
1,266✔
292
            start: span.0 as u64,
422✔
293
            end: span.1 as u64,
422✔
294
        }
295
    }
296
}
297

298
#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize)]
299
pub struct Function {
300
    pub name: String,
301
    pub start: u64,
302
    pub end: u64,
303
}
304

305
#[derive(Default)]
306
pub struct SourceAnalysis {
307
    pub lines: HashMap<PathBuf, LineAnalysis>,
308
    ignored_modules: Vec<PathBuf>,
309
}
310

311
impl SourceAnalysis {
312
    pub fn new() -> Self {
241✔
313
        Default::default()
241✔
314
    }
315

316
    pub fn create_function_map(&self) -> HashMap<PathBuf, Vec<Function>> {
163✔
317
        self.lines
163✔
318
            .iter()
319
            .map(|(file, analysis)| {
468✔
320
                let mut functions: Vec<Function> = analysis
915✔
321
                    .functions
305✔
322
                    .iter()
305✔
323
                    .map(|(function, span)| Function::new(function, *span))
1,571✔
324
                    .collect();
305✔
325
                functions.sort_unstable_by(|a, b| a.start.cmp(&b.start));
1,336✔
326
                (file.to_path_buf(), functions)
305✔
327
            })
328
            .collect()
329
    }
330

331
    pub fn get_line_analysis(&mut self, path: PathBuf) -> &mut LineAnalysis {
3,663✔
332
        self.lines
3,663✔
333
            .entry(path.clone())
10,989✔
334
            .or_insert_with(|| LineAnalysis::new_from_file(&path).unwrap_or_default())
4,758✔
335
    }
336

337
    fn is_ignored_module(&self, path: &Path) -> bool {
288✔
338
        self.ignored_modules.iter().any(|x| path.starts_with(x))
624✔
339
    }
340

341
    pub fn get_analysis(config: &Config) -> Self {
162✔
342
        let mut result = Self::new();
324✔
343
        let mut ignored_files: HashSet<PathBuf> = HashSet::new();
486✔
344
        let root = config.root();
486✔
345

346
        for e in get_source_walker(config) {
628✔
347
            if !ignored_files.contains(e.path()) {
1,216✔
348
                result.analyse_package(e.path(), &root, config, &mut ignored_files);
1,520✔
349
            } else {
350
                let mut analysis = LineAnalysis::new();
×
351
                analysis.ignore_all();
×
352
                result.lines.insert(e.path().to_path_buf(), analysis);
×
353
                ignored_files.remove(e.path());
×
354
            }
355
        }
356
        for e in &ignored_files {
166✔
357
            let mut analysis = LineAnalysis::new();
6✔
358
            analysis.ignore_all();
6✔
359
            result.lines.insert(e.clone(), analysis);
8✔
360
        }
361
        result.debug_printout(config);
486✔
362

363
        result
162✔
364
    }
365

366
    /// Analyses a package of the target crate.
367
    fn analyse_package(
304✔
368
        &mut self,
369
        path: &Path,
370
        root: &Path,
371
        config: &Config,
372
        filtered_files: &mut HashSet<PathBuf>,
373
    ) {
374
        if let Some(file) = path.to_str() {
608✔
375
            let skip_cause_test = !config.include_tests() && path.starts_with(root.join("tests"));
1,256✔
376
            let skip_cause_example = path.starts_with(root.join("examples"))
1,520✔
377
                && !config.run_types.contains(&RunType::Examples);
36✔
378
            if (skip_cause_test || skip_cause_example) || self.is_ignored_module(path) {
1,484✔
379
                let mut analysis = LineAnalysis::new();
54✔
380
                analysis.ignore_all();
54✔
381
                self.lines.insert(path.to_path_buf(), analysis);
72✔
382
            } else {
383
                let file = File::open(file);
858✔
384
                if let Ok(mut file) = file {
572✔
385
                    let mut content = String::new();
572✔
386
                    let res = file.read_to_string(&mut content);
1,144✔
387
                    if let Err(e) = res {
286✔
388
                        warn!(
×
389
                            "Unable to read file into string, skipping source analysis: {}",
390
                            e
391
                        );
392
                        return;
×
393
                    }
394
                    let file = parse_file(&content);
858✔
395
                    if let Ok(file) = file {
572✔
396
                        let ctx = Context {
397
                            config,
398
                            file_contents: &content,
572✔
399
                            file: path,
400
                            ignore_mods: RefCell::new(HashSet::new()),
858✔
401
                            symbol_stack: RefCell::new(vec![]),
286✔
402
                        };
403
                        if self.check_attr_list(&file.attrs, &ctx) {
1,144✔
404
                            self.find_ignorable_lines(&ctx);
828✔
405
                            self.process_items(&file.items, &ctx);
1,104✔
406

407
                            let mut ignored_files = ctx.ignore_mods.into_inner();
828✔
408
                            for f in ignored_files.drain() {
554✔
409
                                if f.is_file() {
4✔
410
                                    filtered_files.insert(f);
4✔
411
                                } else {
UNCOV
412
                                    let walker = WalkDir::new(f).into_iter();
×
UNCOV
413
                                    for e in walker
×
UNCOV
414
                                        .filter_map(std::result::Result::ok)
×
UNCOV
415
                                        .filter(is_source_file)
×
416
                                    {
417
                                        filtered_files.insert(e.path().to_path_buf());
×
418
                                    }
419
                                }
420
                            }
421
                            maybe_ignore_first_line(path, &mut self.lines);
828✔
422
                        } else {
423
                            // Now we need to ignore not only this file but if it is a lib.rs or
424
                            // mod.rs we need to get the others
425
                            let bad_module =
10✔
426
                                match (path.parent(), path.file_name().map(OsStr::to_string_lossy))
50✔
427
                                {
428
                                    (Some(p), Some(n)) => {
20✔
429
                                        if n == "lib.rs" || n == "mod.rs" {
16✔
430
                                            Some(p.to_path_buf())
6✔
431
                                        } else {
432
                                            let ignore = p.join(n.trim_end_matches(".rs"));
16✔
433
                                            if ignore.exists() && ignore.is_dir() {
6✔
434
                                                Some(ignore)
2✔
435
                                            } else {
436
                                                None
2✔
437
                                            }
438
                                        }
439
                                    }
440
                                    _ => None,
×
441
                                };
442
                            // Kill it with fire!`
443
                            if let Some(module) = bad_module {
18✔
444
                                self.lines
8✔
445
                                    .iter_mut()
446
                                    .filter(|(k, _)| k.starts_with(module.as_path()))
128✔
447
                                    .for_each(|(_, v)| v.ignore_all());
28✔
448
                                self.ignored_modules.push(module);
24✔
449
                            }
450
                            let analysis = self.get_line_analysis(path.to_path_buf());
50✔
451
                            analysis.ignore_span(file.span());
40✔
452
                        }
453
                    }
454
                }
455
            }
456
        }
457
    }
458

459
    /// Finds lines from the raw string which are ignorable.
460
    /// These are often things like close braces, semicolons that may register as
461
    /// false positives.
462
    pub(crate) fn find_ignorable_lines(&mut self, ctx: &Context) {
277✔
463
        static IGNORABLE: LazyLock<Regex> =
464
            LazyLock::new(|| Regex::new(r"^((\s*//)|([\[\]\{\}\(\)\s;\?,/]*$))").unwrap());
363✔
465
        let analysis = self.get_line_analysis(ctx.file.to_path_buf());
1,385✔
466
        let lines = ctx
554✔
467
            .file_contents
277✔
468
            .lines()
469
            .enumerate()
470
            .filter(|&(_, x)| IGNORABLE.is_match(x))
12,775✔
471
            .map(|(i, _)| i + 1);
2,047✔
472
        analysis.add_to_ignore(lines);
831✔
473

474
        let lines = ctx
554✔
475
            .file_contents
277✔
476
            .lines()
477
            .enumerate()
478
            .filter(|&(_, x)| {
4,443✔
479
                let mut x = x.to_string();
12,498✔
480
                x.retain(|c| !c.is_whitespace());
126,084✔
481
                x == "}else{"
4,166✔
482
            })
483
            .map(|(i, _)| i + 1);
309✔
484
        analysis.add_to_ignore(lines);
831✔
485
    }
486

487
    pub(crate) fn visit_generics(&mut self, generics: &Generics, ctx: &Context) {
486✔
488
        if let Some(ref wh) = generics.where_clause {
498✔
489
            let analysis = self.get_line_analysis(ctx.file.to_path_buf());
36✔
490
            analysis.ignore_tokens(wh);
12✔
491
        }
492
    }
493

494
    /// Printout a debug summary of the results of source analysis if debug logging
495
    /// is enabled
496
    #[cfg(not(tarpaulin_include))]
497
    pub fn debug_printout(&self, config: &Config) {
498
        if config.debug {
499
            for (path, analysis) in &self.lines {
500
                trace!(
501
                    "Source analysis for {}",
502
                    config.strip_base_dir(path).display()
503
                );
504
                let mut lines = Vec::new();
505
                for l in &analysis.ignore {
506
                    match l {
507
                        Lines::All => {
508
                            lines.clear();
509
                            trace!("All lines are ignorable");
510
                            break;
511
                        }
512
                        Lines::Line(i) => {
513
                            lines.push(i);
514
                        }
515
                    }
516
                }
517
                if !lines.is_empty() {
518
                    lines.sort();
519
                    trace!("Ignorable lines: {:?}", lines);
520
                    lines.clear();
521
                }
522
                for c in &analysis.cover {
523
                    lines.push(c);
524
                }
525

526
                if !lines.is_empty() {
527
                    lines.sort();
528
                    trace!("Coverable lines: {:?}", lines);
529
                }
530
            }
531
        }
532
    }
533
}
534

535
/// lib.rs:1 can often show up as a coverable line when it's not. This ignores
536
/// that line as long as it's not a real source line. This can also affect
537
/// the main files for binaries in a project as well.
538
fn maybe_ignore_first_line(file: &Path, result: &mut HashMap<PathBuf, LineAnalysis>) {
276✔
539
    if let Ok(f) = File::open(file) {
552✔
540
        let read_file = BufReader::new(f);
828✔
541
        if let Some(Ok(first)) = read_file.lines().next() {
828✔
542
            if !(first.starts_with("pub") || first.starts_with("fn")) {
676✔
543
                let file = file.to_path_buf();
776✔
544
                let line_analysis = result.entry(file).or_default();
1,164✔
545
                line_analysis.add_to_ignore([1]);
388✔
546
            }
547
        }
548
    }
549
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc