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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/Cargo.lock +16 -2
  4. data/Cargo.toml +2 -0
  5. data/README.md +170 -8
  6. data/ext/padded_cell_native/core/Cargo.toml +2 -1
  7. data/ext/padded_cell_native/core/src/chart/directives/tests.rs +56 -0
  8. data/ext/padded_cell_native/core/src/chart/directives.rs +144 -0
  9. data/ext/padded_cell_native/core/src/chart/json/tests.rs +46 -0
  10. data/ext/padded_cell_native/core/src/chart/json.rs +175 -0
  11. data/ext/padded_cell_native/core/src/chart/lcov.rs +50 -0
  12. data/ext/padded_cell_native/core/src/chart/markdown/tests.rs +93 -0
  13. data/ext/padded_cell_native/core/src/chart/markdown.rs +201 -0
  14. data/ext/padded_cell_native/core/src/chart/methods/tests.rs +38 -0
  15. data/ext/padded_cell_native/core/src/chart/methods.rs +62 -0
  16. data/ext/padded_cell_native/core/src/chart/output/tests.rs +45 -0
  17. data/ext/padded_cell_native/core/src/chart/output.rs +103 -0
  18. data/ext/padded_cell_native/core/src/chart/results/tests.rs +96 -0
  19. data/ext/padded_cell_native/core/src/chart/results.rs +379 -0
  20. data/ext/padded_cell_native/core/src/chart/source/tests.rs +64 -0
  21. data/ext/padded_cell_native/core/src/chart/source.rs +153 -0
  22. data/ext/padded_cell_native/core/src/chart/stats/tests.rs +52 -0
  23. data/ext/padded_cell_native/core/src/chart/stats.rs +218 -0
  24. data/ext/padded_cell_native/core/src/chart/test_support.rs +93 -0
  25. data/ext/padded_cell_native/core/src/chart/tests.rs +97 -0
  26. data/ext/padded_cell_native/core/src/chart/thresholds/tests.rs +36 -0
  27. data/ext/padded_cell_native/core/src/chart/thresholds.rs +116 -0
  28. data/ext/padded_cell_native/core/src/chart.rs +302 -0
  29. data/ext/padded_cell_native/core/src/discover/tests.rs +2 -0
  30. data/ext/padded_cell_native/core/src/discover.rs +7 -53
  31. data/ext/padded_cell_native/core/src/error.rs +24 -0
  32. data/ext/padded_cell_native/core/src/lib.rs +10 -3
  33. data/ext/padded_cell_native/core/src/parallel.rs +43 -0
  34. data/ext/padded_cell_native/core/src/restarts/tests.rs +29 -0
  35. data/ext/padded_cell_native/core/src/restarts.rs +64 -0
  36. data/ext/padded_cell_native/ffi/Cargo.toml +2 -1
  37. data/ext/padded_cell_native/ffi/src/chart.rs +205 -0
  38. data/ext/padded_cell_native/ffi/src/lib.rs +5 -0
  39. data/ext/padded_cell_native/ffi/src/limits.rs +83 -0
  40. data/lib/padded_cell/asylum.rb +93 -0
  41. data/lib/padded_cell/cell.rb +23 -14
  42. data/lib/padded_cell/chart/accumulator.rb +103 -0
  43. data/lib/padded_cell/chart/config.rb +129 -0
  44. data/lib/padded_cell/chart/delta.rb +148 -0
  45. data/lib/padded_cell/chart/document.rb +64 -0
  46. data/lib/padded_cell/chart/filter.rb +55 -0
  47. data/lib/padded_cell/chart/merge.rb +63 -0
  48. data/lib/padded_cell/chart/probe.rb +70 -0
  49. data/lib/padded_cell/chart/raw.rb +48 -0
  50. data/lib/padded_cell/chart/report.rb +128 -0
  51. data/lib/padded_cell/chart/source_files.rb +90 -0
  52. data/lib/padded_cell/chart.rb +38 -0
  53. data/lib/padded_cell/cli.rb +70 -4
  54. data/lib/padded_cell/limits.rb +45 -0
  55. data/lib/padded_cell/loader.rb +5 -1
  56. data/lib/padded_cell/pool.rb +82 -58
  57. data/lib/padded_cell/reporters.rb +20 -3
  58. data/lib/padded_cell/results.rb +13 -0
  59. data/lib/padded_cell/runner.rb +150 -22
  60. data/lib/padded_cell/version.rb +1 -1
  61. data/lib/padded_cell/worker.rb +63 -0
  62. data/lib/padded_cell.rb +8 -0
  63. 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
+ }