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,175 @@
|
|
|
1
|
+
use std::io::{self, Write};
|
|
2
|
+
|
|
3
|
+
#[cfg(test)]
|
|
4
|
+
mod tests;
|
|
5
|
+
|
|
6
|
+
/// A JSON writer. Containers take a closure writing their members:
|
|
7
|
+
///
|
|
8
|
+
/// ```text
|
|
9
|
+
/// json.object(|json| {
|
|
10
|
+
/// json.key("lines")?.array(|json| json.item()?.uint(1))
|
|
11
|
+
/// })
|
|
12
|
+
/// ```
|
|
13
|
+
pub struct Json<'w, W: Write> {
|
|
14
|
+
out: &'w mut W,
|
|
15
|
+
pretty: bool,
|
|
16
|
+
depth: usize,
|
|
17
|
+
/// The innermost container has no member yet.
|
|
18
|
+
empty: bool,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
impl<'w, W: Write> Json<'w, W> {
|
|
22
|
+
/// `JSON.pretty_generate`'s layout: two-space indents, one member per
|
|
23
|
+
/// line, `[]` and `{}` when empty.
|
|
24
|
+
pub const fn pretty(out: &'w mut W) -> Self {
|
|
25
|
+
Self { out, pretty: true, depth: 0, empty: true }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// `JSON.generate`'s: no whitespace.
|
|
29
|
+
pub const fn compact(out: &'w mut W) -> Self {
|
|
30
|
+
Self { out, pretty: false, depth: 0, empty: true }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// # Errors
|
|
34
|
+
///
|
|
35
|
+
/// What writing returns.
|
|
36
|
+
pub fn object(&mut self, members: impl FnOnce(&mut Self) -> io::Result<()>) -> io::Result<()> {
|
|
37
|
+
self.container(b'{', b'}', members)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// # Errors
|
|
41
|
+
///
|
|
42
|
+
/// What writing returns.
|
|
43
|
+
pub fn array(&mut self, items: impl FnOnce(&mut Self) -> io::Result<()>) -> io::Result<()> {
|
|
44
|
+
self.container(b'[', b']', items)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Starts an object member: its value comes next.
|
|
48
|
+
///
|
|
49
|
+
/// # Errors
|
|
50
|
+
///
|
|
51
|
+
/// What writing returns.
|
|
52
|
+
pub fn key(&mut self, key: &str) -> io::Result<&mut Self> {
|
|
53
|
+
self.separate()?;
|
|
54
|
+
self.raw_str(key)?;
|
|
55
|
+
self.out.write_all(if self.pretty { b": " } else { b":" })?;
|
|
56
|
+
Ok(self)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Starts an array item: its value comes next.
|
|
60
|
+
///
|
|
61
|
+
/// # Errors
|
|
62
|
+
///
|
|
63
|
+
/// What writing returns.
|
|
64
|
+
pub fn item(&mut self) -> io::Result<&mut Self> {
|
|
65
|
+
self.separate()?;
|
|
66
|
+
Ok(self)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// # Errors
|
|
70
|
+
///
|
|
71
|
+
/// What writing returns.
|
|
72
|
+
pub fn str(&mut self, value: &str) -> io::Result<()> {
|
|
73
|
+
self.raw_str(value)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// # Errors
|
|
77
|
+
///
|
|
78
|
+
/// What writing returns.
|
|
79
|
+
pub fn uint(&mut self, value: u64) -> io::Result<()> {
|
|
80
|
+
write!(self.out, "{value}")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Ruby's way: `100.0`, not `100`.
|
|
84
|
+
///
|
|
85
|
+
/// # Errors
|
|
86
|
+
///
|
|
87
|
+
/// What writing returns.
|
|
88
|
+
pub fn float(&mut self, value: f64) -> io::Result<()> {
|
|
89
|
+
let mut text = value.to_string();
|
|
90
|
+
if !text.contains(['.', 'e', 'N', 'i']) {
|
|
91
|
+
text.push_str(".0");
|
|
92
|
+
}
|
|
93
|
+
self.out.write_all(text.as_bytes())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// # Errors
|
|
97
|
+
///
|
|
98
|
+
/// What writing returns.
|
|
99
|
+
pub fn bool(&mut self, value: bool) -> io::Result<()> {
|
|
100
|
+
self.out.write_all(if value { b"true" } else { b"false" })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// # Errors
|
|
104
|
+
///
|
|
105
|
+
/// What writing returns.
|
|
106
|
+
pub fn null(&mut self) -> io::Result<()> {
|
|
107
|
+
self.out.write_all(b"null")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn container(
|
|
111
|
+
&mut self,
|
|
112
|
+
open: u8,
|
|
113
|
+
close: u8,
|
|
114
|
+
members: impl FnOnce(&mut Self) -> io::Result<()>,
|
|
115
|
+
) -> io::Result<()> {
|
|
116
|
+
self.out.write_all(&[open])?;
|
|
117
|
+
self.depth = self.depth.strict_add(1);
|
|
118
|
+
self.empty = true;
|
|
119
|
+
members(self)?;
|
|
120
|
+
self.depth = self.depth.strict_sub(1);
|
|
121
|
+
if !self.empty {
|
|
122
|
+
self.newline()?;
|
|
123
|
+
}
|
|
124
|
+
// The enclosing container has this one as a member.
|
|
125
|
+
self.empty = false;
|
|
126
|
+
self.out.write_all(&[close])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fn separate(&mut self) -> io::Result<()> {
|
|
130
|
+
if !self.empty {
|
|
131
|
+
self.out.write_all(b",")?;
|
|
132
|
+
}
|
|
133
|
+
self.empty = false;
|
|
134
|
+
self.newline()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn newline(&mut self) -> io::Result<()> {
|
|
138
|
+
if self.pretty {
|
|
139
|
+
self.out.write_all(b"\n")?;
|
|
140
|
+
for _ in 0..self.depth {
|
|
141
|
+
self.out.write_all(b" ")?;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
Ok(())
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Escapes what JSON requires, in runs: no copy of the string.
|
|
148
|
+
fn raw_str(&mut self, value: &str) -> io::Result<()> {
|
|
149
|
+
self.out.write_all(b"\"")?;
|
|
150
|
+
let bytes = value.as_bytes();
|
|
151
|
+
let mut start = 0;
|
|
152
|
+
for (index, &byte) in bytes.iter().enumerate() {
|
|
153
|
+
let escape: &[u8] = match byte {
|
|
154
|
+
b'"' => b"\\\"",
|
|
155
|
+
b'\\' => b"\\\\",
|
|
156
|
+
0x08 => b"\\b",
|
|
157
|
+
0x0c => b"\\f",
|
|
158
|
+
b'\n' => b"\\n",
|
|
159
|
+
b'\r' => b"\\r",
|
|
160
|
+
b'\t' => b"\\t",
|
|
161
|
+
0x20.. => continue,
|
|
162
|
+
_ => b"",
|
|
163
|
+
};
|
|
164
|
+
self.out.write_all(&bytes[start..index])?;
|
|
165
|
+
if escape.is_empty() {
|
|
166
|
+
write!(self.out, "\\u{byte:04x}")?;
|
|
167
|
+
} else {
|
|
168
|
+
self.out.write_all(escape)?;
|
|
169
|
+
}
|
|
170
|
+
start = index.strict_add(1);
|
|
171
|
+
}
|
|
172
|
+
self.out.write_all(&bytes[start..])?;
|
|
173
|
+
self.out.write_all(b"\"")
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
use std::io::{self, Write};
|
|
2
|
+
|
|
3
|
+
use super::FileReport;
|
|
4
|
+
|
|
5
|
+
/// Writes `reports` as an LCOV tracefile:
|
|
6
|
+
///
|
|
7
|
+
/// ```text
|
|
8
|
+
/// SF:./app/models/user.rb
|
|
9
|
+
/// DA:3,1 each line of code outside :nocov: blocks
|
|
10
|
+
/// LF:12 lines of code, those in :nocov: blocks included
|
|
11
|
+
/// LH:9 lines covered
|
|
12
|
+
/// BRDA:4,0,1,2 each branch outside blocks: line, 0, number, hits
|
|
13
|
+
/// BRDA:6,0,2,- ("-" for none)
|
|
14
|
+
/// end_of_record
|
|
15
|
+
/// ```
|
|
16
|
+
///
|
|
17
|
+
/// An empty `DA` or `BRDA` section leaves an empty line.
|
|
18
|
+
///
|
|
19
|
+
/// # Errors
|
|
20
|
+
///
|
|
21
|
+
/// What writing to `out` returns.
|
|
22
|
+
pub fn write_lcov(reports: &[FileReport<'_>], out: &mut impl Write) -> io::Result<()> {
|
|
23
|
+
for report in reports {
|
|
24
|
+
writeln!(out, "SF:./{}", report.file.name)?;
|
|
25
|
+
let (mut code, mut covered, mut first) = (0_u64, 0_u64, true);
|
|
26
|
+
for line in report.lines() {
|
|
27
|
+
code = code.strict_add(u64::from(line.hits.is_some()));
|
|
28
|
+
covered = covered.strict_add(u64::from(line.covered()));
|
|
29
|
+
if let Some(hits) = line.hits
|
|
30
|
+
&& !line.skipped
|
|
31
|
+
{
|
|
32
|
+
if !first {
|
|
33
|
+
out.write_all(b"\n")?;
|
|
34
|
+
}
|
|
35
|
+
first = false;
|
|
36
|
+
write!(out, "DA:{},{hits}", line.number)?;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
write!(out, "\nLF:{code}\nLH:{covered}\n")?;
|
|
40
|
+
for (number, branch) in (1_u64..).zip(report.reported_branches()) {
|
|
41
|
+
if number > 1 {
|
|
42
|
+
out.write_all(b"\n")?;
|
|
43
|
+
}
|
|
44
|
+
write!(out, "BRDA:{},0,{number},", branch.report_line())?;
|
|
45
|
+
if branch.hits == 0 { out.write_all(b"-")? } else { write!(out, "{}", branch.hits)? }
|
|
46
|
+
}
|
|
47
|
+
out.write_all(b"\nend_of_record\n")?;
|
|
48
|
+
}
|
|
49
|
+
Ok(())
|
|
50
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
use std::fs;
|
|
2
|
+
|
|
3
|
+
use tempfile::TempDir;
|
|
4
|
+
|
|
5
|
+
use super::super::FileCoverage;
|
|
6
|
+
use super::super::test_support::{calc, reports, summary};
|
|
7
|
+
use super::*;
|
|
8
|
+
|
|
9
|
+
/// Calc misses two branches; gap.rb misses a line holding backticks, and
|
|
10
|
+
/// one line further down.
|
|
11
|
+
#[test]
|
|
12
|
+
fn totals_groups_then_the_code_that_never_ran() {
|
|
13
|
+
let dir = TempDir::new().unwrap();
|
|
14
|
+
let gap = dir.path().join("gap.rb");
|
|
15
|
+
fs::write(&gap, "def a\n 1\nend\n\ndef b\n x = \"```\"\n :y\nend\n\n:z\n").unwrap();
|
|
16
|
+
let mut lines =
|
|
17
|
+
vec![Some(1), Some(1), None, None, Some(1), Some(0), Some(0), None, None, Some(0)];
|
|
18
|
+
lines.truncate(10);
|
|
19
|
+
let files = [
|
|
20
|
+
calc(&dir),
|
|
21
|
+
FileCoverage {
|
|
22
|
+
path: gap,
|
|
23
|
+
name: "lib/gap.rb".to_owned(),
|
|
24
|
+
lines,
|
|
25
|
+
branches: Some(Vec::new()),
|
|
26
|
+
methods: None,
|
|
27
|
+
loaded: true,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
let reports = reports(&files);
|
|
31
|
+
let mut summary = summary(&reports, &[Criterion::Line, Criterion::Branch]);
|
|
32
|
+
summary.groups =
|
|
33
|
+
vec![("Core".to_owned(), vec![0], super::super::Totals::of_files(&reports[..1]))];
|
|
34
|
+
summary.meta.project_name = "App".to_owned();
|
|
35
|
+
summary.meta.timestamp = "2026-10-11T13:00:00.000+02:00".to_owned();
|
|
36
|
+
summary.meta.commit = Some("0123456789abcdef".to_owned());
|
|
37
|
+
let mut out = Vec::new();
|
|
38
|
+
write_markdown(&summary, &mut out).unwrap();
|
|
39
|
+
|
|
40
|
+
let expected = "\
|
|
41
|
+
# Coverage: App
|
|
42
|
+
|
|
43
|
+
Lines 75.00% (9 / 12) · Branches 50.00% (2 / 4)
|
|
44
|
+
|
|
45
|
+
Generated 2026-10-11T13:00:00.000+02:00 by padded_cell 0.1.0, at `0123456789ab`.
|
|
46
|
+
|
|
47
|
+
| Group | Lines | Branches |
|
|
48
|
+
|---|---:|---:|
|
|
49
|
+
| Core | 100.00% (6 / 6) | 50.00% (2 / 4) |
|
|
50
|
+
|
|
51
|
+
## Missed, most first
|
|
52
|
+
|
|
53
|
+
### `lib/gap.rb`
|
|
54
|
+
|
|
55
|
+
Lines 50.00% (3 / 6) · Branches 100.00% (0 / 0)
|
|
56
|
+
|
|
57
|
+
Lines never run: 6-7, 10.
|
|
58
|
+
|
|
59
|
+
````ruby
|
|
60
|
+
6 x = \"```\"
|
|
61
|
+
7 :y
|
|
62
|
+
…
|
|
63
|
+
10 :z
|
|
64
|
+
````
|
|
65
|
+
|
|
66
|
+
### `lib/calc.rb`
|
|
67
|
+
|
|
68
|
+
Lines 100.00% (6 / 6) · Branches 50.00% (2 / 4)
|
|
69
|
+
|
|
70
|
+
Branches never taken: 7 `else`, 16 `else`.
|
|
71
|
+
";
|
|
72
|
+
assert_eq!(String::from_utf8(out).unwrap(), expected);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#[test]
|
|
76
|
+
fn everything_ran() {
|
|
77
|
+
let dir = TempDir::new().unwrap();
|
|
78
|
+
let mut file = calc(&dir);
|
|
79
|
+
file.branches = None;
|
|
80
|
+
let files = [file];
|
|
81
|
+
let reports = reports(&files);
|
|
82
|
+
let mut out = Vec::new();
|
|
83
|
+
write_markdown(&summary(&reports, &[Criterion::Line]), &mut out).unwrap();
|
|
84
|
+
assert!(
|
|
85
|
+
String::from_utf8(out).unwrap().ends_with("by padded_cell 0.1.0.\n\nEverything ran.\n")
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[test]
|
|
90
|
+
fn ranges_collapse_runs() {
|
|
91
|
+
assert_eq!(ranges(&[3, 4, 5, 9, 11, 12]), "3-5, 9, 11-12");
|
|
92
|
+
assert_eq!(ranges(&[7]), "7");
|
|
93
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
use std::io::{self, Write};
|
|
2
|
+
use std::path::Path;
|
|
3
|
+
|
|
4
|
+
use super::stats::{Criterion, Stats};
|
|
5
|
+
use super::{Branch, FileReport, Line, MethodReport, Summary, chomp, floor2};
|
|
6
|
+
|
|
7
|
+
#[cfg(test)]
|
|
8
|
+
mod tests;
|
|
9
|
+
|
|
10
|
+
/// GitHub job summaries take up to 1 MiB: files past it are only counted.
|
|
11
|
+
const BUDGET: usize = 1_000_000;
|
|
12
|
+
/// Lines of missed code shown per file.
|
|
13
|
+
const EXCERPT: usize = 40;
|
|
14
|
+
|
|
15
|
+
/// Writes `summary` as Markdown.
|
|
16
|
+
///
|
|
17
|
+
/// # Errors
|
|
18
|
+
///
|
|
19
|
+
/// What writing to `out` returns.
|
|
20
|
+
pub fn write_markdown(summary: &Summary<'_>, out: &mut impl Write) -> io::Result<()> {
|
|
21
|
+
let mut head = Vec::new();
|
|
22
|
+
header(&mut head, summary)?;
|
|
23
|
+
out.write_all(&head)?;
|
|
24
|
+
let mut files: Vec<&FileReport<'_>> =
|
|
25
|
+
summary.files.iter().filter(|file| missed(file) > 0).collect();
|
|
26
|
+
if files.is_empty() {
|
|
27
|
+
return out.write_all(b"Everything ran.\n");
|
|
28
|
+
}
|
|
29
|
+
files.sort_by(|a, b| missed(b).cmp(&missed(a)).then_with(|| a.file.name.cmp(&b.file.name)));
|
|
30
|
+
out.write_all(b"## Missed, most first\n")?;
|
|
31
|
+
let mut written = head.len();
|
|
32
|
+
for (index, file) in files.iter().enumerate() {
|
|
33
|
+
let mut section = Vec::new();
|
|
34
|
+
file_section(&mut section, summary, file)?;
|
|
35
|
+
written = written.strict_add(section.len());
|
|
36
|
+
if written > BUDGET {
|
|
37
|
+
let rest = files.len().strict_sub(index);
|
|
38
|
+
return writeln!(out, "\n{rest} more files have missed code: see `lcov.info`.");
|
|
39
|
+
}
|
|
40
|
+
out.write_all(§ion)?;
|
|
41
|
+
}
|
|
42
|
+
Ok(())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn header(out: &mut Vec<u8>, summary: &Summary<'_>) -> io::Result<()> {
|
|
46
|
+
let meta = &summary.meta;
|
|
47
|
+
writeln!(out, "# Coverage: {}\n", meta.project_name)?;
|
|
48
|
+
totals(out, summary, |criterion| {
|
|
49
|
+
let stats = summary.totals.get(criterion);
|
|
50
|
+
(stats, stats.percent())
|
|
51
|
+
})?;
|
|
52
|
+
write!(out, "\nGenerated {} by {}", meta.timestamp, meta.generator)?;
|
|
53
|
+
if let Some(commit) = &meta.commit {
|
|
54
|
+
write!(out, ", at `{}`", commit.get(..12).unwrap_or(commit))?;
|
|
55
|
+
}
|
|
56
|
+
out.write_all(b".\n\n")?;
|
|
57
|
+
if summary.groups.is_empty() {
|
|
58
|
+
return Ok(());
|
|
59
|
+
}
|
|
60
|
+
out.write_all(b"| Group |")?;
|
|
61
|
+
for &criterion in &summary.measured {
|
|
62
|
+
write!(out, " {} |", label(criterion))?;
|
|
63
|
+
}
|
|
64
|
+
out.write_all(b"\n|---|")?;
|
|
65
|
+
out.write_all(&b"---:|".repeat(summary.measured.len()))?;
|
|
66
|
+
out.write_all(b"\n")?;
|
|
67
|
+
for (name, _, group) in &summary.groups {
|
|
68
|
+
write!(out, "| {name} |")?;
|
|
69
|
+
for &criterion in &summary.measured {
|
|
70
|
+
let stats = group.get(criterion);
|
|
71
|
+
write!(out, " {} |", ratio(&stats, stats.percent()))?;
|
|
72
|
+
}
|
|
73
|
+
out.write_all(b"\n")?;
|
|
74
|
+
}
|
|
75
|
+
out.write_all(b"\n")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fn file_section(out: &mut Vec<u8>, summary: &Summary<'_>, file: &FileReport<'_>) -> io::Result<()> {
|
|
79
|
+
write!(out, "\n### `{}`\n\n", file.file.name)?;
|
|
80
|
+
totals(out, summary, |criterion| file.stats.with_percent(criterion))?;
|
|
81
|
+
let lines: Vec<u32> = file.lines().filter(Line::missed).map(|line| line.number).collect();
|
|
82
|
+
if !lines.is_empty() {
|
|
83
|
+
writeln!(out, "\nLines never run: {}.", ranges(&lines))?;
|
|
84
|
+
}
|
|
85
|
+
// By line: `208 then, 306 then else`.
|
|
86
|
+
let mut branches: Vec<(u32, &str)> = file
|
|
87
|
+
.reported_branches()
|
|
88
|
+
.filter(Branch::missed)
|
|
89
|
+
.map(|branch| (branch.report_line(), branch.kind))
|
|
90
|
+
.collect();
|
|
91
|
+
branches.sort_by_key(|&(line, _)| line);
|
|
92
|
+
if !branches.is_empty() {
|
|
93
|
+
let mut lines: Vec<String> = Vec::new();
|
|
94
|
+
for chunk in branches.chunk_by(|a, b| a.0 == b.0) {
|
|
95
|
+
let kinds: Vec<&str> = chunk.iter().map(|&(_, kind)| kind).collect();
|
|
96
|
+
lines.push(format!("{} `{}`", chunk[0].0, kinds.join(" ")));
|
|
97
|
+
}
|
|
98
|
+
writeln!(out, "\nBranches never taken: {}.", lines.join(", "))?;
|
|
99
|
+
}
|
|
100
|
+
let methods: Vec<String> = file
|
|
101
|
+
.methods()
|
|
102
|
+
.filter(MethodReport::missed)
|
|
103
|
+
.map(|method| {
|
|
104
|
+
let method = method.method;
|
|
105
|
+
format!("`{}#{}` at {}", method.owner, method.name, method.span.start_line)
|
|
106
|
+
})
|
|
107
|
+
.collect();
|
|
108
|
+
if !methods.is_empty() {
|
|
109
|
+
writeln!(out, "\nMethods never called: {}.", methods.join(", "))?;
|
|
110
|
+
}
|
|
111
|
+
excerpt(out, file, &lines)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// The missed lines, numbered, a `…` between runs.
|
|
115
|
+
fn excerpt(out: &mut Vec<u8>, file: &FileReport<'_>, missed: &[u32]) -> io::Result<()> {
|
|
116
|
+
if missed.is_empty() {
|
|
117
|
+
return Ok(());
|
|
118
|
+
}
|
|
119
|
+
let shown = &missed[..missed.len().min(EXCERPT)];
|
|
120
|
+
let texts: Vec<(u32, String)> = file
|
|
121
|
+
.lines()
|
|
122
|
+
.filter(|line| shown.binary_search(&line.number).is_ok())
|
|
123
|
+
.map(|line| (line.number, String::from_utf8_lossy(chomp(line.text)).into_owned()))
|
|
124
|
+
.collect();
|
|
125
|
+
// A fence longer than any run of backticks in the code.
|
|
126
|
+
let longest = texts.iter().map(|(_, text)| longest_run(text, '`')).max().unwrap_or(0);
|
|
127
|
+
let fence = "`".repeat(longest.max(2).strict_add(1));
|
|
128
|
+
let width = shown.last().map_or(1, |last| last.to_string().len());
|
|
129
|
+
let ruby = Path::new(&file.file.name).extension().is_some_and(|extension| extension == "rb");
|
|
130
|
+
let language = if ruby { "ruby" } else { "" };
|
|
131
|
+
write!(out, "\n{fence}{language}\n")?;
|
|
132
|
+
let mut previous = None;
|
|
133
|
+
for (number, text) in &texts {
|
|
134
|
+
if previous.is_some_and(|previous: u32| previous.strict_add(1) != *number) {
|
|
135
|
+
writeln!(out, "{:>width$}", "…")?;
|
|
136
|
+
}
|
|
137
|
+
writeln!(out, "{number:>width$} {text}")?;
|
|
138
|
+
previous = Some(*number);
|
|
139
|
+
}
|
|
140
|
+
if missed.len() > shown.len() {
|
|
141
|
+
writeln!(out, "… {} more lines", missed.len().strict_sub(shown.len()))?;
|
|
142
|
+
}
|
|
143
|
+
writeln!(out, "{fence}")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fn totals(
|
|
147
|
+
out: &mut Vec<u8>,
|
|
148
|
+
summary: &Summary<'_>,
|
|
149
|
+
stats: impl Fn(Criterion) -> (Stats, f64),
|
|
150
|
+
) -> io::Result<()> {
|
|
151
|
+
let parts: Vec<String> = summary
|
|
152
|
+
.measured
|
|
153
|
+
.iter()
|
|
154
|
+
.map(|&criterion| {
|
|
155
|
+
let (stats, percent) = stats(criterion);
|
|
156
|
+
format!("{} {}", label(criterion), ratio(&stats, percent))
|
|
157
|
+
})
|
|
158
|
+
.collect();
|
|
159
|
+
writeln!(out, "{}", parts.join(" · "))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// `83.83% (82782 / 98745)`
|
|
163
|
+
fn ratio(stats: &Stats, percent: f64) -> String {
|
|
164
|
+
format!("{:.2}% ({} / {})", floor2(percent), stats.covered, stats.total())
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const fn label(criterion: Criterion) -> &'static str {
|
|
168
|
+
match criterion {
|
|
169
|
+
Criterion::Line => "Lines",
|
|
170
|
+
Criterion::Branch => "Branches",
|
|
171
|
+
Criterion::Method => "Methods",
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// What a file has left to cover.
|
|
176
|
+
const fn missed(file: &FileReport<'_>) -> u64 {
|
|
177
|
+
let counts = &file.stats.counts;
|
|
178
|
+
counts.line.missed.strict_add(counts.branch.missed).strict_add(counts.method.missed)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// `[3, 4, 5, 9]` as `3-5, 9`.
|
|
182
|
+
fn ranges(numbers: &[u32]) -> String {
|
|
183
|
+
let mut runs: Vec<(u32, u32)> = Vec::new();
|
|
184
|
+
for &number in numbers {
|
|
185
|
+
match runs.last_mut() {
|
|
186
|
+
Some((_, end)) if end.strict_add(1) == number => *end = number,
|
|
187
|
+
_ => runs.push((number, number)),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
let runs: Vec<String> = runs
|
|
191
|
+
.iter()
|
|
192
|
+
.map(
|
|
193
|
+
|&(start, end)| if start == end { start.to_string() } else { format!("{start}-{end}") },
|
|
194
|
+
)
|
|
195
|
+
.collect();
|
|
196
|
+
runs.join(", ")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn longest_run(text: &str, character: char) -> usize {
|
|
200
|
+
text.split(|other| other != character).map(str::len).max().unwrap_or(0)
|
|
201
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
#[test]
|
|
4
|
+
fn defs_with_their_innermost_owner_and_span() {
|
|
5
|
+
let source = Source::new(
|
|
6
|
+
concat!(
|
|
7
|
+
"def top = 1\nmodule A\n class B::C\n é = 1; def x(y)\n y\n end\n",
|
|
8
|
+
" def self.z = 2\n end\nend\n",
|
|
9
|
+
)
|
|
10
|
+
.as_bytes()
|
|
11
|
+
.to_vec(),
|
|
12
|
+
);
|
|
13
|
+
let found: Vec<(String, String, Span)> = static_methods(&source)
|
|
14
|
+
.unwrap()
|
|
15
|
+
.into_iter()
|
|
16
|
+
.map(|method| (method.owner, method.name, method.span))
|
|
17
|
+
.collect();
|
|
18
|
+
let span = |start_line, start_column, end_line, end_column| Span {
|
|
19
|
+
start_line,
|
|
20
|
+
start_column,
|
|
21
|
+
end_line,
|
|
22
|
+
end_column,
|
|
23
|
+
};
|
|
24
|
+
assert_eq!(
|
|
25
|
+
found,
|
|
26
|
+
[
|
|
27
|
+
("Object".to_owned(), "top".to_owned(), span(1, 0, 1, 11)),
|
|
28
|
+
// Columns count bytes, as Ruby's do: é is two.
|
|
29
|
+
("B::C".to_owned(), "x".to_owned(), span(4, 10, 6, 5)),
|
|
30
|
+
("B::C".to_owned(), "z".to_owned(), span(7, 4, 7, 18)),
|
|
31
|
+
]
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[test]
|
|
36
|
+
fn sources_that_do_not_parse_have_no_methods() {
|
|
37
|
+
assert_eq!(static_methods(&Source::new(b"def broken(\n".to_vec())), None);
|
|
38
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
use ruby_prism::{ClassNode, DefNode, Location, ModuleNode, Visit};
|
|
2
|
+
|
|
3
|
+
use super::{Method, Source, Span};
|
|
4
|
+
|
|
5
|
+
#[cfg(test)]
|
|
6
|
+
mod tests;
|
|
7
|
+
|
|
8
|
+
/// `None` when the source does not parse.
|
|
9
|
+
#[must_use]
|
|
10
|
+
pub fn static_methods(source: &Source) -> Option<Vec<Method>> {
|
|
11
|
+
let result = ruby_prism::parse(source.bytes());
|
|
12
|
+
if result.errors().next().is_some() {
|
|
13
|
+
return None;
|
|
14
|
+
}
|
|
15
|
+
let mut collector = Collector { source, owners: Vec::new(), methods: Vec::new() };
|
|
16
|
+
collector.visit(&result.node());
|
|
17
|
+
Some(collector.methods)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
struct Collector<'s> {
|
|
21
|
+
source: &'s Source,
|
|
22
|
+
owners: Vec<String>,
|
|
23
|
+
methods: Vec<Method>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
impl Collector<'_> {
|
|
27
|
+
fn within(&mut self, owner: &Location<'_>, visit: impl FnOnce(&mut Self)) {
|
|
28
|
+
self.owners.push(String::from_utf8_lossy(owner.as_slice()).into_owned());
|
|
29
|
+
visit(self);
|
|
30
|
+
self.owners.pop();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fn span(&self, location: &Location<'_>) -> Span {
|
|
34
|
+
let (start_line, start_column) = self.source.position(location.start_offset());
|
|
35
|
+
let (end_line, end_column) = self.source.position(location.end_offset());
|
|
36
|
+
Span { start_line, start_column, end_line, end_column }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl<'pr> Visit<'pr> for Collector<'_> {
|
|
41
|
+
fn visit_class_node(&mut self, node: &ClassNode<'pr>) {
|
|
42
|
+
self.within(&node.constant_path().location(), |collector| {
|
|
43
|
+
ruby_prism::visit_class_node(collector, node);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fn visit_module_node(&mut self, node: &ModuleNode<'pr>) {
|
|
48
|
+
self.within(&node.constant_path().location(), |collector| {
|
|
49
|
+
ruby_prism::visit_module_node(collector, node);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn visit_def_node(&mut self, node: &DefNode<'pr>) {
|
|
54
|
+
self.methods.push(Method {
|
|
55
|
+
owner: self.owners.last().map_or("Object", String::as_str).to_owned(),
|
|
56
|
+
name: String::from_utf8_lossy(node.name().as_slice()).into_owned(),
|
|
57
|
+
span: self.span(&node.location()),
|
|
58
|
+
hits: 0,
|
|
59
|
+
});
|
|
60
|
+
ruby_prism::visit_def_node(self, node);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
use tempfile::TempDir;
|
|
2
|
+
|
|
3
|
+
use super::super::{Meta, Totals};
|
|
4
|
+
use super::*;
|
|
5
|
+
|
|
6
|
+
fn empty() -> Summary<'static> {
|
|
7
|
+
Summary {
|
|
8
|
+
files: &[],
|
|
9
|
+
measured: Vec::new(),
|
|
10
|
+
totals: Totals::default(),
|
|
11
|
+
groups: Vec::new(),
|
|
12
|
+
violations: Vec::new(),
|
|
13
|
+
meta: Meta::default(),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[test]
|
|
18
|
+
fn formats_by_name() {
|
|
19
|
+
assert_eq!("lcov".parse(), Ok(Format::Lcov));
|
|
20
|
+
assert_eq!("json".parse(), Ok(Format::Json));
|
|
21
|
+
assert_eq!("html5".parse::<Format>(), Err(UnknownFormat("html5".to_owned())));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[test]
|
|
25
|
+
fn writes_into_a_new_directory_without_leftovers() {
|
|
26
|
+
let dir = TempDir::new().unwrap();
|
|
27
|
+
let output = dir.path().join("coverage");
|
|
28
|
+
let written = write(&output, Format::Json, &empty()).unwrap();
|
|
29
|
+
|
|
30
|
+
assert_eq!(written, [output.join("coverage.json"), output.join(".resultset.json")]);
|
|
31
|
+
assert_eq!(
|
|
32
|
+
std::fs::read(output.join(".resultset.json")).unwrap(),
|
|
33
|
+
b"{\"padded_cell\":{\"coverage\":{},\"timestamp\":0.0}}\n"
|
|
34
|
+
);
|
|
35
|
+
assert_eq!(std::fs::read_dir(&output).unwrap().count(), 2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[test]
|
|
39
|
+
fn unwritable_directories_are_file_errors() {
|
|
40
|
+
let dir = TempDir::new().unwrap();
|
|
41
|
+
let blocker = dir.path().join("file");
|
|
42
|
+
std::fs::write(&blocker, "").unwrap();
|
|
43
|
+
let error = write(&blocker.join("coverage"), Format::Lcov, &empty()).unwrap_err();
|
|
44
|
+
assert_eq!(error.path, blocker.join("coverage/lcov.info"));
|
|
45
|
+
}
|