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,175 @@
1
+ use std::io::{self, Write};
2
+
3
+ #[cfg(test)]
4
+ mod tests;
5
+
6
+ /// A JSON writer. Containers take a closure writing their members:
7
+ ///
8
+ /// ```text
9
+ /// json.object(|json| {
10
+ /// json.key("lines")?.array(|json| json.item()?.uint(1))
11
+ /// })
12
+ /// ```
13
+ pub struct Json<'w, W: Write> {
14
+ out: &'w mut W,
15
+ pretty: bool,
16
+ depth: usize,
17
+ /// The innermost container has no member yet.
18
+ empty: bool,
19
+ }
20
+
21
+ impl<'w, W: Write> Json<'w, W> {
22
+ /// `JSON.pretty_generate`'s layout: two-space indents, one member per
23
+ /// line, `[]` and `{}` when empty.
24
+ pub const fn pretty(out: &'w mut W) -> Self {
25
+ Self { out, pretty: true, depth: 0, empty: true }
26
+ }
27
+
28
+ /// `JSON.generate`'s: no whitespace.
29
+ pub const fn compact(out: &'w mut W) -> Self {
30
+ Self { out, pretty: false, depth: 0, empty: true }
31
+ }
32
+
33
+ /// # Errors
34
+ ///
35
+ /// What writing returns.
36
+ pub fn object(&mut self, members: impl FnOnce(&mut Self) -> io::Result<()>) -> io::Result<()> {
37
+ self.container(b'{', b'}', members)
38
+ }
39
+
40
+ /// # Errors
41
+ ///
42
+ /// What writing returns.
43
+ pub fn array(&mut self, items: impl FnOnce(&mut Self) -> io::Result<()>) -> io::Result<()> {
44
+ self.container(b'[', b']', items)
45
+ }
46
+
47
+ /// Starts an object member: its value comes next.
48
+ ///
49
+ /// # Errors
50
+ ///
51
+ /// What writing returns.
52
+ pub fn key(&mut self, key: &str) -> io::Result<&mut Self> {
53
+ self.separate()?;
54
+ self.raw_str(key)?;
55
+ self.out.write_all(if self.pretty { b": " } else { b":" })?;
56
+ Ok(self)
57
+ }
58
+
59
+ /// Starts an array item: its value comes next.
60
+ ///
61
+ /// # Errors
62
+ ///
63
+ /// What writing returns.
64
+ pub fn item(&mut self) -> io::Result<&mut Self> {
65
+ self.separate()?;
66
+ Ok(self)
67
+ }
68
+
69
+ /// # Errors
70
+ ///
71
+ /// What writing returns.
72
+ pub fn str(&mut self, value: &str) -> io::Result<()> {
73
+ self.raw_str(value)
74
+ }
75
+
76
+ /// # Errors
77
+ ///
78
+ /// What writing returns.
79
+ pub fn uint(&mut self, value: u64) -> io::Result<()> {
80
+ write!(self.out, "{value}")
81
+ }
82
+
83
+ /// Ruby's way: `100.0`, not `100`.
84
+ ///
85
+ /// # Errors
86
+ ///
87
+ /// What writing returns.
88
+ pub fn float(&mut self, value: f64) -> io::Result<()> {
89
+ let mut text = value.to_string();
90
+ if !text.contains(['.', 'e', 'N', 'i']) {
91
+ text.push_str(".0");
92
+ }
93
+ self.out.write_all(text.as_bytes())
94
+ }
95
+
96
+ /// # Errors
97
+ ///
98
+ /// What writing returns.
99
+ pub fn bool(&mut self, value: bool) -> io::Result<()> {
100
+ self.out.write_all(if value { b"true" } else { b"false" })
101
+ }
102
+
103
+ /// # Errors
104
+ ///
105
+ /// What writing returns.
106
+ pub fn null(&mut self) -> io::Result<()> {
107
+ self.out.write_all(b"null")
108
+ }
109
+
110
+ fn container(
111
+ &mut self,
112
+ open: u8,
113
+ close: u8,
114
+ members: impl FnOnce(&mut Self) -> io::Result<()>,
115
+ ) -> io::Result<()> {
116
+ self.out.write_all(&[open])?;
117
+ self.depth = self.depth.strict_add(1);
118
+ self.empty = true;
119
+ members(self)?;
120
+ self.depth = self.depth.strict_sub(1);
121
+ if !self.empty {
122
+ self.newline()?;
123
+ }
124
+ // The enclosing container has this one as a member.
125
+ self.empty = false;
126
+ self.out.write_all(&[close])
127
+ }
128
+
129
+ fn separate(&mut self) -> io::Result<()> {
130
+ if !self.empty {
131
+ self.out.write_all(b",")?;
132
+ }
133
+ self.empty = false;
134
+ self.newline()
135
+ }
136
+
137
+ fn newline(&mut self) -> io::Result<()> {
138
+ if self.pretty {
139
+ self.out.write_all(b"\n")?;
140
+ for _ in 0..self.depth {
141
+ self.out.write_all(b" ")?;
142
+ }
143
+ }
144
+ Ok(())
145
+ }
146
+
147
+ /// Escapes what JSON requires, in runs: no copy of the string.
148
+ fn raw_str(&mut self, value: &str) -> io::Result<()> {
149
+ self.out.write_all(b"\"")?;
150
+ let bytes = value.as_bytes();
151
+ let mut start = 0;
152
+ for (index, &byte) in bytes.iter().enumerate() {
153
+ let escape: &[u8] = match byte {
154
+ b'"' => b"\\\"",
155
+ b'\\' => b"\\\\",
156
+ 0x08 => b"\\b",
157
+ 0x0c => b"\\f",
158
+ b'\n' => b"\\n",
159
+ b'\r' => b"\\r",
160
+ b'\t' => b"\\t",
161
+ 0x20.. => continue,
162
+ _ => b"",
163
+ };
164
+ self.out.write_all(&bytes[start..index])?;
165
+ if escape.is_empty() {
166
+ write!(self.out, "\\u{byte:04x}")?;
167
+ } else {
168
+ self.out.write_all(escape)?;
169
+ }
170
+ start = index.strict_add(1);
171
+ }
172
+ self.out.write_all(&bytes[start..])?;
173
+ self.out.write_all(b"\"")
174
+ }
175
+ }
@@ -0,0 +1,50 @@
1
+ use std::io::{self, Write};
2
+
3
+ use super::FileReport;
4
+
5
+ /// Writes `reports` as an LCOV tracefile:
6
+ ///
7
+ /// ```text
8
+ /// SF:./app/models/user.rb
9
+ /// DA:3,1 each line of code outside :nocov: blocks
10
+ /// LF:12 lines of code, those in :nocov: blocks included
11
+ /// LH:9 lines covered
12
+ /// BRDA:4,0,1,2 each branch outside blocks: line, 0, number, hits
13
+ /// BRDA:6,0,2,- ("-" for none)
14
+ /// end_of_record
15
+ /// ```
16
+ ///
17
+ /// An empty `DA` or `BRDA` section leaves an empty line.
18
+ ///
19
+ /// # Errors
20
+ ///
21
+ /// What writing to `out` returns.
22
+ pub fn write_lcov(reports: &[FileReport<'_>], out: &mut impl Write) -> io::Result<()> {
23
+ for report in reports {
24
+ writeln!(out, "SF:./{}", report.file.name)?;
25
+ let (mut code, mut covered, mut first) = (0_u64, 0_u64, true);
26
+ for line in report.lines() {
27
+ code = code.strict_add(u64::from(line.hits.is_some()));
28
+ covered = covered.strict_add(u64::from(line.covered()));
29
+ if let Some(hits) = line.hits
30
+ && !line.skipped
31
+ {
32
+ if !first {
33
+ out.write_all(b"\n")?;
34
+ }
35
+ first = false;
36
+ write!(out, "DA:{},{hits}", line.number)?;
37
+ }
38
+ }
39
+ write!(out, "\nLF:{code}\nLH:{covered}\n")?;
40
+ for (number, branch) in (1_u64..).zip(report.reported_branches()) {
41
+ if number > 1 {
42
+ out.write_all(b"\n")?;
43
+ }
44
+ write!(out, "BRDA:{},0,{number},", branch.report_line())?;
45
+ if branch.hits == 0 { out.write_all(b"-")? } else { write!(out, "{}", branch.hits)? }
46
+ }
47
+ out.write_all(b"\nend_of_record\n")?;
48
+ }
49
+ Ok(())
50
+ }
@@ -0,0 +1,93 @@
1
+ use std::fs;
2
+
3
+ use tempfile::TempDir;
4
+
5
+ use super::super::FileCoverage;
6
+ use super::super::test_support::{calc, reports, summary};
7
+ use super::*;
8
+
9
+ /// Calc misses two branches; gap.rb misses a line holding backticks, and
10
+ /// one line further down.
11
+ #[test]
12
+ fn totals_groups_then_the_code_that_never_ran() {
13
+ let dir = TempDir::new().unwrap();
14
+ let gap = dir.path().join("gap.rb");
15
+ fs::write(&gap, "def a\n 1\nend\n\ndef b\n x = \"```\"\n :y\nend\n\n:z\n").unwrap();
16
+ let mut lines =
17
+ vec![Some(1), Some(1), None, None, Some(1), Some(0), Some(0), None, None, Some(0)];
18
+ lines.truncate(10);
19
+ let files = [
20
+ calc(&dir),
21
+ FileCoverage {
22
+ path: gap,
23
+ name: "lib/gap.rb".to_owned(),
24
+ lines,
25
+ branches: Some(Vec::new()),
26
+ methods: None,
27
+ loaded: true,
28
+ },
29
+ ];
30
+ let reports = reports(&files);
31
+ let mut summary = summary(&reports, &[Criterion::Line, Criterion::Branch]);
32
+ summary.groups =
33
+ vec![("Core".to_owned(), vec![0], super::super::Totals::of_files(&reports[..1]))];
34
+ summary.meta.project_name = "App".to_owned();
35
+ summary.meta.timestamp = "2026-10-11T13:00:00.000+02:00".to_owned();
36
+ summary.meta.commit = Some("0123456789abcdef".to_owned());
37
+ let mut out = Vec::new();
38
+ write_markdown(&summary, &mut out).unwrap();
39
+
40
+ let expected = "\
41
+ # Coverage: App
42
+
43
+ Lines 75.00% (9 / 12) · Branches 50.00% (2 / 4)
44
+
45
+ Generated 2026-10-11T13:00:00.000+02:00 by padded_cell 0.1.0, at `0123456789ab`.
46
+
47
+ | Group | Lines | Branches |
48
+ |---|---:|---:|
49
+ | Core | 100.00% (6 / 6) | 50.00% (2 / 4) |
50
+
51
+ ## Missed, most first
52
+
53
+ ### `lib/gap.rb`
54
+
55
+ Lines 50.00% (3 / 6) · Branches 100.00% (0 / 0)
56
+
57
+ Lines never run: 6-7, 10.
58
+
59
+ ````ruby
60
+ 6 x = \"```\"
61
+ 7 :y
62
+ …
63
+ 10 :z
64
+ ````
65
+
66
+ ### `lib/calc.rb`
67
+
68
+ Lines 100.00% (6 / 6) · Branches 50.00% (2 / 4)
69
+
70
+ Branches never taken: 7 `else`, 16 `else`.
71
+ ";
72
+ assert_eq!(String::from_utf8(out).unwrap(), expected);
73
+ }
74
+
75
+ #[test]
76
+ fn everything_ran() {
77
+ let dir = TempDir::new().unwrap();
78
+ let mut file = calc(&dir);
79
+ file.branches = None;
80
+ let files = [file];
81
+ let reports = reports(&files);
82
+ let mut out = Vec::new();
83
+ write_markdown(&summary(&reports, &[Criterion::Line]), &mut out).unwrap();
84
+ assert!(
85
+ String::from_utf8(out).unwrap().ends_with("by padded_cell 0.1.0.\n\nEverything ran.\n")
86
+ );
87
+ }
88
+
89
+ #[test]
90
+ fn ranges_collapse_runs() {
91
+ assert_eq!(ranges(&[3, 4, 5, 9, 11, 12]), "3-5, 9, 11-12");
92
+ assert_eq!(ranges(&[7]), "7");
93
+ }
@@ -0,0 +1,201 @@
1
+ use std::io::{self, Write};
2
+ use std::path::Path;
3
+
4
+ use super::stats::{Criterion, Stats};
5
+ use super::{Branch, FileReport, Line, MethodReport, Summary, chomp, floor2};
6
+
7
+ #[cfg(test)]
8
+ mod tests;
9
+
10
+ /// GitHub job summaries take up to 1 MiB: files past it are only counted.
11
+ const BUDGET: usize = 1_000_000;
12
+ /// Lines of missed code shown per file.
13
+ const EXCERPT: usize = 40;
14
+
15
+ /// Writes `summary` as Markdown.
16
+ ///
17
+ /// # Errors
18
+ ///
19
+ /// What writing to `out` returns.
20
+ pub fn write_markdown(summary: &Summary<'_>, out: &mut impl Write) -> io::Result<()> {
21
+ let mut head = Vec::new();
22
+ header(&mut head, summary)?;
23
+ out.write_all(&head)?;
24
+ let mut files: Vec<&FileReport<'_>> =
25
+ summary.files.iter().filter(|file| missed(file) > 0).collect();
26
+ if files.is_empty() {
27
+ return out.write_all(b"Everything ran.\n");
28
+ }
29
+ files.sort_by(|a, b| missed(b).cmp(&missed(a)).then_with(|| a.file.name.cmp(&b.file.name)));
30
+ out.write_all(b"## Missed, most first\n")?;
31
+ let mut written = head.len();
32
+ for (index, file) in files.iter().enumerate() {
33
+ let mut section = Vec::new();
34
+ file_section(&mut section, summary, file)?;
35
+ written = written.strict_add(section.len());
36
+ if written > BUDGET {
37
+ let rest = files.len().strict_sub(index);
38
+ return writeln!(out, "\n{rest} more files have missed code: see `lcov.info`.");
39
+ }
40
+ out.write_all(&section)?;
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
+ }