diff --git a/crates/ide-coverage/src/import.rs b/crates/ide-coverage/src/import.rs index 669f1b8c..e35c24c1 100644 --- a/crates/ide-coverage/src/import.rs +++ b/crates/ide-coverage/src/import.rs @@ -1,8 +1,8 @@ //! "Import a report collected in CI from disk": the format is sniffed from the -//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another +//! content (lcov, Go cover profile, JaCoCo XML, SimpleCov resultset) and paths recorded on another //! machine are mapped into the project by their longest existing suffix. -use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov}; +use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov, simplecov}; use ide_core::{IdeError, IdeResult}; use std::path::{Component, Path, PathBuf}; @@ -11,6 +11,7 @@ pub(crate) enum Format { Lcov, GoProfile, Jacoco, + SimpleCov, } pub(crate) fn detect(text: &str) -> Option { @@ -18,6 +19,9 @@ pub(crate) fn detect(text: &str) -> Option { if head.starts_with('<') { return head.contains(" IdeResult { let text = std::fs::read_to_string(path)?; let mut suite = match detect(&text) { Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"), + Some(Format::SimpleCov) => simplecov::parse(&text, root)?.finish("SimpleCov"), Some(Format::GoProfile) => { let (module, module_dir) = go_module_for(root, &text); goprofile::parse(&text, &module, &module_dir).finish("go test -cover") @@ -50,7 +55,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult { } None => { return Err(IdeError::invalid(format!( - "{} is not an lcov tracefile, Go cover profile or JaCoCo XML report", + "{} is not an lcov tracefile, Go cover profile, JaCoCo XML report or SimpleCov resultset", path.display() ))); } @@ -125,9 +130,29 @@ mod tests { Some(Format::Jacoco) ); assert_eq!(detect(""), None); + assert_eq!(detect("{\"RSpec\": {\"coverage\": {}}}"), Some(Format::SimpleCov)); + assert_eq!(detect("{\"name\": \"package.json\"}"), None); assert_eq!(detect("hello"), None); } + #[test] + fn imports_ci_simplecov_with_foreign_paths() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("app"); + std::fs::create_dir_all(root.join("lib")).unwrap(); + std::fs::write(root.join("lib/a.rb"), "").unwrap(); + let report = tmp.path().join(".resultset.json"); + std::fs::write( + &report, + r#"{"RSpec":{"coverage":{"/home/runner/work/app/app/lib/a.rb":{"lines":[null,1,0]}}}}"#, + ) + .unwrap(); + let suite = import(&report, &root).unwrap(); + assert_eq!(suite.runner, "SimpleCov"); + assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("lib/a.rb")); + assert_eq!((suite.files[0].lines_total, suite.files[0].lines_covered), (2, 1)); + } + #[test] fn imports_ci_lcov_with_foreign_paths() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-coverage/src/lib.rs b/crates/ide-coverage/src/lib.rs index 75781203..707f936d 100644 --- a/crates/ide-coverage/src/lib.rs +++ b/crates/ide-coverage/src/lib.rs @@ -10,6 +10,7 @@ //! | JS / TS (vitest, jest scripts) | the runner's `--coverage` | lcov | //! | Node.js scripts | `c8` | lcov | //! | Python (pytest, scripts) | coverage.py (`coverage run`, then `coverage lcov`) | lcov | +//! | Ruby (rspec, minitest, rake, scripts) | SimpleCov (`coverage/.resultset.json`) | SimpleCov resultset | //! | Go (`go test`) | `-coverprofile` | Go cover profile | //! | Maven / Gradle tests | JaCoCo (plugin goals / an init script) | JaCoCo XML | //! @@ -20,6 +21,7 @@ mod import; mod jacoco; mod lcov; mod plan; +mod simplecov; pub use import::import; pub use plan::prepare; @@ -83,6 +85,8 @@ pub enum CoverageReportSpec { Lcov { runner: String, path: IpcPath, base: IpcPath }, /// `go test -coverprofile`; import paths under `module` map to `module_dir`. GoProfile { path: IpcPath, module: String, module_dir: IpcPath }, + /// SimpleCov's `.resultset.json`; relative paths resolve against `base`. + SimpleCov { path: IpcPath, base: IpcPath }, /// Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`. Jacoco { dir: IpcPath }, /// The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o `). @@ -185,6 +189,10 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult { let text = read_report(path)?; Ok(lcov::parse(&text, base).finish(runner)) } + CoverageReportSpec::SimpleCov { path, base } => { + let text = read_report(path)?; + Ok(simplecov::parse(&text, base)?.finish("SimpleCov")) + } CoverageReportSpec::GoProfile { path, module, module_dir } => { let text = read_report(path)?; Ok(goprofile::parse(&text, module, module_dir).finish("go test -cover")) diff --git a/crates/ide-coverage/src/plan.rs b/crates/ide-coverage/src/plan.rs index 2368aaaa..5355ffd5 100644 --- a/crates/ide-coverage/src/plan.rs +++ b/crates/ide-coverage/src/plan.rs @@ -67,6 +67,7 @@ pub fn prepare(root: &Path, config: &RunConfig, out: &Path) -> CoveragePrepared "gradle" | "gradlew" => gradle(config, &dir, out), "npm" | "pnpm" | "yarn" | "bun" => npm(root, config, &dir, out), "node" | "npx" | "tsx" | "ts-node" => c8(root, config, &dir, out), + "ruby" | "bundle" | "rake" | "rspec" | "rails" => ruby(root, config, &dir, out), "deno" => unsupported("Coverage of Deno tasks is not supported yet."), "java" => unsupported( "Coverage of Java applications needs the JaCoCo agent; run a Maven or Gradle test configuration with coverage.", @@ -234,6 +235,65 @@ fn c8(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepar ready("c8", config, path(&c8), args, report) } +/// Loaded first (`ide_run::ruby_preload`) when the project never starts SimpleCov itself. +const SIMPLECOV_PRELOAD: &str = r#"# Written by OpenIDEA for Run with Coverage. +begin + require "simplecov" +rescue LoadError + # `bundle exec` is a Ruby process too and loads this before Bundler puts the project's gems on + # the load path; the Ruby it starts loads bundler/setup first and finds SimpleCov. + return +end +SimpleCov.coverage_dir(ENV.fetch("OPENIDEA_COVERAGE_DIR")) +SimpleCov.start do + add_filter %r{^/(test|spec|features)/} + enable_coverage :branch +end +"#; + +/// Files where a project conventionally starts SimpleCov. +const SIMPLECOV_STARTERS: &[&str] = + &[".simplecov", "spec/spec_helper.rb", "spec/rails_helper.rb", "test/test_helper.rb", "test/minitest_helper.rb"]; + +fn ruby(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared { + let bundled = [dir, root].iter().any(|d| d.join("Gemfile").is_file()); + let mentions = |file: &str| { + [dir, root].iter().any(|d| std::fs::read_to_string(d.join(file)).is_ok_and(|t| t.contains("simplecov"))) + }; + if bundled && !mentions("Gemfile.lock") && !mentions("Gemfile") { + return missing( + "simplecov", + "Ruby coverage needs the simplecov gem in the project's Gemfile.", + Some((vec![s("bundle"), s("add"), s("simplecov"), s("--group"), s("test")], dir)), + ); + } + let starts = |d: &Path| { + SIMPLECOV_STARTERS.iter().any(|f| { + std::fs::read_to_string(d.join(f)) + .is_ok_and(|t| t.contains("SimpleCov.start") || f.starts_with(".simplecov")) + }) + }; + let mut config = config.clone(); + let resultset = if [dir, root].iter().any(|d| starts(d)) { + // The project's own configuration decides where the report goes (default `coverage/`). + // Drop the previous run's data: SimpleCov merges into it. + let results = dir.join("coverage"); + let _ = std::fs::remove_file(results.join(".resultset.json")); + results.join(".resultset.json") + } else { + let preload = out.join("openidea_simplecov.rb"); + if let Err(e) = std::fs::write(&preload, SIMPLECOV_PRELOAD) { + return unsupported(format!("Cannot write the SimpleCov preload: {e}")); + } + config.env.insert(s("OPENIDEA_COVERAGE_DIR"), path(out)); + // `out` lives under the IDE's data directory, which may have spaces (RUBYOPT cannot take them). + ide_run::ruby_preload::add(&mut config.env, out, "openidea_simplecov", true); + out.join(".resultset.json") + }; + let report = CoverageReportSpec::SimpleCov { path: resultset.into(), base: dir.to_path_buf().into() }; + ready("SimpleCov", &config, config.command.clone(), config.args.clone(), report) +} + fn python(config: &RunConfig, dir: &Path, out: &Path, py: wrap::PythonCommand) -> CoveragePrepared { if py.target.is_empty() { return unsupported("Coverage needs a Python script or module to run."); @@ -398,6 +458,74 @@ mod tests { )); } + /// `bundle exec` loads the preload before Bundler sets up the project's gems: without SimpleCov + /// on the load path it must step aside instead of failing the launcher. + #[test] + fn simplecov_preload_tolerates_a_ruby_without_the_gem() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("out dir"); + std::fs::create_dir_all(&out).unwrap(); + std::fs::write(out.join("openidea_simplecov.rb"), SIMPLECOV_PRELOAD).unwrap(); + let status = std::process::Command::new("ruby") + .args(["-e", "exit 0"]) + .env("RUBYLIB", &out) + .env("RUBYOPT", "-ropenidea_simplecov") + .env("OPENIDEA_COVERAGE_DIR", &out) + .env("GEM_HOME", dir.path().join("empty gems")) + .env("GEM_PATH", dir.path().join("empty gems")) + .status(); + // No Ruby on this machine: nothing to check. + let Ok(status) = status else { return }; + assert!(status.success()); + } + + #[test] + fn ruby_runs_get_simplecov() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + // The IDE's data directory has spaces on macOS (`Application Support`). + let out = root.join("out dir"); + std::fs::create_dir_all(&out).unwrap(); + std::fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap(); + let config = cfg("rspec", "bundle", &["exec", "rspec"]); + // Bundler project without the gem: install hint. + match prepare(root, &config, &out) { + CoveragePrepared::MissingTool { tool, install: Some(i), .. } => { + assert_eq!(tool, "simplecov"); + assert_eq!(i.args, ["add", "simplecov", "--group", "test"]); + } + other => panic!("{other:?}"), + } + // Gem present, never started: preloaded through RUBYOPT, report in `out`. + std::fs::write(root.join("Gemfile"), "gem 'simplecov'\n").unwrap(); + let p = prepare(root, &config, &out); + let (l, report) = launch(&p); + assert_eq!( + (l.command.as_str(), l.args.as_slice()), + ("bundle", ["exec".to_owned(), "rspec".to_owned()].as_slice()) + ); + assert!(l.env["RUBYOPT"].contains("-ropenidea_simplecov")); + assert!(std::env::split_paths(&l.env["RUBYLIB"]).any(|d| d == out)); + assert_eq!(l.env["OPENIDEA_COVERAGE_DIR"], out.to_string_lossy()); + assert!(out.join("openidea_simplecov.rb").is_file()); + assert!( + matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).starts_with(&out)) + ); + // Started by the project: untouched environment, stale data dropped, report in `coverage/`. + std::fs::create_dir_all(root.join("test")).unwrap(); + std::fs::create_dir_all(root.join("coverage")).unwrap(); + std::fs::write(root.join("coverage/.resultset.json"), "{}").unwrap(); + std::fs::write(root.join("test/test_helper.rb"), "require 'simplecov'\nSimpleCov.start\n").unwrap(); + let p = prepare(root, &cfg("ruby", "ruby", &["-Ilib", "test/a_test.rb"]), &out); + let (l, report) = launch(&p); + assert!(!l.env.contains_key("RUBYOPT")); + assert_eq!(l.args, ["-Ilib", "test/a_test.rb"]); + assert!(!root.join("coverage/.resultset.json").exists()); + assert!( + matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).ends_with("coverage/.resultset.json")) + ); + } + #[test] fn unknown_commands_are_unsupported() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-coverage/src/simplecov.rs b/crates/ide-coverage/src/simplecov.rs new file mode 100644 index 00000000..f93c4175 --- /dev/null +++ b/crates/ide-coverage/src/simplecov.rs @@ -0,0 +1,74 @@ +//! SimpleCov's `coverage/.resultset.json`: +//! +//! ```json +//! { "RSpec": { "timestamp": 1, "coverage": { +//! "/abs/lib/a.rb": { "lines": [null, 1, 0], +//! "branches": { "[:if, 0, 3, 4, 3, 20]": { "[:then, 1, 4, 6, 4, 9]": 1, "[:else, 2, 6, 6, 6, 9]": 0 } } }, +//! "/abs/lib/legacy.rb": [null, 1] } } } +//! ``` +//! +//! `lines[i]` is line `i + 1` (`null` = not relevant), so the index is already 0-based. +//! A branch belongs to the line its condition starts on (third number of its key). + +use crate::Accumulator; +use ide_core::{IdeError, IdeResult}; +use serde_json::Value; +use std::path::Path; + +/// Start line (1-based) of a `"[:if, 0, 3, 4, 3, 20]"` branch key. +fn start_line(key: &str) -> Option { + key.trim_matches(['[', ']']).split(',').nth(2)?.trim().parse().ok() +} + +pub(crate) fn parse(text: &str, base: &Path) -> IdeResult { + let json: Value = serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("SimpleCov resultset: {e}")))?; + let mut acc = Accumulator::default(); + // One entry per test command ("RSpec", "Minitest", …); their coverage is merged. + for suite in json.as_object().into_iter().flat_map(|o| o.values()) { + let Some(files) = suite.get("coverage").and_then(Value::as_object) else { continue }; + for (name, data) in files { + let p = Path::new(name); + let path = if p.is_absolute() { p.to_path_buf() } else { base.join(p) }; + acc.file(&path); + let lines = data.get("lines").or(Some(data)).and_then(Value::as_array); + for (i, hits) in lines.into_iter().flatten().enumerate() { + if let Some(hits) = hits.as_u64() { + acc.line(&path, i as u32, hits); + } + } + for (cond, branches) in data.get("branches").and_then(Value::as_object).into_iter().flatten() { + let Some(line) = start_line(cond) else { continue }; + for (branch, hits) in branches.as_object().into_iter().flatten() { + acc.has_branches = true; + acc.branch(&path, line.saturating_sub(1), format!("{cond}/{branch}"), hits.as_u64() > Some(0)); + } + } + } + } + Ok(acc) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_lines_branches_and_merges_suites() { + let text = r#"{ + "RSpec": {"coverage": {"/p/lib/a.rb": {"lines": [null, 1, 0], + "branches": {"[:if, 0, 2, 4, 2, 20]": {"[:then, 1, 2, 6, 2, 9]": 1, "[:else, 2, 2, 6, 2, 9]": 0}}}}}, + "Minitest": {"coverage": {"/p/lib/a.rb": {"lines": [null, 2, 3]}, "lib/old.rb": [1, null]}} + }"#; + let suite = parse(text, Path::new("/p")).unwrap().finish("SimpleCov"); + assert_eq!(suite.files.len(), 2); + let a = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("a.rb")).unwrap(); + assert_eq!(a.lines.iter().map(|l| (l.line, l.hits)).collect::>(), [(1, 3), (2, 3)]); + assert_eq!((a.branches_total, a.branches_covered), (2, 1)); + assert_eq!(a.lines[0].branches_total, 2); + let old = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("lib/old.rb")).unwrap(); + assert_eq!(old.path.as_ref() as &Path, Path::new("/p/lib/old.rb")); + assert_eq!(old.lines[0].hits, 1); + assert!(suite.has_branches); + assert!(parse("nope", Path::new("/p")).is_err()); + } +} diff --git a/crates/ide-host/src/ops/coverage.rs b/crates/ide-host/src/ops/coverage.rs index 007d701d..f57b538f 100644 --- a/crates/ide-host/src/ops/coverage.rs +++ b/crates/ide-host/src/ops/coverage.rs @@ -32,7 +32,7 @@ pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportS blocking(move || ide_coverage::collect(&report)).await } -/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML, +/// Reads a report collected elsewhere (CI): lcov, a Go cover profile, JaCoCo XML or a SimpleCov resultset, /// with paths mapped into this project. pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult { let root = host.project(window)?.root.clone(); diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 7e85a164..97e31342 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -12,6 +12,7 @@ pub mod java_cmdline; pub mod junit; mod process; pub mod python_path; +pub mod ruby_preload; pub mod tomcat; mod utf8; pub mod which; diff --git a/crates/ide-run/src/ruby_preload.rs b/crates/ide-run/src/ruby_preload.rs new file mode 100644 index 00000000..f4293763 --- /dev/null +++ b/crates/ide-run/src/ruby_preload.rs @@ -0,0 +1,61 @@ +//! Loads a Ruby file into a launched process without touching the project: the directory goes on +//! `RUBYLIB` and `RUBYOPT` gets the bare feature name (`-ropenidea_hook`). +//! +//! `RUBYOPT` is split at spaces with no quoting, so an absolute path in it (`-r/…/Application +//! Support/…/hook.rb`, where the IDE keeps its data) makes Ruby fail with `invalid switch in RUBYOPT`. +//! `RUBYLIB` is a path list and takes spaces. +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Makes `feature` (a file `/.rb`) load first in every Ruby the process starts. +/// `inherit`: the process also sees the IDE's own environment, whose `RUBYLIB` / `RUBYOPT` stay. +pub fn add(env: &mut BTreeMap, dir: &Path, feature: &str, inherit: bool) { + let mut inherited = if inherit { crate::system_env() } else { BTreeMap::new() }; + let mut existing = |name: &str| env.get(name).cloned().or_else(|| inherited.remove(name)).filter(|v| !v.is_empty()); + + let mut dirs: Vec = vec![dir.to_path_buf()]; + if let Some(lib) = existing("RUBYLIB") { + for d in std::env::split_paths(&lib) { + if !dirs.contains(&d) { + dirs.push(d); + } + } + } + let rubyopt = existing("RUBYOPT"); + if let Ok(joined) = std::env::join_paths(dirs) { + env.insert("RUBYLIB".into(), joined.to_string_lossy().into_owned()); + } + let option = format!("-r{feature}"); + let value = match rubyopt { + Some(e) if e.split_whitespace().any(|o| o == option) => e, + Some(e) => format!("{e} {option}"), + None => option, + }; + env.insert("RUBYOPT".into(), value); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_directory_with_spaces_goes_on_rubylib_not_into_rubyopt() { + let dir = Path::new("/Users/me/Library/Application Support/ide/ruby"); + let mut env = BTreeMap::new(); + add(&mut env, dir, "openidea_hook", false); + assert_eq!(env["RUBYLIB"], dir.to_string_lossy()); + assert_eq!(env["RUBYOPT"], "-ropenidea_hook"); + assert!(!env["RUBYOPT"].contains(' ')); + } + + #[test] + fn keeps_existing_settings_and_adds_once() { + // Path lists use the platform's separator (`;` on Windows). + let list = |dirs: &[&str]| std::env::join_paths(dirs).unwrap().to_string_lossy().into_owned(); + let mut env = BTreeMap::from([("RUBYLIB".into(), list(&["/a", "/b"])), ("RUBYOPT".into(), "-w".into())]); + add(&mut env, Path::new("/hook"), "h", false); + add(&mut env, Path::new("/hook"), "h", false); + assert_eq!(env["RUBYLIB"], list(&["/hook", "/a", "/b"])); + assert_eq!(env["RUBYOPT"], "-w -rh"); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index 39dfd584..ffe15451 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2426,6 +2426,8 @@ export type CoverageReportSpec = { kind: "lcov"; runner: string; path: IpcPath; base: IpcPath } | /** `go test -coverprofile`; import paths under `module` map to `module_dir`. */ { kind: "goProfile"; path: IpcPath; module: string; moduleDir: IpcPath } | +/** SimpleCov's `.resultset.json`; relative paths resolve against `base`. */ +{ kind: "simpleCov"; path: IpcPath; base: IpcPath } | /** Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`. */ { kind: "jacoco"; dir: IpcPath } | /** The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o `). */ diff --git a/src/features/coverage/coverage.test.ts b/src/features/coverage/coverage.test.ts index 22257cc2..dff972c7 100644 --- a/src/features/coverage/coverage.test.ts +++ b/src/features/coverage/coverage.test.ts @@ -57,6 +57,9 @@ describe("coverageSupports", () => { expect(coverageSupports(cfg("pnpm", ["run", "dev"]))).toBe(false); expect(coverageSupports(cfg("/v/bin/python3", ["-m", "pytest"]))).toBe(true); expect(coverageSupports(cfg("uv", ["run", "pytest"]))).toBe(true); + expect(coverageSupports(cfg("/v/bin/ruby", ["-Ilib", "test/a_test.rb"]))).toBe(true); + expect(coverageSupports(cfg("bundle", ["exec", "rspec"], { isTest: true }))).toBe(true); + expect(coverageSupports(cfg("bundle", ["exec", "rake", "db:migrate"]))).toBe(false); expect(coverageSupports(cfg("make", ["test"]))).toBe(false); }); }); diff --git a/src/features/coverage/model.ts b/src/features/coverage/model.ts index e36d8cea..472537ed 100644 --- a/src/features/coverage/model.ts +++ b/src/features/coverage/model.ts @@ -16,7 +16,7 @@ const runsJvmTests = (args: readonly string[]) => /** * Whether a coverage runner is known for `config` (cheap; the backend decides for real): * cargo test / run / nextest, go test, Maven / Gradle tests, package.json test scripts, - * Node.js programs and Python. + * Node.js programs, Python and Ruby (scripts, rspec, test rake tasks). */ export function coverageSupports(config: Pick): boolean { const program = programName(config.command); @@ -43,6 +43,13 @@ export function coverageSupports(config: Pick { filters: [ { name: "Coverage reports (lcov, JaCoCo XML, Go cover profile)", - extensions: ["info", "lcov", "xml", "out", "txt", "cov", "coverprofile"], + extensions: ["info", "lcov", "xml", "out", "txt", "cov", "coverprofile", "json"], }, { name: "All files", extensions: ["*"] }, ],