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,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
|
+
}
|
|
@@ -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
|
+
}
|