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,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
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
use std::io::{self, Write};
|
|
2
|
+
|
|
3
|
+
use super::json::Json;
|
|
4
|
+
use super::stats::{Criterion, Stats, Totals};
|
|
5
|
+
use super::thresholds::{CRITERIA, Scope, Violation};
|
|
6
|
+
use super::{Branch, FileReport, Line, chomp, floor2};
|
|
7
|
+
|
|
8
|
+
#[cfg(test)]
|
|
9
|
+
mod tests;
|
|
10
|
+
|
|
11
|
+
const SCHEMA: &str = concat!(
|
|
12
|
+
"https://raw.githubusercontent.com/simplecov-ruby/simplecov/main/",
|
|
13
|
+
"schemas/coverage-v1.3.schema.json",
|
|
14
|
+
);
|
|
15
|
+
const COMMAND: &str = "padded_cell";
|
|
16
|
+
|
|
17
|
+
/// About the run.
|
|
18
|
+
#[derive(Debug, Clone, Default, PartialEq)]
|
|
19
|
+
pub struct Meta {
|
|
20
|
+
/// The project root, absolute.
|
|
21
|
+
pub root: String,
|
|
22
|
+
/// ISO 8601, with milliseconds.
|
|
23
|
+
pub timestamp: String,
|
|
24
|
+
/// The same moment, in seconds since the epoch.
|
|
25
|
+
pub created_at: f64,
|
|
26
|
+
/// The root's git HEAD.
|
|
27
|
+
pub commit: Option<String>,
|
|
28
|
+
pub project_name: String,
|
|
29
|
+
/// What wrote the report: `padded_cell 0.1.0`.
|
|
30
|
+
pub generator: String,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Everything a report says about a run.
|
|
34
|
+
#[derive(Debug)]
|
|
35
|
+
pub struct Summary<'a> {
|
|
36
|
+
pub files: &'a [FileReport<'a>],
|
|
37
|
+
pub measured: Vec<Criterion>,
|
|
38
|
+
pub totals: Totals,
|
|
39
|
+
/// Name, indices into `files`, and their totals.
|
|
40
|
+
pub groups: Vec<(String, Vec<usize>, Totals)>,
|
|
41
|
+
pub violations: Vec<Violation>,
|
|
42
|
+
pub meta: Meta,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
impl Summary<'_> {
|
|
46
|
+
/// `criterion` was measured.
|
|
47
|
+
#[must_use]
|
|
48
|
+
pub fn measures(&self, criterion: Criterion) -> bool {
|
|
49
|
+
self.measured.contains(&criterion)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// `coverage.json`.
|
|
54
|
+
///
|
|
55
|
+
/// # Errors
|
|
56
|
+
///
|
|
57
|
+
/// What writing to `out` returns.
|
|
58
|
+
pub fn write_coverage_json(summary: &Summary<'_>, out: &mut impl Write) -> io::Result<()> {
|
|
59
|
+
Json::pretty(out).object(|json| {
|
|
60
|
+
json.key("$schema")?.str(SCHEMA)?;
|
|
61
|
+
json.key("meta")?.object(|json| meta(json, summary))?;
|
|
62
|
+
json.key("total")?.object(|json| statistics(json, summary, &summary.totals))?;
|
|
63
|
+
json.key("coverage")?.object(|json| {
|
|
64
|
+
summary.files.iter().try_for_each(|file| {
|
|
65
|
+
json.key(&file.file.name)?.object(|json| source_file(json, summary, file))
|
|
66
|
+
})
|
|
67
|
+
})?;
|
|
68
|
+
json.key("groups")?.object(|json| {
|
|
69
|
+
summary.groups.iter().try_for_each(|(name, members, totals)| {
|
|
70
|
+
json.key(name)?.object(|json| {
|
|
71
|
+
statistics(json, summary, totals)?;
|
|
72
|
+
json.key("files")?.array(|json| {
|
|
73
|
+
let names = members.iter().filter_map(|&index| summary.files.get(index));
|
|
74
|
+
names.into_iter().try_for_each(|file| json.item()?.str(&file.file.name))
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})?;
|
|
79
|
+
json.key("errors")?.object(|json| errors(json, &summary.violations))
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// `.resultset.json`: Ruby's Coverage result, keyed by absolute path, under
|
|
84
|
+
/// the command name.
|
|
85
|
+
///
|
|
86
|
+
/// # Errors
|
|
87
|
+
///
|
|
88
|
+
/// What writing to `out` returns.
|
|
89
|
+
pub fn write_resultset(summary: &Summary<'_>, out: &mut impl Write) -> io::Result<()> {
|
|
90
|
+
Json::compact(out).object(|json| {
|
|
91
|
+
json.key(COMMAND)?.object(|json| {
|
|
92
|
+
json.key("coverage")?.object(|json| {
|
|
93
|
+
let methods = summary.measures(Criterion::Method);
|
|
94
|
+
summary.files.iter().try_for_each(|file| {
|
|
95
|
+
let path = file.file.path.to_string_lossy();
|
|
96
|
+
json.key(&path)?.object(|json| resultset_file(json, file, methods))
|
|
97
|
+
})
|
|
98
|
+
})?;
|
|
99
|
+
json.key("timestamp")?.float(summary.meta.created_at)
|
|
100
|
+
})
|
|
101
|
+
})?;
|
|
102
|
+
out.write_all(b"\n")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// `.last_run.json`: the run's percents, floored, which the next run's
|
|
106
|
+
/// `maximum_drop` compares with.
|
|
107
|
+
///
|
|
108
|
+
/// # Errors
|
|
109
|
+
///
|
|
110
|
+
/// What writing to `out` returns.
|
|
111
|
+
pub fn write_last_run(summary: &Summary<'_>, out: &mut impl Write) -> io::Result<()> {
|
|
112
|
+
Json::pretty(out).object(|json| {
|
|
113
|
+
json.key("result")?.object(|json| {
|
|
114
|
+
summary.measured.iter().try_for_each(|&criterion| {
|
|
115
|
+
json.key(criterion.singular())?
|
|
116
|
+
.float(floor2(summary.totals.get(criterion).percent()))
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})?;
|
|
120
|
+
out.write_all(b"\n")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fn meta<W: Write>(json: &mut Json<'_, W>, summary: &Summary<'_>) -> io::Result<()> {
|
|
124
|
+
let meta = &summary.meta;
|
|
125
|
+
json.key("schema_version")?.str("1.3")?;
|
|
126
|
+
json.key("simplecov_version")?.str(&meta.generator)?;
|
|
127
|
+
json.key("command_name")?.str(COMMAND)?;
|
|
128
|
+
json.key("command_names")?.array(|json| json.item()?.str(COMMAND))?;
|
|
129
|
+
json.key("project_name")?.str(&meta.project_name)?;
|
|
130
|
+
json.key("timestamp")?.str(&meta.timestamp)?;
|
|
131
|
+
json.key("root")?.str(&meta.root)?;
|
|
132
|
+
match &meta.commit {
|
|
133
|
+
Some(commit) => json.key("commit")?.str(commit)?,
|
|
134
|
+
None => json.key("commit")?.null()?,
|
|
135
|
+
}
|
|
136
|
+
json.key("primary_coverage")?.str("line")?;
|
|
137
|
+
for criterion in CRITERIA {
|
|
138
|
+
let key = format!("{}_coverage", criterion.singular());
|
|
139
|
+
json.key(&key)?.bool(summary.measures(criterion))?;
|
|
140
|
+
}
|
|
141
|
+
Ok(())
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn statistics<W: Write>(
|
|
145
|
+
json: &mut Json<'_, W>,
|
|
146
|
+
summary: &Summary<'_>,
|
|
147
|
+
totals: &Totals,
|
|
148
|
+
) -> io::Result<()> {
|
|
149
|
+
summary.measured.iter().try_for_each(|&criterion| {
|
|
150
|
+
let stats = totals.get(criterion);
|
|
151
|
+
json.key(criterion.plural())?
|
|
152
|
+
.object(|json| statistic(json, criterion, &stats, stats.percent()))
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn statistic<W: Write>(
|
|
157
|
+
json: &mut Json<'_, W>,
|
|
158
|
+
criterion: Criterion,
|
|
159
|
+
stats: &Stats,
|
|
160
|
+
percent: f64,
|
|
161
|
+
) -> io::Result<()> {
|
|
162
|
+
json.key("covered")?.uint(stats.covered)?;
|
|
163
|
+
json.key("missed")?.uint(stats.missed)?;
|
|
164
|
+
if criterion == Criterion::Line {
|
|
165
|
+
json.key("omitted")?.uint(stats.omitted)?;
|
|
166
|
+
}
|
|
167
|
+
json.key("total")?.uint(stats.total())?;
|
|
168
|
+
json.key("percent")?.float(percent)?;
|
|
169
|
+
json.key("strength")?.float(stats.strength())
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fn source_file<W: Write>(
|
|
173
|
+
json: &mut Json<'_, W>,
|
|
174
|
+
summary: &Summary<'_>,
|
|
175
|
+
file: &FileReport<'_>,
|
|
176
|
+
) -> io::Result<()> {
|
|
177
|
+
json.key("source")?.array(|json| {
|
|
178
|
+
file.lines()
|
|
179
|
+
.try_for_each(|line| json.item()?.str(&String::from_utf8_lossy(chomp(line.text))))
|
|
180
|
+
})?;
|
|
181
|
+
let stats = &file.stats;
|
|
182
|
+
json.key("lines")?.array(|json| file.lines().try_for_each(|line| hits(json.item()?, &line)))?;
|
|
183
|
+
json.key("lines_covered_percent")?.float(stats.percent(Criterion::Line))?;
|
|
184
|
+
json.key("covered_lines")?.uint(stats.counts.line.covered)?;
|
|
185
|
+
json.key("missed_lines")?.uint(stats.counts.line.missed)?;
|
|
186
|
+
json.key("omitted_lines")?.uint(stats.counts.line.omitted)?;
|
|
187
|
+
json.key("total_lines")?.uint(stats.counts.line.total())?;
|
|
188
|
+
if summary.measures(Criterion::Branch) {
|
|
189
|
+
json.key("branches")?
|
|
190
|
+
.array(|json| file.branches().try_for_each(|b| branch(json.item()?, &b)))?;
|
|
191
|
+
counts(json, "branches", stats.percent(Criterion::Branch), &stats.counts.branch)?;
|
|
192
|
+
}
|
|
193
|
+
if summary.measures(Criterion::Method) {
|
|
194
|
+
json.key("methods")?.array(|json| {
|
|
195
|
+
file.methods().try_for_each(|method| {
|
|
196
|
+
json.item()?.object(|json| {
|
|
197
|
+
let name = format!("{}#{}", method.method.owner, method.method.name);
|
|
198
|
+
json.key("name")?.str(&name)?;
|
|
199
|
+
json.key("start_line")?.uint(method.method.span.start_line.into())?;
|
|
200
|
+
json.key("end_line")?.uint(method.method.span.end_line.into())?;
|
|
201
|
+
let coverage = json.key("coverage")?;
|
|
202
|
+
if method.skipped {
|
|
203
|
+
coverage.str("ignored")
|
|
204
|
+
} else {
|
|
205
|
+
coverage.uint(method.method.hits)
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
})?;
|
|
210
|
+
counts(json, "methods", stats.percent(Criterion::Method), &stats.counts.method)?;
|
|
211
|
+
}
|
|
212
|
+
Ok(())
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fn counts<W: Write>(
|
|
216
|
+
json: &mut Json<'_, W>,
|
|
217
|
+
name: &str,
|
|
218
|
+
percent: f64,
|
|
219
|
+
stats: &Stats,
|
|
220
|
+
) -> io::Result<()> {
|
|
221
|
+
json.key(&format!("{name}_covered_percent"))?.float(percent)?;
|
|
222
|
+
json.key(&format!("covered_{name}"))?.uint(stats.covered)?;
|
|
223
|
+
json.key(&format!("missed_{name}"))?.uint(stats.missed)?;
|
|
224
|
+
json.key(&format!("total_{name}"))?.uint(stats.total())
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// Hits, `null` for no code, `"ignored"` for a skipped line.
|
|
228
|
+
fn hits<W: Write>(json: &mut Json<'_, W>, line: &Line<'_>) -> io::Result<()> {
|
|
229
|
+
match line.hits {
|
|
230
|
+
_ if line.skipped => json.str("ignored"),
|
|
231
|
+
Some(hits) => json.uint(hits),
|
|
232
|
+
None => json.null(),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
fn branch<W: Write>(json: &mut Json<'_, W>, branch: &Branch<'_>) -> io::Result<()> {
|
|
237
|
+
json.object(|json| {
|
|
238
|
+
json.key("type")?.str(branch.kind)?;
|
|
239
|
+
json.key("start_line")?.uint(branch.start_line.into())?;
|
|
240
|
+
json.key("end_line")?.uint(branch.end_line.into())?;
|
|
241
|
+
let coverage = json.key("coverage")?;
|
|
242
|
+
if branch.skipped {
|
|
243
|
+
coverage.str("ignored")?;
|
|
244
|
+
} else {
|
|
245
|
+
coverage.uint(branch.hits)?;
|
|
246
|
+
}
|
|
247
|
+
json.key("inline")?.bool(branch.inline)?;
|
|
248
|
+
json.key("report_line")?.uint(branch.report_line().into())
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/// The violations, by kind.
|
|
253
|
+
fn errors<W: Write>(json: &mut Json<'_, W>, violations: &[Violation]) -> io::Result<()> {
|
|
254
|
+
let total: Vec<&Violation> = violations.iter().filter(|v| v.scope == Scope::Total).collect();
|
|
255
|
+
section(json, "minimum_coverage", "expected", &total)?;
|
|
256
|
+
named(json, "minimum_coverage_by_file", violations, |v| match &v.scope {
|
|
257
|
+
Scope::File(name) => Some(name),
|
|
258
|
+
_ => None,
|
|
259
|
+
})?;
|
|
260
|
+
named(json, "minimum_coverage_by_group", violations, |v| match &v.scope {
|
|
261
|
+
Scope::Group(name) => Some(name),
|
|
262
|
+
_ => None,
|
|
263
|
+
})?;
|
|
264
|
+
let drops: Vec<&Violation> = violations.iter().filter(|v| v.scope == Scope::Drop).collect();
|
|
265
|
+
section(json, "maximum_coverage_drop", "maximum", &drops)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn section<W: Write>(
|
|
269
|
+
json: &mut Json<'_, W>,
|
|
270
|
+
key: &str,
|
|
271
|
+
limit: &str,
|
|
272
|
+
found: &[&Violation],
|
|
273
|
+
) -> io::Result<()> {
|
|
274
|
+
if found.is_empty() {
|
|
275
|
+
return Ok(());
|
|
276
|
+
}
|
|
277
|
+
json.key(key)?.object(|json| found.iter().try_for_each(|v| violation(json, limit, v)))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// Violations by file or group name; a name's violations come together.
|
|
281
|
+
fn named<W: Write>(
|
|
282
|
+
json: &mut Json<'_, W>,
|
|
283
|
+
key: &str,
|
|
284
|
+
violations: &[Violation],
|
|
285
|
+
name: impl Fn(&Violation) -> Option<&String>,
|
|
286
|
+
) -> io::Result<()> {
|
|
287
|
+
let mut names: Vec<&String> = violations.iter().filter_map(&name).collect();
|
|
288
|
+
names.dedup();
|
|
289
|
+
if names.is_empty() {
|
|
290
|
+
return Ok(());
|
|
291
|
+
}
|
|
292
|
+
json.key(key)?.object(|json| {
|
|
293
|
+
names.iter().try_for_each(|&scope| {
|
|
294
|
+
let found: Vec<&Violation> =
|
|
295
|
+
violations.iter().filter(|v| name(v) == Some(scope)).collect();
|
|
296
|
+
json.key(scope)?
|
|
297
|
+
.object(|json| found.iter().try_for_each(|v| violation(json, "expected", v)))
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
fn violation<W: Write>(
|
|
303
|
+
json: &mut Json<'_, W>,
|
|
304
|
+
limit: &str,
|
|
305
|
+
violation: &Violation,
|
|
306
|
+
) -> io::Result<()> {
|
|
307
|
+
json.key(violation.criterion.plural())?.object(|json| {
|
|
308
|
+
json.key(limit)?.float(violation.limit)?;
|
|
309
|
+
json.key("actual")?.float(violation.actual)
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fn resultset_file<W: Write>(
|
|
314
|
+
json: &mut Json<'_, W>,
|
|
315
|
+
file: &FileReport<'_>,
|
|
316
|
+
methods: bool,
|
|
317
|
+
) -> io::Result<()> {
|
|
318
|
+
json.key("lines")?.array(|json| {
|
|
319
|
+
file.raw_lines().iter().try_for_each(|hits| match hits {
|
|
320
|
+
Some(hits) => json.item()?.uint(*hits),
|
|
321
|
+
None => json.item()?.null(),
|
|
322
|
+
})
|
|
323
|
+
})?;
|
|
324
|
+
if let Some(conditions) = &file.file.branches {
|
|
325
|
+
// Ruby numbers conditions and arms in the order it compiled them.
|
|
326
|
+
let mut id = 0_u64;
|
|
327
|
+
json.key("branches")?.object(|json| {
|
|
328
|
+
conditions.iter().try_for_each(|condition| {
|
|
329
|
+
json.key(&ruby_tuple(&condition.kind, &mut id, &condition.span))?.object(|json| {
|
|
330
|
+
condition.arms.iter().try_for_each(|arm| {
|
|
331
|
+
json.key(&ruby_tuple(&arm.kind, &mut id, &arm.span))?.uint(arm.hits)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
})?;
|
|
336
|
+
}
|
|
337
|
+
if methods {
|
|
338
|
+
json.key("methods")?.object(|json| {
|
|
339
|
+
file.methods().try_for_each(|method| {
|
|
340
|
+
let span = &method.method.span;
|
|
341
|
+
let key = format!(
|
|
342
|
+
"[{}, {}, {}, {}, {}, {}]",
|
|
343
|
+
method.method.owner,
|
|
344
|
+
ruby_symbol(&method.method.name),
|
|
345
|
+
span.start_line,
|
|
346
|
+
span.start_column,
|
|
347
|
+
span.end_line,
|
|
348
|
+
span.end_column
|
|
349
|
+
);
|
|
350
|
+
json.key(&key)?.uint(method.method.hits)
|
|
351
|
+
})
|
|
352
|
+
})?;
|
|
353
|
+
}
|
|
354
|
+
Ok(())
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/// `[:if, 0, 2, 4, 6, 7]`, as Ruby's `Array#to_s` writes a branch key.
|
|
358
|
+
fn ruby_tuple(kind: &str, id: &mut u64, span: &super::Span) -> String {
|
|
359
|
+
let tuple = format!(
|
|
360
|
+
"[{}, {id}, {}, {}, {}, {}]",
|
|
361
|
+
ruby_symbol(kind),
|
|
362
|
+
span.start_line,
|
|
363
|
+
span.start_column,
|
|
364
|
+
span.end_line,
|
|
365
|
+
span.end_column
|
|
366
|
+
);
|
|
367
|
+
*id = id.strict_add(1);
|
|
368
|
+
tuple
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// `:if`, or `:"&."` for a symbol that needs quoting.
|
|
372
|
+
fn ruby_symbol(name: &str) -> String {
|
|
373
|
+
let plain = name
|
|
374
|
+
.bytes()
|
|
375
|
+
.all(|byte| byte.is_ascii_alphanumeric() || b"_?!=[]+-*/%<>~^&|".contains(&byte))
|
|
376
|
+
&& !name.is_empty()
|
|
377
|
+
&& name != "&.";
|
|
378
|
+
if plain { format!(":{name}") } else { format!(":{name:?}") }
|
|
379
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
fn source(text: &str) -> Source {
|
|
4
|
+
Source::new(text.as_bytes().to_vec())
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
#[test]
|
|
8
|
+
fn lines_split_as_ruby_readlines() {
|
|
9
|
+
const CASES: &[(&str, &[&str])] = &[
|
|
10
|
+
("", &[]),
|
|
11
|
+
("a", &["a"]),
|
|
12
|
+
("a\nb", &["a\n", "b"]),
|
|
13
|
+
("a\nb\n", &["a\n", "b\n"]),
|
|
14
|
+
("\n\n", &["\n", "\n"]),
|
|
15
|
+
("a\r\nb", &["a\r\n", "b"]),
|
|
16
|
+
];
|
|
17
|
+
for (text, expected) in CASES {
|
|
18
|
+
let source = source(text);
|
|
19
|
+
let lines: Vec<&[u8]> = source.lines().collect();
|
|
20
|
+
let expected: Vec<&[u8]> = expected.iter().map(|line| line.as_bytes()).collect();
|
|
21
|
+
assert_eq!(lines, expected, "text: {text:?}");
|
|
22
|
+
assert_eq!(source.len(), expected.len(), "text: {text:?}");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[test]
|
|
27
|
+
fn blank_and_comment_lines() {
|
|
28
|
+
const CASES: &[(&str, bool)] = &[
|
|
29
|
+
("", true),
|
|
30
|
+
("\n", true),
|
|
31
|
+
(" \t\r\n", true),
|
|
32
|
+
("# comment\n", true),
|
|
33
|
+
(" # indented\n", true),
|
|
34
|
+
("\x0b\x0c#\n", true),
|
|
35
|
+
("code\n", false),
|
|
36
|
+
("x = 1 # comment\n", false),
|
|
37
|
+
(" end", false),
|
|
38
|
+
];
|
|
39
|
+
for (line, expected) in CASES {
|
|
40
|
+
assert_eq!(blank_or_comment(line.as_bytes()), *expected, "line: {line:?}");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[test]
|
|
45
|
+
fn nocov_blocks_run_from_marker_to_marker() {
|
|
46
|
+
let text = "a\n# :nocov:\nb\n # :nocov: done\nc\nd # :nocov:\n#:nocov:\ne\n";
|
|
47
|
+
assert_eq!(source(text).nocov("nocov"), [2..=4, 7..=8]);
|
|
48
|
+
assert_eq!(source(text).nocov("skip"), []);
|
|
49
|
+
assert_eq!(source("a\n# :skip:\nb\n").nocov("skip"), [RangeInclusive::new(2, 3)]);
|
|
50
|
+
// A marker needs both colons: `# :nocov` is a comment.
|
|
51
|
+
assert_eq!(source("# :nocov\n# nocov:\n").nocov("nocov"), []);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#[test]
|
|
55
|
+
fn unloaded_files_keep_only_their_code() {
|
|
56
|
+
let source = source("# frozen\n\nclass A\n # :nocov:\n def b; end\n # :nocov:\nend\n");
|
|
57
|
+
let stub = [Some(0), Some(0), Some(0), Some(0), Some(0), Some(0), Some(0), Some(0)];
|
|
58
|
+
let skipped = source.nocov("nocov");
|
|
59
|
+
assert_eq!(
|
|
60
|
+
source.relevant(&stub, &skipped),
|
|
61
|
+
[None, None, Some(0), None, None, None, Some(0), None],
|
|
62
|
+
"comments, blank lines, blocks, and lines past the source are not code"
|
|
63
|
+
);
|
|
64
|
+
}
|