padded_cell 0.1.0 → 0.2.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 +25 -0
- data/Cargo.lock +2 -2
- data/README.md +148 -7
- data/ext/padded_cell_native/core/Cargo.toml +1 -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 +5 -1
- data/ext/padded_cell_native/core/src/parallel.rs +43 -0
- data/ext/padded_cell_native/ffi/Cargo.toml +1 -1
- data/ext/padded_cell_native/ffi/src/chart.rs +205 -0
- data/ext/padded_cell_native/ffi/src/lib.rs +3 -0
- data/lib/padded_cell/asylum.rb +91 -0
- data/lib/padded_cell/cell.rb +14 -8
- 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 +59 -4
- data/lib/padded_cell/loader.rb +5 -1
- data/lib/padded_cell/pool.rb +19 -42
- data/lib/padded_cell/reporters.rb +20 -3
- data/lib/padded_cell/results.rb +13 -0
- data/lib/padded_cell/runner.rb +125 -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 +39 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
use std::fs::{self, File};
|
|
2
|
+
use std::io::{self, BufWriter, Write};
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
use std::str::FromStr;
|
|
5
|
+
|
|
6
|
+
use super::{
|
|
7
|
+
Summary, write_coverage_json, write_last_run, write_lcov, write_markdown, write_resultset,
|
|
8
|
+
};
|
|
9
|
+
use crate::FileError;
|
|
10
|
+
|
|
11
|
+
#[cfg(test)]
|
|
12
|
+
mod tests;
|
|
13
|
+
|
|
14
|
+
/// A report format.
|
|
15
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
16
|
+
pub enum Format {
|
|
17
|
+
/// `lcov.info`
|
|
18
|
+
Lcov,
|
|
19
|
+
/// `coverage.json` and `.resultset.json`.
|
|
20
|
+
Json,
|
|
21
|
+
/// `coverage.md`: totals, then the code that never ran.
|
|
22
|
+
Markdown,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
impl Format {
|
|
26
|
+
/// The files the format writes, in the report directory.
|
|
27
|
+
#[must_use]
|
|
28
|
+
pub const fn file_names(self) -> &'static [&'static str] {
|
|
29
|
+
match self {
|
|
30
|
+
Self::Lcov => &["lcov.info"],
|
|
31
|
+
Self::Json => &["coverage.json", ".resultset.json"],
|
|
32
|
+
Self::Markdown => &["coverage.md"],
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// A format name nothing writes.
|
|
38
|
+
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
39
|
+
#[error("unknown coverage format {0:?} (known: markdown, lcov, json)")]
|
|
40
|
+
pub struct UnknownFormat(pub String);
|
|
41
|
+
|
|
42
|
+
impl FromStr for Format {
|
|
43
|
+
type Err = UnknownFormat;
|
|
44
|
+
|
|
45
|
+
fn from_str(name: &str) -> Result<Self, UnknownFormat> {
|
|
46
|
+
match name {
|
|
47
|
+
"lcov" => Ok(Self::Lcov),
|
|
48
|
+
"json" => Ok(Self::Json),
|
|
49
|
+
"markdown" => Ok(Self::Markdown),
|
|
50
|
+
_ => Err(UnknownFormat(name.to_owned())),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Writes `summary` in `format` into `dir`, which it creates; returns the
|
|
56
|
+
/// paths written.
|
|
57
|
+
///
|
|
58
|
+
/// # Errors
|
|
59
|
+
///
|
|
60
|
+
/// [`FileError`] for the file that could not be written.
|
|
61
|
+
pub fn write(dir: &Path, format: Format, summary: &Summary<'_>) -> Result<Vec<PathBuf>, FileError> {
|
|
62
|
+
format
|
|
63
|
+
.file_names()
|
|
64
|
+
.iter()
|
|
65
|
+
.map(|&name| {
|
|
66
|
+
write_file(dir, name, |out| match name {
|
|
67
|
+
"lcov.info" => write_lcov(summary.files, out),
|
|
68
|
+
"coverage.json" => write_coverage_json(summary, out),
|
|
69
|
+
"coverage.md" => write_markdown(summary, out),
|
|
70
|
+
_ => write_resultset(summary, out),
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
.collect()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Writes `.last_run.json`, which the next run's `maximum_drop` reads.
|
|
77
|
+
///
|
|
78
|
+
/// # Errors
|
|
79
|
+
///
|
|
80
|
+
/// [`FileError`] when it could not be written.
|
|
81
|
+
pub fn write_last_run_file(dir: &Path, summary: &Summary<'_>) -> Result<PathBuf, FileError> {
|
|
82
|
+
write_file(dir, ".last_run.json", |out| write_last_run(summary, out))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Replaces `dir/name` at once: a reader never sees half a report.
|
|
86
|
+
pub(super) fn write_file(
|
|
87
|
+
dir: &Path,
|
|
88
|
+
name: &str,
|
|
89
|
+
render: impl FnOnce(&mut BufWriter<File>) -> io::Result<()>,
|
|
90
|
+
) -> Result<PathBuf, FileError> {
|
|
91
|
+
let path = dir.join(name);
|
|
92
|
+
let partial = dir.join(format!(".{name}.partial"));
|
|
93
|
+
let error = |source| FileError { path: path.clone(), source };
|
|
94
|
+
fs::create_dir_all(dir).map_err(error)?;
|
|
95
|
+
let written = File::create(&partial).and_then(|file| {
|
|
96
|
+
let mut out = BufWriter::new(file);
|
|
97
|
+
render(&mut out)?;
|
|
98
|
+
out.flush()?;
|
|
99
|
+
out.into_inner().map_err(io::IntoInnerError::into_error)?.sync_all()
|
|
100
|
+
});
|
|
101
|
+
written.and_then(|()| fs::rename(&partial, &path)).map_err(error)?;
|
|
102
|
+
Ok(path)
|
|
103
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
use tempfile::TempDir;
|
|
2
|
+
|
|
3
|
+
use super::super::FileCoverage;
|
|
4
|
+
use super::super::test_support::{calc, reports, summary};
|
|
5
|
+
use super::*;
|
|
6
|
+
|
|
7
|
+
fn render(file: &FileCoverage, measured: &[Criterion], write: WriteFn) -> String {
|
|
8
|
+
let files = [file.clone()];
|
|
9
|
+
let reports = reports(&files);
|
|
10
|
+
let mut summary = summary(&reports, measured);
|
|
11
|
+
summary.groups = vec![("Core".to_owned(), vec![0], summary.totals)];
|
|
12
|
+
summary.violations = vec![Violation {
|
|
13
|
+
scope: Scope::File("lib/calc.rb".to_owned()),
|
|
14
|
+
criterion: Criterion::Branch,
|
|
15
|
+
limit: 60.0,
|
|
16
|
+
actual: 50.0,
|
|
17
|
+
}];
|
|
18
|
+
summary.meta = Meta {
|
|
19
|
+
root: "/app".to_owned(),
|
|
20
|
+
timestamp: "2026-10-11T13:00:00.000+02:00".to_owned(),
|
|
21
|
+
created_at: 1_791_716_400.5,
|
|
22
|
+
commit: None,
|
|
23
|
+
project_name: "App".to_owned(),
|
|
24
|
+
generator: "padded_cell 0.1.0".to_owned(),
|
|
25
|
+
};
|
|
26
|
+
let mut out = Vec::new();
|
|
27
|
+
write(&summary, &mut out).unwrap();
|
|
28
|
+
String::from_utf8(out).unwrap()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type WriteFn = fn(&Summary<'_>, &mut Vec<u8>) -> io::Result<()>;
|
|
32
|
+
|
|
33
|
+
#[test]
|
|
34
|
+
fn last_run_holds_the_floored_percents_measured() {
|
|
35
|
+
let dir = TempDir::new().unwrap();
|
|
36
|
+
let rendered = render(&calc(&dir), &[Criterion::Line, Criterion::Branch], write_last_run);
|
|
37
|
+
assert_eq!(
|
|
38
|
+
rendered,
|
|
39
|
+
"{\n \"result\": {\n \"line\": 100.0,\n \"branch\": 50.0\n }\n}\n"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Keys as Ruby's `Array#to_s` writes them; ids numbered in compile order.
|
|
44
|
+
#[test]
|
|
45
|
+
fn resultset_keys_are_rubys() {
|
|
46
|
+
let dir = TempDir::new().unwrap();
|
|
47
|
+
let measured = [Criterion::Line, Criterion::Branch, Criterion::Method];
|
|
48
|
+
let rendered = render(&calc(&dir), &measured, write_resultset);
|
|
49
|
+
for expected in [
|
|
50
|
+
r#"{"padded_cell":{"coverage":{""#,
|
|
51
|
+
r#"/calc.rb":{"lines":[null,null,1,1,2,1,null,1,null,null,null,null,1,null,null,1,null],"#,
|
|
52
|
+
concat!(
|
|
53
|
+
r#""branches":{"[:if, 0, 5, 4, 9, 7]":"#,
|
|
54
|
+
r#"{"[:then, 1, 6, 6, 6, 15]":1,"[:else, 2, 8, 6, 8, 12]":0},"#,
|
|
55
|
+
),
|
|
56
|
+
r#""[:if, 3, 16, 20, 16, 37]":{"[:then, 4, 16, 27, 16, 31]":1,"#,
|
|
57
|
+
r#""methods":{"[Calc, :sign, 4, 2, 10, 5]":2,"#,
|
|
58
|
+
r#""timestamp":1791716400.5}}"#,
|
|
59
|
+
] {
|
|
60
|
+
assert!(rendered.contains(expected), "{expected} in {rendered}");
|
|
61
|
+
}
|
|
62
|
+
assert_eq!(ruby_symbol("&."), r#":"&.""#);
|
|
63
|
+
assert_eq!(ruby_symbol("empty?"), ":empty?");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[test]
|
|
67
|
+
fn coverage_json_sections() {
|
|
68
|
+
let dir = TempDir::new().unwrap();
|
|
69
|
+
let rendered = render(&calc(&dir), &[Criterion::Line, Criterion::Branch], |s, o| {
|
|
70
|
+
write_coverage_json(s, o)
|
|
71
|
+
});
|
|
72
|
+
for expected in [
|
|
73
|
+
&format!(" \"$schema\": \"{SCHEMA}\",\n"),
|
|
74
|
+
" \"simplecov_version\": \"padded_cell 0.1.0\",\n",
|
|
75
|
+
" \"commit\": null,\n",
|
|
76
|
+
" \"method_coverage\": false\n",
|
|
77
|
+
" \"source\": [\n \"# frozen_string_literal: true\",\n",
|
|
78
|
+
" \"ignored\",\n",
|
|
79
|
+
concat!(
|
|
80
|
+
" \"type\": \"else\",\n \"start_line\": 8,\n",
|
|
81
|
+
" \"end_line\": 8,\n \"coverage\": 0,\n",
|
|
82
|
+
" \"inline\": false,\n \"report_line\": 7\n",
|
|
83
|
+
),
|
|
84
|
+
" \"branches_covered_percent\": 50.0,\n",
|
|
85
|
+
" \"Core\": {\n \"lines\": {\n",
|
|
86
|
+
" \"files\": [\n \"lib/calc.rb\"\n ]\n",
|
|
87
|
+
concat!(
|
|
88
|
+
" \"errors\": {\n \"minimum_coverage_by_file\": {\n",
|
|
89
|
+
" \"lib/calc.rb\": {\n \"branches\": {\n",
|
|
90
|
+
" \"expected\": 60.0,\n \"actual\": 50.0\n",
|
|
91
|
+
),
|
|
92
|
+
] {
|
|
93
|
+
assert!(rendered.contains(expected), "{expected:?} in {rendered}");
|
|
94
|
+
}
|
|
95
|
+
assert!(!rendered.contains("\"methods\""), "methods were not measured");
|
|
96
|
+
}
|