Skip to main content

supercov_engine/
ruby_project.rs

1//! Ruby project discovery and ahead-of-run obligation preparation.
2//!
3//! The project runs in place: nothing here copies or rewrites sources. Rust
4//! reads every in-scope `.rb` file once, builds the complete manifest and the
5//! runtime probe plan, and records which files were included, excluded or
6//! unparseable so the run can say exactly what its denominator covers.
7
8use std::{
9    collections::{BTreeMap, BTreeSet},
10    fs,
11    path::{Path, PathBuf},
12};
13
14use serde_json::json;
15
16use crate::{
17    coverage_report::CoverageManifest,
18    integrity::ExplicitIntegrityInputs,
19    ruby_instrumenter::{
20        RUBY_PROBE_PLAN_VERSION, RUBY_PROBE_RECEIVER, RubyFilePlan, RubyProbePlan,
21        build_ruby_obligations,
22    },
23    source_discovery::{SourceScope, SourceScopeEntry, SourceScopeMode, SourceScopeStatus},
24};
25
26pub const UNPARSEABLE_LIMITATION: &str = "ruby-source-unparseable";
27
28/// Directories that never hold the project's own measured source. The list
29/// follows what Ruby coverage tooling conventionally filters for Rails and
30/// gem layouts (Rails `config/` holds boot and environment settings, not
31/// application logic).
32const EXCLUDED_DIRECTORIES: &[&str] = &[
33    ".git",
34    ".hg",
35    ".svn",
36    ".supercov",
37    ".bundle",
38    "node_modules",
39    "vendor",
40    "tmp",
41    "log",
42    "coverage",
43    "config",
44    "db",
45    "bin",
46    "public",
47    "storage",
48    ".yardoc",
49    "doc",
50    "pkg",
51];
52
53const TEST_DIRECTORIES: &[&str] = &["spec", "test", "tests", "features"];
54
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub struct RubyFiles {
57    /// Relative, `/`-separated paths of measured application sources.
58    pub sources: Vec<String>,
59    /// Relative paths of specs, tests and other excluded `.rb` files.
60    pub tests: Vec<String>,
61    pub dependency_files: Vec<PathBuf>,
62    pub configuration_files: Vec<PathBuf>,
63    pub excluded: Vec<(String, &'static str)>,
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub struct PreparedRubyProject {
68    pub root: PathBuf,
69    pub files: RubyFiles,
70    pub manifest: CoverageManifest,
71    pub plan: RubyProbePlan,
72    pub unparseable: Vec<(String, String)>,
73}
74
75fn is_test_path(relative: &str) -> Option<&'static str> {
76    let mut components = relative.split('/').peekable();
77    let mut file_name = "";
78    while let Some(component) = components.next() {
79        if components.peek().is_none() {
80            file_name = component;
81            break;
82        }
83        if TEST_DIRECTORIES.contains(&component) {
84            return Some("inside a test directory");
85        }
86    }
87    if file_name.ends_with("_spec.rb") || file_name.ends_with("_test.rb") {
88        return Some("test module by name");
89    }
90    if file_name.starts_with("test_") && file_name.ends_with(".rb") {
91        return Some("test module by name");
92    }
93    if matches!(
94        file_name,
95        "spec_helper.rb" | "rails_helper.rb" | "test_helper.rb"
96    ) {
97        return Some("test helper");
98    }
99    None
100}
101
102fn walk(root: &Path, directory: &Path, files: &mut RubyFiles) -> Result<(), String> {
103    let mut entries = fs::read_dir(directory)
104        .map_err(|error| format!("{}: {error}", directory.display()))?
105        .collect::<Result<Vec<_>, _>>()
106        .map_err(|error| error.to_string())?;
107    entries.sort_by_key(fs::DirEntry::file_name);
108    for entry in entries {
109        let path = entry.path();
110        let name = entry
111            .file_name()
112            .into_string()
113            .map_err(|_| format!("Ruby project contains a non-UTF-8 path: {}", path.display()))?;
114        let file_type = entry.file_type().map_err(|error| error.to_string())?;
115        let relative = path
116            .strip_prefix(root)
117            .map_err(|_| format!("path escaped root: {}", path.display()))?
118            .to_string_lossy()
119            .replace('\\', "/");
120        if file_type.is_dir() {
121            if EXCLUDED_DIRECTORIES.contains(&name.as_str()) {
122                files
123                    .excluded
124                    .push((relative, "tooling, dependency or generated directory"));
125                continue;
126            }
127            walk(root, &path, files)?;
128        } else if file_type.is_file() {
129            match name.as_str() {
130                "Gemfile" | "Gemfile.lock" | ".ruby-version" | ".tool-versions" => {
131                    files.dependency_files.push(PathBuf::from(&relative));
132                    continue;
133                }
134                ".rspec" | "Rakefile" | "config.ru" => {
135                    files.configuration_files.push(PathBuf::from(&relative));
136                    continue;
137                }
138                _ => {}
139            }
140            if name.ends_with(".gemspec") {
141                files.dependency_files.push(PathBuf::from(&relative));
142                continue;
143            }
144            if !name.ends_with(".rb") {
145                continue;
146            }
147            match is_test_path(&relative) {
148                Some(reason) => {
149                    files.tests.push(relative.clone());
150                    files.excluded.push((relative, reason));
151                }
152                None => files.sources.push(relative),
153            }
154        }
155        // Symlinks are neither followed nor measured.
156    }
157    Ok(())
158}
159
160pub fn discover_ruby_files(root: &Path) -> Result<RubyFiles, String> {
161    let mut files = RubyFiles::default();
162    walk(root, root, &mut files)?;
163    files.sources.sort();
164    files.tests.sort();
165    files.dependency_files.sort();
166    files.configuration_files.sort();
167    Ok(files)
168}
169
170pub fn prepare_ruby_project(
171    root: &Path,
172    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
173) -> Result<PreparedRubyProject, String> {
174    let mut files = discover_ruby_files(root)?;
175    match roots {
176        Some(roots) => {
177            roots.narrow(&mut files.sources, &mut files.excluded, String::as_str);
178            roots.refuse_if_empty(files.sources.len(), "Ruby")?;
179        }
180        None => crate::source_discovery::leave_out_what_lies_beside(
181            &mut files.sources,
182            &mut files.excluded,
183        ),
184    }
185    if files.sources.is_empty() && files.tests.is_empty() {
186        return Err(
187            "no Ruby source files were found under the project root; Supercov measures .rb files outside vendor, db, bin and test directories".into(),
188        );
189    }
190    let mut manifest = CoverageManifest {
191        unmeasured: Vec::new(),
192        decisions: Vec::new(),
193        points: Vec::new(),
194        branches: Vec::new(),
195        limitations: Vec::new(),
196        scope: None,
197        assertion_sites: Vec::new(),
198    };
199    let mut plan_files = BTreeMap::<String, RubyFilePlan>::new();
200    let mut probes = BTreeMap::new();
201    let mut next_probe = 0u64;
202    let mut limitation_ids = BTreeSet::new();
203    let mut unparseable = Vec::new();
204    for relative in &files.sources {
205        let path = root.join(relative);
206        let source = fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
207        match build_ruby_obligations(relative, &source, &mut next_probe) {
208            Ok(obligations) => {
209                manifest.points.extend(obligations.manifest.points);
210                manifest.decisions.extend(obligations.manifest.decisions);
211                manifest.branches.extend(obligations.manifest.branches);
212                manifest.unmeasured.extend(obligations.manifest.unmeasured);
213                for item in obligations.manifest.limitations {
214                    let id = item
215                        .get("id")
216                        .and_then(serde_json::Value::as_str)
217                        .unwrap_or_default()
218                        .to_owned();
219                    if limitation_ids.insert(id) {
220                        manifest.limitations.push(item);
221                    }
222                }
223                plan_files.insert(relative.clone(), obligations.plan);
224                probes.extend(obligations.probes);
225            }
226            Err(error) => unparseable.push((relative.clone(), error.to_string())),
227        }
228    }
229    if let Some((file, reason)) = unparseable.first()
230        && limitation_ids.insert(UNPARSEABLE_LIMITATION.into())
231    {
232        manifest.limitations.push(json!({
233            "id": UNPARSEABLE_LIMITATION,
234            "kind": "source-scope",
235            "file": file,
236            "line": 1,
237            "column": 0,
238            "source": "",
239            "reason": format!(
240                "{} source file(s) could not be parsed and carry no obligations; first: {file}: {reason}",
241                unparseable.len()
242            )
243        }));
244    }
245    manifest.unmeasured.sort();
246    manifest.unmeasured.dedup();
247    let mut entries = Vec::new();
248    for file in &files.sources {
249        let unparseable_file = unparseable.iter().any(|(path, _)| path == file);
250        entries.push(SourceScopeEntry {
251            file: file.clone(),
252            status: if unparseable_file {
253                SourceScopeStatus::Excluded
254            } else {
255                SourceScopeStatus::Included
256            },
257            reason: if unparseable_file {
258                "could not be parsed".into()
259            } else {
260                "Ruby application source".into()
261            },
262            package_root: None,
263        });
264    }
265    for (file, reason) in &files.excluded {
266        if file.ends_with(".rb") {
267            entries.push(SourceScopeEntry {
268                file: file.clone(),
269                status: SourceScopeStatus::Excluded,
270                reason: (*reason).into(),
271                package_root: None,
272            });
273        }
274    }
275    entries.sort_by(|left, right| left.file.cmp(&right.file));
276    manifest.scope = Some(
277        serde_json::to_value(SourceScope {
278            version: 1,
279            mode: if roots.is_some() {
280                SourceScopeMode::Explicit
281            } else {
282                SourceScopeMode::Automatic
283            },
284            roots: match roots {
285                Some(roots) => roots.local_roots().map_err(|error| error.to_string())?,
286                None => vec![".".into()],
287            },
288            entries,
289        })
290        .map_err(|error| error.to_string())?,
291    );
292    let mut plan = RubyProbePlan {
293        version: RUBY_PROBE_PLAN_VERSION,
294        root: root.display().to_string(),
295        receiver: RUBY_PROBE_RECEIVER.into(),
296        files: plan_files,
297        probes,
298        probe_obligations: Vec::new(),
299    };
300    plan.probe_obligations = plan.probe_obligations();
301    Ok(PreparedRubyProject {
302        root: root.to_owned(),
303        plan,
304        manifest,
305        files,
306        unparseable,
307    })
308}
309
310pub fn ruby_integrity_inputs(files: &RubyFiles, command: &[String]) -> ExplicitIntegrityInputs {
311    let execution_configuration = command.join("\0").into_bytes();
312    ExplicitIntegrityInputs {
313        source_files: files.sources.iter().map(PathBuf::from).collect(),
314        test_files: files.tests.iter().map(PathBuf::from).collect(),
315        dependency_files: files.dependency_files.clone(),
316        configuration_files: files.configuration_files.clone(),
317        execution_configuration,
318    }
319}
320
321#[cfg(test)]
322mod tests {
323
324    #[test]
325    fn the_ambient_environment_is_not_part_of_run_identity() {
326        // Identity is what the project is, not which shell it was run from.
327        // Folding the environment in meant no two terminals, and no two
328        // machines, ever agreed on the same run. What a project genuinely needs
329        // from its environment it declares in the files that are hashed above.
330        let files = RubyFiles::default();
331        let command = ["rspec".to_owned(), "--format=progress".to_owned()];
332        let inputs = ruby_integrity_inputs(&files, &command);
333        assert_eq!(inputs.execution_configuration, b"rspec\0--format=progress");
334    }
335    use std::time::{SystemTime, UNIX_EPOCH};
336
337    use super::*;
338
339    fn fixture(name: &str) -> PathBuf {
340        let nonce = SystemTime::now()
341            .duration_since(UNIX_EPOCH)
342            .unwrap()
343            .as_nanos();
344        let root = std::env::temp_dir().join(format!(
345            "supercov-ruby-project-{}-{nonce}-{name}",
346            std::process::id()
347        ));
348        fs::create_dir_all(&root).unwrap();
349        root
350    }
351
352    fn write(root: &Path, relative: &str, contents: &str) {
353        let path = root.join(relative);
354        fs::create_dir_all(path.parent().unwrap()).unwrap();
355        fs::write(path, contents).unwrap();
356    }
357
358    fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
359        let roots = roots
360            .iter()
361            .map(|root| (*root).to_owned())
362            .collect::<Vec<_>>();
363        crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
364    }
365
366    #[test]
367    fn benchmarks_and_examples_beside_the_gem_are_not_measured_unless_named() {
368        // addressable keeps two benchmark scripts beside `lib`, hashie three:
369        // nothing a test runs, and counted with the gem's own files.
370        let root = fixture("beside");
371        write(&root, "Gemfile", "source 'https://rubygems.org'\n");
372        write(&root, "lib/app.rb", "def f(a)\n  a && 1\nend\n");
373        write(&root, "lib/app/examples.rb", "def g\n  1\nend\n");
374        write(&root, "benchmark/template_bench.rb", "require 'app'\n");
375        write(&root, "examples/usage.rb", "require 'app'\n");
376
377        let project = prepare_ruby_project(&root, None).unwrap();
378        assert_eq!(
379            project.files.sources,
380            ["lib/app.rb", "lib/app/examples.rb"],
381            "{:?}",
382            project.files
383        );
384        assert!(
385            project
386                .files
387                .excluded
388                .contains(&("benchmark/template_bench.rb".into(), "benchmark")),
389            "{:?}",
390            project.files.excluded
391        );
392        let named =
393            prepare_ruby_project(&root, Some(&explicit_roots(&root, &["examples"]))).unwrap();
394        assert_eq!(named.files.sources, ["examples/usage.rb"]);
395        fs::remove_dir_all(root).ok();
396    }
397
398    #[test]
399    fn explicit_roots_narrow_ruby_to_the_code_they_name() {
400        // `third_party`, not `vendor`: vendor is already left out by default,
401        // and a test that passed for that reason would prove nothing.
402        let root = fixture("explicit-roots");
403        write(&root, "Gemfile", "source 'https://rubygems.org'\n");
404        write(&root, "lib/app.rb", "def f(a)\n  a && 1\nend\n");
405        write(&root, "third_party/dep.rb", "def g\n  1\nend\n");
406
407        let project = prepare_ruby_project(&root, Some(&explicit_roots(&root, &["lib"]))).unwrap();
408        assert_eq!(project.files.sources, ["lib/app.rb"], "{:?}", project.files);
409        assert!(
410            project
411                .files
412                .excluded
413                .contains(&("third_party/dep.rb".into(), "outside explicit source roots")),
414            "{:?}",
415            project.files.excluded
416        );
417        let scope: crate::source_discovery::SourceScope =
418            serde_json::from_value(project.manifest.scope.clone().unwrap()).unwrap();
419        assert_eq!(
420            scope.mode,
421            crate::source_discovery::SourceScopeMode::Explicit
422        );
423        assert_eq!(scope.roots, ["lib"]);
424
425        // A single file is a root as well.
426        let project =
427            prepare_ruby_project(&root, Some(&explicit_roots(&root, &["third_party/dep.rb"])))
428                .unwrap();
429        assert_eq!(project.files.sources, ["third_party/dep.rb"]);
430        fs::remove_dir_all(root).ok();
431    }
432
433    #[test]
434    fn separates_sources_tests_and_tooling() {
435        let root = fixture("discover");
436        write(&root, "Gemfile", "source 'https://rubygems.org'\n");
437        write(&root, ".rspec", "--require spec_helper\n");
438        write(&root, "lib/app.rb", "def f(a)\n  a && 1\nend\n");
439        write(&root, "lib/app/version.rb", "VERSION = '1'\n");
440        write(&root, "spec/app_spec.rb", "RSpec.describe 'x' do end\n");
441        write(&root, "spec/spec_helper.rb", "");
442        write(&root, "test/app_test.rb", "");
443        write(&root, "vendor/bundle/gem.rb", "x = 1\n");
444        write(&root, "db/schema.rb", "x = 1\n");
445        write(&root, "broken/old.rb", "def (\n");
446        let project = prepare_ruby_project(&root, None).unwrap();
447        assert_eq!(
448            project.files.sources,
449            ["broken/old.rb", "lib/app.rb", "lib/app/version.rb"]
450        );
451        assert_eq!(
452            project.files.tests,
453            [
454                "spec/app_spec.rb",
455                "spec/spec_helper.rb",
456                "test/app_test.rb"
457            ]
458        );
459        assert_eq!(project.files.dependency_files, [PathBuf::from("Gemfile")]);
460        assert_eq!(project.files.configuration_files, [PathBuf::from(".rspec")]);
461        assert_eq!(project.unparseable.len(), 1);
462        assert!(project.plan.files.contains_key("lib/app.rb"));
463        assert!(!project.plan.files.contains_key("broken/old.rb"));
464        assert_eq!(project.manifest.limitations.len(), 1);
465        assert_eq!(
466            project.manifest.limitations[0]["id"],
467            UNPARSEABLE_LIMITATION
468        );
469        // Probe keys are unique across files.
470        let keys = project.plan.probes.keys().copied().collect::<Vec<_>>();
471        let unique = keys.iter().collect::<BTreeSet<_>>();
472        assert_eq!(keys.len(), unique.len());
473        fs::remove_dir_all(root).unwrap();
474    }
475}