padded_cell 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/Cargo.lock +16 -2
- data/Cargo.toml +2 -0
- data/README.md +170 -8
- data/ext/padded_cell_native/core/Cargo.toml +2 -1
- data/ext/padded_cell_native/core/src/chart/directives/tests.rs +56 -0
- data/ext/padded_cell_native/core/src/chart/directives.rs +144 -0
- data/ext/padded_cell_native/core/src/chart/json/tests.rs +46 -0
- data/ext/padded_cell_native/core/src/chart/json.rs +175 -0
- data/ext/padded_cell_native/core/src/chart/lcov.rs +50 -0
- data/ext/padded_cell_native/core/src/chart/markdown/tests.rs +93 -0
- data/ext/padded_cell_native/core/src/chart/markdown.rs +201 -0
- data/ext/padded_cell_native/core/src/chart/methods/tests.rs +38 -0
- data/ext/padded_cell_native/core/src/chart/methods.rs +62 -0
- data/ext/padded_cell_native/core/src/chart/output/tests.rs +45 -0
- data/ext/padded_cell_native/core/src/chart/output.rs +103 -0
- data/ext/padded_cell_native/core/src/chart/results/tests.rs +96 -0
- data/ext/padded_cell_native/core/src/chart/results.rs +379 -0
- data/ext/padded_cell_native/core/src/chart/source/tests.rs +64 -0
- data/ext/padded_cell_native/core/src/chart/source.rs +153 -0
- data/ext/padded_cell_native/core/src/chart/stats/tests.rs +52 -0
- data/ext/padded_cell_native/core/src/chart/stats.rs +218 -0
- data/ext/padded_cell_native/core/src/chart/test_support.rs +93 -0
- data/ext/padded_cell_native/core/src/chart/tests.rs +97 -0
- data/ext/padded_cell_native/core/src/chart/thresholds/tests.rs +36 -0
- data/ext/padded_cell_native/core/src/chart/thresholds.rs +116 -0
- data/ext/padded_cell_native/core/src/chart.rs +302 -0
- data/ext/padded_cell_native/core/src/discover/tests.rs +2 -0
- data/ext/padded_cell_native/core/src/discover.rs +7 -53
- data/ext/padded_cell_native/core/src/error.rs +24 -0
- data/ext/padded_cell_native/core/src/lib.rs +10 -3
- data/ext/padded_cell_native/core/src/parallel.rs +43 -0
- data/ext/padded_cell_native/core/src/restarts/tests.rs +29 -0
- data/ext/padded_cell_native/core/src/restarts.rs +64 -0
- data/ext/padded_cell_native/ffi/Cargo.toml +2 -1
- data/ext/padded_cell_native/ffi/src/chart.rs +205 -0
- data/ext/padded_cell_native/ffi/src/lib.rs +5 -0
- data/ext/padded_cell_native/ffi/src/limits.rs +83 -0
- data/lib/padded_cell/asylum.rb +93 -0
- data/lib/padded_cell/cell.rb +23 -14
- data/lib/padded_cell/chart/accumulator.rb +103 -0
- data/lib/padded_cell/chart/config.rb +129 -0
- data/lib/padded_cell/chart/delta.rb +148 -0
- data/lib/padded_cell/chart/document.rb +64 -0
- data/lib/padded_cell/chart/filter.rb +55 -0
- data/lib/padded_cell/chart/merge.rb +63 -0
- data/lib/padded_cell/chart/probe.rb +70 -0
- data/lib/padded_cell/chart/raw.rb +48 -0
- data/lib/padded_cell/chart/report.rb +128 -0
- data/lib/padded_cell/chart/source_files.rb +90 -0
- data/lib/padded_cell/chart.rb +38 -0
- data/lib/padded_cell/cli.rb +70 -4
- data/lib/padded_cell/limits.rb +45 -0
- data/lib/padded_cell/loader.rb +5 -1
- data/lib/padded_cell/pool.rb +82 -58
- data/lib/padded_cell/reporters.rb +20 -3
- data/lib/padded_cell/results.rb +13 -0
- data/lib/padded_cell/runner.rb +150 -22
- data/lib/padded_cell/version.rb +1 -1
- data/lib/padded_cell/worker.rb +63 -0
- data/lib/padded_cell.rb +8 -0
- metadata +43 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
use std::ops::RangeInclusive;
|
|
2
|
+
use std::path::Path;
|
|
3
|
+
|
|
4
|
+
use crate::FileError;
|
|
5
|
+
|
|
6
|
+
#[cfg(test)]
|
|
7
|
+
mod tests;
|
|
8
|
+
|
|
9
|
+
/// A source file split into lines the way Ruby's `readlines` splits it:
|
|
10
|
+
/// after each newline, and a last line without one.
|
|
11
|
+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
12
|
+
pub struct Source {
|
|
13
|
+
bytes: Vec<u8>,
|
|
14
|
+
/// Where each line ends, its newline included.
|
|
15
|
+
ends: Vec<usize>,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl Source {
|
|
19
|
+
/// # Errors
|
|
20
|
+
///
|
|
21
|
+
/// [`FileError`] when the file cannot be read.
|
|
22
|
+
pub fn read(path: &Path) -> Result<Self, FileError> {
|
|
23
|
+
Ok(Self::new(FileError::read(path)?))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[must_use]
|
|
27
|
+
pub fn new(bytes: Vec<u8>) -> Self {
|
|
28
|
+
let mut ends: Vec<usize> = newline_ends(&bytes);
|
|
29
|
+
if bytes.last().is_some_and(|&last| last != b'\n') {
|
|
30
|
+
ends.push(bytes.len());
|
|
31
|
+
}
|
|
32
|
+
Self { bytes, ends }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// The number of lines.
|
|
36
|
+
#[must_use]
|
|
37
|
+
pub const fn len(&self) -> usize {
|
|
38
|
+
self.ends.len()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#[must_use]
|
|
42
|
+
pub const fn is_empty(&self) -> bool {
|
|
43
|
+
self.ends.is_empty()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[must_use]
|
|
47
|
+
pub fn bytes(&self) -> &[u8] {
|
|
48
|
+
&self.bytes
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// The 1-based line and the 0-based byte column of `offset`.
|
|
52
|
+
#[must_use]
|
|
53
|
+
pub fn position(&self, offset: usize) -> (u32, u32) {
|
|
54
|
+
let line = self.ends.partition_point(|&end| end <= offset);
|
|
55
|
+
let number = |value: usize| u32::try_from(value).unwrap_or(u32::MAX);
|
|
56
|
+
(number(line.strict_add(1)), number(offset.strict_sub(self.line_start(offset))))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Where the line holding `offset` starts.
|
|
60
|
+
#[must_use]
|
|
61
|
+
pub fn line_start(&self, offset: usize) -> usize {
|
|
62
|
+
let line = self.ends.partition_point(|&end| end <= offset);
|
|
63
|
+
line.checked_sub(1).map_or(0, |previous| self.ends[previous])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Each line, its newline included.
|
|
67
|
+
pub fn lines(&self) -> impl Iterator<Item = &[u8]> {
|
|
68
|
+
let starts = std::iter::once(0).chain(self.ends.iter().copied());
|
|
69
|
+
starts.zip(&self.ends).map(|(start, &end)| &self.bytes[start..end])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// The `# :token:` blocks: from a marker comment to the next, both
|
|
73
|
+
/// included. A block left open runs to the last line.
|
|
74
|
+
#[must_use]
|
|
75
|
+
pub fn nocov(&self, token: &str) -> Vec<RangeInclusive<u32>> {
|
|
76
|
+
let mut markers: Vec<u32> = self
|
|
77
|
+
.lines()
|
|
78
|
+
.zip(1..)
|
|
79
|
+
.filter(|(line, _)| nocov_marker(line, token))
|
|
80
|
+
.map(|(_, n)| n)
|
|
81
|
+
.collect();
|
|
82
|
+
if markers.len() % 2 == 1 {
|
|
83
|
+
markers.push(u32::try_from(self.len()).unwrap_or(u32::MAX));
|
|
84
|
+
}
|
|
85
|
+
markers.as_chunks::<2>().0.iter().map(|&[start, end]| start..=end).collect()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// The lines of a file that never loaded, from `Coverage.line_stub`:
|
|
89
|
+
/// blank and comment lines, and lines in `skipped` blocks, are not code.
|
|
90
|
+
#[must_use]
|
|
91
|
+
pub fn relevant(
|
|
92
|
+
&self,
|
|
93
|
+
stub: &[Option<u64>],
|
|
94
|
+
skipped: &[RangeInclusive<u32>],
|
|
95
|
+
) -> Vec<Option<u64>> {
|
|
96
|
+
let mut lines = self.lines();
|
|
97
|
+
stub.iter()
|
|
98
|
+
.zip(1..)
|
|
99
|
+
.map(|(&hits, number)| {
|
|
100
|
+
let text = lines.next()?;
|
|
101
|
+
let code =
|
|
102
|
+
!blank_or_comment(text) && !skipped.iter().any(|block| block.contains(&number));
|
|
103
|
+
if code { hits } else { None }
|
|
104
|
+
})
|
|
105
|
+
.collect()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Without its line ending, as Ruby's `chomp`.
|
|
110
|
+
#[must_use]
|
|
111
|
+
pub fn chomp(line: &[u8]) -> &[u8] {
|
|
112
|
+
let line = line.strip_suffix(b"\n").unwrap_or(line);
|
|
113
|
+
line.strip_suffix(b"\r").unwrap_or(line)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Offsets just past each newline.
|
|
117
|
+
fn newline_ends(bytes: &[u8]) -> Vec<usize> {
|
|
118
|
+
bytes
|
|
119
|
+
.iter()
|
|
120
|
+
.enumerate()
|
|
121
|
+
.filter(|&(_, &byte)| byte == b'\n')
|
|
122
|
+
.map(|(index, _)| index.strict_add(1))
|
|
123
|
+
.collect()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Ruby's `\s`: ASCII whitespace only.
|
|
127
|
+
pub const fn ruby_space(byte: u8) -> bool {
|
|
128
|
+
matches!(byte, b' ' | b'\t' | b'\n' | b'\x0b' | b'\x0c' | b'\r')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Without its leading `\s`.
|
|
132
|
+
pub fn trim_start(line: &[u8]) -> &[u8] {
|
|
133
|
+
let start = line.iter().position(|&byte| !ruby_space(byte)).unwrap_or(line.len());
|
|
134
|
+
&line[start..]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// `/^\s*$/` or `/^\s*#/`.
|
|
138
|
+
#[must_use]
|
|
139
|
+
pub fn blank_or_comment(line: &[u8]) -> bool {
|
|
140
|
+
matches!(trim_start(line).first(), None | Some(b'#'))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// `/^(\s*)#(\s*)(:token:)/`.
|
|
144
|
+
fn nocov_marker(line: &[u8], token: &str) -> bool {
|
|
145
|
+
let Some(comment) = trim_start(line).strip_prefix(b"#") else {
|
|
146
|
+
return false;
|
|
147
|
+
};
|
|
148
|
+
let marker = trim_start(comment);
|
|
149
|
+
marker
|
|
150
|
+
.strip_prefix(b":")
|
|
151
|
+
.and_then(|rest| rest.strip_prefix(token.as_bytes()))
|
|
152
|
+
.is_some_and(|rest| rest.starts_with(b":"))
|
|
153
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
const fn stats(covered: u64, missed: u64, hits: u64) -> Stats {
|
|
4
|
+
Stats { covered, missed, omitted: 0, hits }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
#[test]
|
|
8
|
+
fn percent_and_strength() {
|
|
9
|
+
const CASES: &[(Stats, f64, f64)] = &[
|
|
10
|
+
(stats(0, 0, 0), 100.0, 0.0),
|
|
11
|
+
(stats(3, 0, 6), 100.0, 2.0),
|
|
12
|
+
(stats(0, 4, 0), 0.0, 0.0),
|
|
13
|
+
(stats(2, 1, 9), 200.0 / 3.0, 3.0),
|
|
14
|
+
];
|
|
15
|
+
for (stats, percent, strength) in CASES {
|
|
16
|
+
assert_eq!(stats.percent().to_bits(), percent.to_bits(), "{stats:?}");
|
|
17
|
+
assert_eq!(stats.strength().to_bits(), strength.to_bits(), "{stats:?}");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[test]
|
|
22
|
+
fn stats_sum() {
|
|
23
|
+
let total: Stats = [stats(1, 2, 3), stats(4, 5, 6)].into_iter().sum();
|
|
24
|
+
assert_eq!(total, stats(5, 7, 9));
|
|
25
|
+
assert_eq!(total.total(), 12);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Values from Ruby's `Float#floor(2)`.
|
|
29
|
+
#[test]
|
|
30
|
+
fn floor2_is_rubys_float_floor() {
|
|
31
|
+
const CASES: &[(f64, f64)] = &[
|
|
32
|
+
(0.29, 0.29),
|
|
33
|
+
(89.999, 89.99),
|
|
34
|
+
(100.0, 100.0),
|
|
35
|
+
(0.0, 0.0),
|
|
36
|
+
(200.0 / 3.0, 66.66),
|
|
37
|
+
(0.001, 0.0),
|
|
38
|
+
(57.142_857_142_857_14, 57.14),
|
|
39
|
+
];
|
|
40
|
+
for (number, expected) in CASES {
|
|
41
|
+
assert_eq!(floor2(*number).to_bits(), expected.to_bits(), "number: {number}");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[test]
|
|
46
|
+
fn unmeasured_files_are_at_zero_until_covered() {
|
|
47
|
+
let mut file = FileStats { unmeasured_branches: true, ..FileStats::default() };
|
|
48
|
+
assert_eq!(file.percent(Criterion::Branch).to_bits(), 0.0_f64.to_bits());
|
|
49
|
+
assert_eq!(file.percent(Criterion::Line).to_bits(), 100.0_f64.to_bits());
|
|
50
|
+
file.unmeasured_branches = false;
|
|
51
|
+
assert_eq!(file.percent(Criterion::Branch).to_bits(), 100.0_f64.to_bits());
|
|
52
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
use std::iter::Sum;
|
|
2
|
+
use std::ops::{Add, AddAssign};
|
|
3
|
+
|
|
4
|
+
use super::FileReport;
|
|
5
|
+
|
|
6
|
+
#[cfg(test)]
|
|
7
|
+
mod tests;
|
|
8
|
+
|
|
9
|
+
/// What coverage counts.
|
|
10
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
11
|
+
pub enum Criterion {
|
|
12
|
+
Line,
|
|
13
|
+
Branch,
|
|
14
|
+
Method,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
impl Criterion {
|
|
18
|
+
/// `line`: its name in `.last_run.json` and in meta keys.
|
|
19
|
+
#[must_use]
|
|
20
|
+
pub const fn singular(self) -> &'static str {
|
|
21
|
+
match self {
|
|
22
|
+
Self::Line => "line",
|
|
23
|
+
Self::Branch => "branch",
|
|
24
|
+
Self::Method => "method",
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// `lines`: its name in `coverage.json`.
|
|
29
|
+
#[must_use]
|
|
30
|
+
pub const fn plural(self) -> &'static str {
|
|
31
|
+
match self {
|
|
32
|
+
Self::Line => "lines",
|
|
33
|
+
Self::Branch => "branches",
|
|
34
|
+
Self::Method => "methods",
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The counts of one criterion, for a file, a group or a whole run.
|
|
40
|
+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
41
|
+
pub struct Stats {
|
|
42
|
+
pub covered: u64,
|
|
43
|
+
pub missed: u64,
|
|
44
|
+
/// Lines that are not code (lines only).
|
|
45
|
+
pub omitted: u64,
|
|
46
|
+
/// The hits of the covered items, summed.
|
|
47
|
+
pub hits: u64,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl Stats {
|
|
51
|
+
/// What can be covered.
|
|
52
|
+
#[must_use]
|
|
53
|
+
pub const fn total(&self) -> u64 {
|
|
54
|
+
self.covered.strict_add(self.missed)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Covered, in percent. 100 when nothing was missed, nothing to cover
|
|
58
|
+
/// included.
|
|
59
|
+
#[must_use]
|
|
60
|
+
pub fn percent(&self) -> f64 {
|
|
61
|
+
if self.missed == 0 { 100.0 } else { float(self.covered) * 100.0 / float(self.total()) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Hits per item that can be covered.
|
|
65
|
+
#[must_use]
|
|
66
|
+
pub fn strength(&self) -> f64 {
|
|
67
|
+
if self.total() == 0 { 0.0 } else { float(self.hits) / float(self.total()) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
impl Add for Stats {
|
|
72
|
+
type Output = Self;
|
|
73
|
+
|
|
74
|
+
fn add(self, other: Self) -> Self {
|
|
75
|
+
Self {
|
|
76
|
+
covered: self.covered.strict_add(other.covered),
|
|
77
|
+
missed: self.missed.strict_add(other.missed),
|
|
78
|
+
omitted: self.omitted.strict_add(other.omitted),
|
|
79
|
+
hits: self.hits.strict_add(other.hits),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
impl AddAssign for Stats {
|
|
85
|
+
fn add_assign(&mut self, other: Self) {
|
|
86
|
+
*self = *self + other;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
impl Sum for Stats {
|
|
91
|
+
fn sum<I: Iterator<Item = Self>>(stats: I) -> Self {
|
|
92
|
+
stats.fold(Self::default(), Add::add)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The counts of several files: a group, or a whole run. Its percents are
|
|
97
|
+
/// computed from the summed counts.
|
|
98
|
+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
99
|
+
pub struct Totals {
|
|
100
|
+
pub line: Stats,
|
|
101
|
+
pub branch: Stats,
|
|
102
|
+
pub method: Stats,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
impl Totals {
|
|
106
|
+
/// The totals of `files`.
|
|
107
|
+
#[must_use]
|
|
108
|
+
pub fn of_files<'r>(files: impl IntoIterator<Item = &'r FileReport<'r>>) -> Self {
|
|
109
|
+
Self::of(files.into_iter().map(|file| &file.stats.counts))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#[must_use]
|
|
113
|
+
pub fn of<'r>(files: impl IntoIterator<Item = &'r Self>) -> Self {
|
|
114
|
+
files.into_iter().fold(Self::default(), |totals, file| Self {
|
|
115
|
+
line: totals.line + file.line,
|
|
116
|
+
branch: totals.branch + file.branch,
|
|
117
|
+
method: totals.method + file.method,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#[must_use]
|
|
122
|
+
pub const fn get(&self, criterion: Criterion) -> Stats {
|
|
123
|
+
match criterion {
|
|
124
|
+
Criterion::Line => self.line,
|
|
125
|
+
Criterion::Branch => self.branch,
|
|
126
|
+
Criterion::Method => self.method,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// The counts of a file.
|
|
132
|
+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
133
|
+
pub struct FileStats {
|
|
134
|
+
pub counts: Totals,
|
|
135
|
+
/// A file never loaded, without branch data: its branch percent is 0
|
|
136
|
+
/// rather than 100.
|
|
137
|
+
unmeasured_branches: bool,
|
|
138
|
+
unmeasured_methods: bool,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
impl FileStats {
|
|
142
|
+
pub(super) fn of(report: &FileReport<'_>) -> Self {
|
|
143
|
+
let mut stats = Self {
|
|
144
|
+
unmeasured_branches: !report.file.loaded && report.file.branches.is_none(),
|
|
145
|
+
unmeasured_methods: !report.file.loaded && report.methods.is_none(),
|
|
146
|
+
..Self::default()
|
|
147
|
+
};
|
|
148
|
+
let counts = &mut stats.counts;
|
|
149
|
+
for line in report.lines() {
|
|
150
|
+
let hits = line.hits.unwrap_or(0);
|
|
151
|
+
count(&mut counts.line, line.covered(), line.missed(), hits);
|
|
152
|
+
counts.line.omitted = counts.line.omitted.strict_add(u64::from(line.never()));
|
|
153
|
+
}
|
|
154
|
+
for branch in report.branches() {
|
|
155
|
+
count(&mut counts.branch, branch.covered(), branch.missed(), branch.hits);
|
|
156
|
+
}
|
|
157
|
+
for method in report.methods() {
|
|
158
|
+
count(&mut counts.method, method.covered(), method.missed(), method.method.hits);
|
|
159
|
+
}
|
|
160
|
+
stats
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[must_use]
|
|
164
|
+
pub const fn get(&self, criterion: Criterion) -> Stats {
|
|
165
|
+
self.counts.get(criterion)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// The counts of `criterion`, and the file's percent.
|
|
169
|
+
#[must_use]
|
|
170
|
+
pub fn with_percent(&self, criterion: Criterion) -> (Stats, f64) {
|
|
171
|
+
(self.get(criterion), self.percent(criterion))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// The file's percent.
|
|
175
|
+
#[must_use]
|
|
176
|
+
pub fn percent(&self, criterion: Criterion) -> f64 {
|
|
177
|
+
let unmeasured = match criterion {
|
|
178
|
+
Criterion::Line => false,
|
|
179
|
+
Criterion::Branch => self.unmeasured_branches,
|
|
180
|
+
Criterion::Method => self.unmeasured_methods,
|
|
181
|
+
};
|
|
182
|
+
let stats = self.get(criterion);
|
|
183
|
+
if unmeasured && stats.covered == 0 { 0.0 } else { stats.percent() }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const fn count(stats: &mut Stats, covered: bool, missed: bool, hits: u64) {
|
|
188
|
+
if covered {
|
|
189
|
+
stats.covered = stats.covered.strict_add(1);
|
|
190
|
+
stats.hits = stats.hits.strict_add(hits);
|
|
191
|
+
} else if missed {
|
|
192
|
+
stats.missed = stats.missed.strict_add(1);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// Ruby's `Float#floor(2)`, which thresholds compare with:
|
|
197
|
+
/// 89.999 is 89.99, and 0.29 stays 0.29 although `0.29 * 100.0` is
|
|
198
|
+
/// 28.999999999999996.
|
|
199
|
+
#[must_use]
|
|
200
|
+
pub fn floor2(number: f64) -> f64 {
|
|
201
|
+
floor_digits(number, 2)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Ruby's `Float#floor(digits)`: floors `number * 10^digits`, then takes
|
|
205
|
+
/// the next step up when it does not exceed `number` (the product lost it).
|
|
206
|
+
#[must_use]
|
|
207
|
+
pub fn floor_digits(number: f64, digits: i32) -> f64 {
|
|
208
|
+
let scale = 10_f64.powi(digits);
|
|
209
|
+
let floored = (number * scale).floor();
|
|
210
|
+
let up = (floored + 1.0) / scale;
|
|
211
|
+
if up > number { floored / scale } else { up }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// Counts are far below 2^53: the conversion is exact.
|
|
215
|
+
#[expect(clippy::cast_precision_loss, reason = "counts stay far below 2^53")]
|
|
216
|
+
const fn float(count: u64) -> f64 {
|
|
217
|
+
count as f64
|
|
218
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
use std::fs;
|
|
2
|
+
|
|
3
|
+
use tempfile::TempDir;
|
|
4
|
+
|
|
5
|
+
use super::stats::Criterion;
|
|
6
|
+
use super::{
|
|
7
|
+
Arm, Condition, FileCoverage, FileReport, Meta, Method, Options, Span, Summary, Totals,
|
|
8
|
+
analyze_all,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const CALC: &str = "# frozen_string_literal: true
|
|
12
|
+
|
|
13
|
+
class Calc
|
|
14
|
+
def sign(number)
|
|
15
|
+
if number.positive?
|
|
16
|
+
:positive
|
|
17
|
+
else
|
|
18
|
+
:other
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# :nocov:
|
|
23
|
+
def debug = puts(1)
|
|
24
|
+
# :nocov:
|
|
25
|
+
|
|
26
|
+
def maybe(flag) = flag ? :yes : :no
|
|
27
|
+
end
|
|
28
|
+
";
|
|
29
|
+
|
|
30
|
+
pub const fn span(start_line: u32, start_column: u32, end_line: u32, end_column: u32) -> Span {
|
|
31
|
+
Span { start_line, start_column, end_line, end_column }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub fn arm(kind: &str, span: Span, hits: u64) -> Arm {
|
|
35
|
+
Arm { kind: kind.to_owned(), span, hits }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub fn method(name: &str, span: Span, hits: u64) -> Method {
|
|
39
|
+
Method { owner: "Calc".to_owned(), name: name.to_owned(), span, hits }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Calc, run once with a positive number and once with `maybe(true)`.
|
|
43
|
+
pub fn calc(dir: &TempDir) -> FileCoverage {
|
|
44
|
+
let path = dir.path().join("calc.rb");
|
|
45
|
+
fs::write(&path, CALC).unwrap();
|
|
46
|
+
let mut lines = vec![None; 17];
|
|
47
|
+
for (line, hits) in [(3, 1), (4, 1), (5, 2), (6, 1), (8, 1), (13, 1), (16, 1)] {
|
|
48
|
+
lines[line - 1] = Some(hits);
|
|
49
|
+
}
|
|
50
|
+
FileCoverage {
|
|
51
|
+
path,
|
|
52
|
+
name: "lib/calc.rb".to_owned(),
|
|
53
|
+
lines,
|
|
54
|
+
branches: Some(vec![
|
|
55
|
+
Condition {
|
|
56
|
+
kind: "if".to_owned(),
|
|
57
|
+
span: span(5, 4, 9, 7),
|
|
58
|
+
arms: vec![arm("then", span(6, 6, 6, 15), 1), arm("else", span(8, 6, 8, 12), 0)],
|
|
59
|
+
},
|
|
60
|
+
Condition {
|
|
61
|
+
kind: "if".to_owned(),
|
|
62
|
+
span: span(16, 20, 16, 37),
|
|
63
|
+
arms: vec![
|
|
64
|
+
arm("then", span(16, 27, 16, 31), 1),
|
|
65
|
+
arm("else", span(16, 34, 16, 37), 0),
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
]),
|
|
69
|
+
methods: Some(vec![
|
|
70
|
+
method("sign", span(4, 2, 10, 5), 2),
|
|
71
|
+
method("debug", span(13, 2, 13, 21), 0),
|
|
72
|
+
method("maybe", span(16, 2, 16, 37), 1),
|
|
73
|
+
]),
|
|
74
|
+
loaded: true,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// `files`, analyzed with the default options.
|
|
79
|
+
pub fn reports(files: &[FileCoverage]) -> Vec<FileReport<'_>> {
|
|
80
|
+
analyze_all(files, &Options::default()).unwrap()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// A run of `reports`, measuring `measured`, without groups or violations.
|
|
84
|
+
pub fn summary<'a>(reports: &'a [FileReport<'a>], measured: &[Criterion]) -> Summary<'a> {
|
|
85
|
+
Summary {
|
|
86
|
+
files: reports,
|
|
87
|
+
measured: measured.to_vec(),
|
|
88
|
+
totals: Totals::of_files(reports),
|
|
89
|
+
groups: Vec::new(),
|
|
90
|
+
violations: Vec::new(),
|
|
91
|
+
meta: Meta { generator: "padded_cell 0.1.0".to_owned(), ..Meta::default() },
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
use std::fs;
|
|
2
|
+
|
|
3
|
+
use tempfile::TempDir;
|
|
4
|
+
|
|
5
|
+
use super::test_support::calc;
|
|
6
|
+
use super::*;
|
|
7
|
+
|
|
8
|
+
#[test]
|
|
9
|
+
fn lines_branches_and_methods_classified() {
|
|
10
|
+
let dir = TempDir::new().unwrap();
|
|
11
|
+
let file = calc(&dir);
|
|
12
|
+
let report = analyze(&file, &Options::default()).unwrap();
|
|
13
|
+
|
|
14
|
+
let skipped: Vec<u32> =
|
|
15
|
+
report.lines().filter(|line| line.skipped).map(|line| line.number).collect();
|
|
16
|
+
assert_eq!(skipped, [12, 13, 14]);
|
|
17
|
+
let branches: Vec<(u32, bool, u64)> = report
|
|
18
|
+
.branches()
|
|
19
|
+
.map(|branch| (branch.report_line(), branch.inline, branch.hits))
|
|
20
|
+
.collect();
|
|
21
|
+
assert_eq!(branches, [(5, false, 1), (7, false, 0), (16, true, 1), (16, true, 0)]);
|
|
22
|
+
let methods: Vec<(&str, bool)> =
|
|
23
|
+
report.methods().map(|method| (method.method.name.as_str(), method.skipped)).collect();
|
|
24
|
+
assert_eq!(methods, [("sign", false), ("debug", true), ("maybe", false)]);
|
|
25
|
+
|
|
26
|
+
let stats = report.stats.counts;
|
|
27
|
+
assert_eq!(stats.line, Stats { covered: 6, missed: 0, omitted: 8, hits: 7 });
|
|
28
|
+
assert_eq!(stats.branch, Stats { covered: 2, missed: 2, omitted: 0, hits: 2 });
|
|
29
|
+
assert_eq!(stats.method, Stats { covered: 2, missed: 0, omitted: 0, hits: 3 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[test]
|
|
33
|
+
fn lcov_tracefile_byte_for_byte() {
|
|
34
|
+
let dir = TempDir::new().unwrap();
|
|
35
|
+
let empty = dir.path().join("empty.rb");
|
|
36
|
+
fs::write(&empty, "# nothing\n").unwrap();
|
|
37
|
+
let files = [
|
|
38
|
+
calc(&dir),
|
|
39
|
+
FileCoverage {
|
|
40
|
+
path: empty,
|
|
41
|
+
name: "lib/empty.rb".to_owned(),
|
|
42
|
+
lines: vec![None],
|
|
43
|
+
branches: Some(vec![]),
|
|
44
|
+
methods: None,
|
|
45
|
+
loaded: true,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
let reports = analyze_all(&files, &Options::default()).unwrap();
|
|
49
|
+
let mut out = Vec::new();
|
|
50
|
+
write_lcov(&reports, &mut out).unwrap();
|
|
51
|
+
|
|
52
|
+
let expected = "SF:./lib/calc.rb
|
|
53
|
+
DA:3,1
|
|
54
|
+
DA:4,1
|
|
55
|
+
DA:5,2
|
|
56
|
+
DA:6,1
|
|
57
|
+
DA:8,1
|
|
58
|
+
DA:16,1
|
|
59
|
+
LF:7
|
|
60
|
+
LH:6
|
|
61
|
+
BRDA:5,0,1,1
|
|
62
|
+
BRDA:7,0,2,-
|
|
63
|
+
BRDA:16,0,3,1
|
|
64
|
+
BRDA:16,0,4,-
|
|
65
|
+
end_of_record
|
|
66
|
+
SF:./lib/empty.rb
|
|
67
|
+
|
|
68
|
+
LF:0
|
|
69
|
+
LH:0
|
|
70
|
+
|
|
71
|
+
end_of_record
|
|
72
|
+
";
|
|
73
|
+
assert_eq!(String::from_utf8(out).unwrap(), expected);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[test]
|
|
77
|
+
fn files_never_loaded_count_their_code_as_missed() {
|
|
78
|
+
let dir = TempDir::new().unwrap();
|
|
79
|
+
let mut file = calc(&dir);
|
|
80
|
+
file.loaded = false;
|
|
81
|
+
file.lines = vec![Some(0); 17];
|
|
82
|
+
file.branches = None;
|
|
83
|
+
file.methods = None;
|
|
84
|
+
let report = analyze(&file, &Options::default()).unwrap();
|
|
85
|
+
|
|
86
|
+
assert_eq!(report.stats.counts.line, Stats { covered: 0, missed: 10, omitted: 4, hits: 0 });
|
|
87
|
+
assert_eq!(report.stats.percent(Criterion::Branch).to_bits(), 0.0_f64.to_bits());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[test]
|
|
91
|
+
fn unreadable_sources_are_file_errors() {
|
|
92
|
+
let dir = TempDir::new().unwrap();
|
|
93
|
+
let mut file = calc(&dir);
|
|
94
|
+
file.path = dir.path().join("missing.rb");
|
|
95
|
+
let error = analyze(&file, &Options::default()).unwrap_err();
|
|
96
|
+
assert_eq!(error.path, file.path);
|
|
97
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
use tempfile::TempDir;
|
|
2
|
+
|
|
3
|
+
use super::super::test_support::{calc, reports};
|
|
4
|
+
use super::*;
|
|
5
|
+
|
|
6
|
+
/// Calc: lines 100% (6/6), branches 50% (2/4).
|
|
7
|
+
#[test]
|
|
8
|
+
fn every_kind_of_threshold() {
|
|
9
|
+
let dir = TempDir::new().unwrap();
|
|
10
|
+
let files = [calc(&dir)];
|
|
11
|
+
let reports = reports(&files);
|
|
12
|
+
let totals = Totals::of_files(&reports);
|
|
13
|
+
let thresholds = Thresholds {
|
|
14
|
+
minimum: [Some(100.0), Some(60.0), Some(100.0)],
|
|
15
|
+
minimum_by_file: [None, Some(50.01), None],
|
|
16
|
+
minimum_by_group: vec![("Core".to_owned(), [Some(100.0), Some(49.99), None])],
|
|
17
|
+
maximum_drop: [Some(0.0), Some(5.0), None],
|
|
18
|
+
last_run: [Some(100.0), Some(60.0), None],
|
|
19
|
+
};
|
|
20
|
+
let groups = [("Core".to_owned(), totals), ("Other".to_owned(), Totals::default())];
|
|
21
|
+
let found =
|
|
22
|
+
violations(&thresholds, &[Criterion::Line, Criterion::Branch], &totals, &reports, &groups);
|
|
23
|
+
|
|
24
|
+
let violation =
|
|
25
|
+
|scope, limit, actual| Violation { scope, criterion: Criterion::Branch, limit, actual };
|
|
26
|
+
assert_eq!(
|
|
27
|
+
found,
|
|
28
|
+
[
|
|
29
|
+
violation(Scope::Total, 60.0, 50.0),
|
|
30
|
+
violation(Scope::File("lib/calc.rb".to_owned()), 50.01, 50.0),
|
|
31
|
+
// Dropped from 60% to 50%: more than 5 points. Methods were not
|
|
32
|
+
// measured: their minimum is not checked.
|
|
33
|
+
violation(Scope::Drop, 5.0, 10.0),
|
|
34
|
+
]
|
|
35
|
+
);
|
|
36
|
+
}
|