Файли
openidea/crates/ide-coverage/src/plan.rs
T
l0ng-ai 1e00ea5ee2 feat: Third pass aligning core experience with IntelliJ IDEA
Project & files
- Project view modes (Project Files / Open Files / All Changed Files /
  Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go,
  Maven/Gradle jars; read-only jar/zip browsing
- ⌘N New popup with type search and more file kinds; New Project, Rename
  Project, Reload All from Disk, Export/Import Settings, Power Save Mode
- File type associations, extra content roots and exclude patterns (indexed
  and watched without reopening the project)

Editor & navigation
- Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline,
  history; Find in Files: Module scope, custom pattern scopes, multiline
- Search Everywhere filters, Assign Shortcut, settings results; named bookmark
  lists, file/folder bookmarks; Structure filters and Show Inherited; Recent
  Locations timestamps and breadcrumbs; Code Vision author
- Completion options, editor tab placement/layout, inline completion (LSP 3.18)
- Spell checking (Typo inspection, ide-spell) and Inspections settings with
  custom profiles; Code Style schemes, formatter markers, Do not format

Run, VCS, services, database
- Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker,
  Compound), Modify options, Before launch, env dialog, Current File,
  Emulate terminal (PTY)
- Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the
  graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees
- Generic Services host (Docker connections/registries, run sessions); TODO
  scope/changes tabs with changelists; Coverage report import
- Data sources: wire-compatible vendors, URL import, SSL certs, Options,
  Schemas and Advanced tabs, color and comment

Workbench
- Run Anything, Customize Main Toolbar, named tool window layouts, menu
  completeness, Code Cleanup, macros, "Don't Show Again", editor banners
- Chinese translations for the new UI

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-25 21:24:00 +08:00

410 рядки
17 KiB
Rust

//! Which coverage tool runs a configuration, and how its command line changes.
use crate::{CoveragePrepared, CoverageReportSpec, InstallCommand};
use ide_run::RunConfig;
use ide_run::wrap::{self, program_name};
use std::path::Path;
const GRADLE_INIT: &str = r#"// Written by OpenIDEA for Run with Coverage.
allprojects {
plugins.withId('java') {
apply plugin: 'jacoco'
tasks.withType(Test).configureEach { t -> t.finalizedBy(tasks.named('jacocoTestReport')) }
tasks.named('jacocoTestReport') { r -> r.reports { xml.required.set(true) } }
}
}
"#;
fn s(v: &str) -> String {
v.to_owned()
}
fn path(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
fn unsupported(reason: impl Into<String>) -> CoveragePrepared {
CoveragePrepared::Unsupported { reason: reason.into() }
}
fn missing(tool: &str, message: impl Into<String>, install: Option<(Vec<String>, &Path)>) -> CoveragePrepared {
CoveragePrepared::MissingTool {
tool: tool.to_owned(),
message: message.into(),
install: install.and_then(|(argv, cwd)| {
let (command, args) = argv.split_first()?;
Some(InstallCommand { command: command.clone(), args: args.to_vec(), cwd: Some(cwd.to_path_buf().into()) })
}),
}
}
fn ready(
runner: &str,
config: &RunConfig,
command: String,
args: Vec<String>,
report: CoverageReportSpec,
) -> CoveragePrepared {
CoveragePrepared::Ready {
runner: runner.to_owned(),
launch: Box::new(RunConfig { command, args, ..config.clone() }),
report,
}
}
/// How to run `config` with coverage. `out` is an empty directory for the run's
/// data; it must exist (the Gradle init script is written there). Probes tools,
/// so call it from a blocking thread.
pub fn prepare(root: &Path, config: &RunConfig, out: &Path) -> CoveragePrepared {
let dir = wrap::run_dir(root, config);
let program = program_name(&config.command);
let first = config.args.first().map(String::as_str).unwrap_or_default();
match program.as_str() {
"cargo" => cargo(config, &dir, out),
"go" if first == "test" => go(config, &dir, out),
"go" => unsupported("Go coverage is collected by `go test`; run a test configuration with coverage."),
"mvn" | "mvnw" => maven(config, &dir),
"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),
"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.",
),
_ => match wrap::python_command(config) {
Some(py) => python(config, &dir, out, py),
None => unsupported(format!("No coverage runner is known for '{}'.", config.name)),
},
}
}
fn cargo(config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
// `cargo +nightly test …`: the toolchain stays in front.
let toolchain: Vec<String> = config.args.iter().take_while(|a| a.starts_with('+')).cloned().collect();
let rest = &config.args[toolchain.len()..];
let Some(sub) = rest.first().filter(|s| matches!(s.as_str(), "test" | "run" | "nextest")) else {
return unsupported("Coverage runs `cargo test`, `cargo run` or `cargo nextest` configurations.");
};
let probe: Vec<String> = std::iter::once(config.command.clone())
.chain(toolchain.iter().cloned())
.chain([s("llvm-cov"), s("--version")])
.collect();
if !wrap::probe(&probe, dir, config) {
return missing(
"cargo-llvm-cov",
"Rust coverage needs cargo-llvm-cov (and the llvm-tools-preview rustup component).",
Some((vec![s("cargo"), s("install"), s("cargo-llvm-cov")], dir)),
);
}
let lcov = out.join("lcov.info");
let mut args = toolchain;
args.extend([s("llvm-cov"), sub.clone(), s("--lcov"), s("--output-path"), path(&lcov)]);
args.extend(rest[1..].iter().cloned());
let report =
CoverageReportSpec::Lcov { runner: s("cargo-llvm-cov"), path: lcov.into(), base: dir.to_path_buf().into() };
ready("cargo-llvm-cov", config, config.command.clone(), args, report)
}
fn go(config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
let profile = out.join("cover.out");
let mut args = vec![s("test"), format!("-coverprofile={}", path(&profile))];
args.extend(config.args[1..].iter().cloned());
let (module, module_dir) = wrap::go_module(dir).unwrap_or_else(|| (String::new(), dir.to_path_buf()));
let report = CoverageReportSpec::GoProfile { path: profile.into(), module, module_dir: module_dir.into() };
ready("go test -cover", config, config.command.clone(), args, report)
}
fn runs_tests(args: &[String]) -> bool {
args.iter().any(|a| {
let task = a.rsplit(':').next().unwrap_or(a);
matches!(task, "test" | "verify" | "check" | "integrationTest") || a.starts_with("-Dtest=") || a == "--tests"
})
}
fn maven(config: &RunConfig, dir: &Path) -> CoveragePrepared {
if !runs_tests(&config.args) {
return unsupported("Coverage runs Maven configurations that run tests (`test`, `verify`).");
}
let mut args = vec![s("org.jacoco:jacoco-maven-plugin:prepare-agent")];
args.extend(config.args.iter().cloned());
// Report even when tests fail, and from this run's data only.
args.extend([
s("org.jacoco:jacoco-maven-plugin:report"),
s("-Djacoco.append=false"),
s("-Dmaven.test.failure.ignore=true"),
]);
ready("JaCoCo", config, config.command.clone(), args, CoverageReportSpec::Jacoco { dir: dir.to_path_buf().into() })
}
fn gradle(config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
if !runs_tests(&config.args) {
return unsupported("Coverage runs Gradle configurations that run tests (`test`, `check`).");
}
let script = out.join("openidea-jacoco.init.gradle");
if let Err(e) = std::fs::write(&script, GRADLE_INIT) {
return unsupported(format!("Cannot write the Gradle init script: {e}"));
}
let mut args = vec![s("--init-script"), path(&script)];
args.extend(config.args.iter().cloned());
ready("JaCoCo", config, config.command.clone(), args, CoverageReportSpec::Jacoco { dir: dir.to_path_buf().into() })
}
/// `npm install -D x` / `pnpm add -D x`.
fn add_dev(pm: &str, package: &str) -> Vec<String> {
let verb = if pm == "npm" { "install" } else { "add" };
vec![s(pm), s(verb), s("-D"), s(package)]
}
const SHELL_META: &[char] = &['&', '|', ';', '<', '>', '$', '`', '"', '\'', '(', ')'];
fn npm(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
let pm = program_name(&config.command);
let Some((name, script)) = wrap::npm_script(config, dir) else {
return unsupported("Coverage runs package.json scripts that use vitest or jest.");
};
let tokens: Vec<&str> = script.split_whitespace().collect();
let runner_at = tokens.iter().position(|t| matches!(*t, "vitest" | "jest"));
let Some(runner_at) = runner_at else {
return unsupported(format!("The '{name}' script does not run vitest or jest: {script}"));
};
let runner = tokens[runner_at];
let lcov = out.join("lcov.info");
let coverage_args: Vec<String> = if runner == "vitest" {
if !wrap::node_package("@vitest/coverage-v8", dir, root)
&& !wrap::node_package("@vitest/coverage-istanbul", dir, root)
{
// The provider must match the installed vitest exactly.
let provider = match wrap::node_package_version("vitest", dir, root) {
Some(v) => format!("@vitest/coverage-v8@{v}"),
None => s("@vitest/coverage-v8"),
};
return missing(
"@vitest/coverage-v8",
"Vitest coverage needs a coverage provider package.",
Some((add_dev(&pm, &provider), dir)),
);
}
vec![
s("--coverage.enabled=true"),
s("--coverage.reporter=lcov"),
format!("--coverage.reportsDirectory={}", path(out)),
]
} else {
vec![s("--coverage"), s("--coverageReporters=lcov"), format!("--coverageDirectory={}", path(out))]
};
let report =
CoverageReportSpec::Lcov { runner: runner.to_owned(), path: lcov.into(), base: dir.to_path_buf().into() };
// A plain `vitest run …` script runs the runner directly, so its arguments need no `--` games.
let simple =
!script.contains(SHELL_META) && tokens[..runner_at].iter().all(|t| matches!(*t, "npx" | "pnpm" | "exec"));
if simple && let Some(bin) = wrap::node_bin(runner, dir, root) {
let mut args: Vec<String> = tokens[runner_at + 1..].iter().map(|t| s(t)).collect();
args.extend(wrap::passthrough_args(config));
args.extend(coverage_args);
return ready(runner, config, path(&bin), args, report);
}
let mut args = config.args.clone();
if !args.iter().any(|a| a == "--") {
args.push(s("--"));
}
args.extend(coverage_args);
ready(runner, config, config.command.clone(), args, report)
}
fn c8(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
let Some(c8) = wrap::find_tool("c8", config, dir, root) else {
return missing(
"c8",
"Coverage of Node.js programs needs c8.",
Some((vec![s("npm"), s("install"), s("-g"), s("c8")], dir)),
);
};
let mut args = vec![
s("--reporter=lcov"),
format!("--reports-dir={}", path(out)),
format!("--temp-directory={}", path(&out.join("tmp"))),
config.command.clone(),
];
args.extend(config.args.iter().cloned());
let report = CoverageReportSpec::Lcov {
runner: s("c8"),
path: out.join("lcov.info").into(),
base: dir.to_path_buf().into(),
};
ready("c8", config, path(&c8), args, 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.");
}
let mut config = config.clone();
let data = out.join(".coverage");
config.env.insert(s("COVERAGE_FILE"), path(&data));
let with =
|extra: &[&str]| -> Vec<String> { py.interpreter.iter().cloned().chain(extra.iter().map(|e| s(e))).collect() };
if !wrap::probe(&with(&["-m", "coverage", "--version"]), dir, &config) {
let install = match py.manager.as_deref() {
Some("uv") => vec![s("uv"), s("add"), s("--dev"), s("coverage")],
Some("poetry") => vec![s("poetry"), s("add"), s("--group"), s("dev"), s("coverage")],
Some("pdm") => vec![s("pdm"), s("add"), s("-d"), s("coverage")],
_ => with(&["-m", "pip", "install", "coverage"]),
};
return missing(
"coverage",
"Python coverage needs coverage.py in the project's interpreter.",
Some((install, dir)),
);
}
let lcov = out.join("lcov.info");
let mut argv = with(&["-m", "coverage", "run", "--branch"]);
argv.extend(py.target.iter().cloned());
let report = CoverageReportSpec::Exported {
runner: s("coverage.py"),
argv: with(&["-m", "coverage", "lcov", "-o", &path(&lcov)]),
cwd: dir.to_path_buf().into(),
env: config.env.clone(),
lcov: lcov.into(),
};
let (command, args) = argv.split_first().map(|(c, a)| (c.clone(), a.to_vec())).unwrap_or_default();
ready("coverage.py", &config, command, args, report)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn cfg(kind: &str, command: &str, args: &[&str]) -> RunConfig {
RunConfig {
id: "t".into(),
name: "t".into(),
kind: kind.into(),
command: command.into(),
args: args.iter().map(|s| s.to_string()).collect(),
cwd: None,
env: BTreeMap::new(),
detected: true,
is_test: true,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
fn launch(p: &CoveragePrepared) -> (&RunConfig, &CoverageReportSpec) {
match p {
CoveragePrepared::Ready { launch, report, .. } => (launch, report),
other => panic!("not ready: {other:?}"),
}
}
#[test]
fn go_test_gets_a_cover_profile() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("go.mod"), "module example.com/m\n").unwrap();
let out = dir.path().join("out");
let p = prepare(dir.path(), &cfg("go", "go", &["test", "./...", "-run", "X"]), &out);
let (l, report) = launch(&p);
assert_eq!(l.args[0], "test");
assert!(l.args[1].starts_with("-coverprofile=") && l.args[1].ends_with("cover.out"));
assert_eq!(&l.args[2..], ["./...", "-run", "X"]);
assert!(matches!(report, CoverageReportSpec::GoProfile { module, .. } if module == "example.com/m"));
assert!(matches!(
prepare(dir.path(), &cfg("go", "go", &["run", "."]), &out),
CoveragePrepared::Unsupported { .. }
));
}
#[test]
fn maven_and_gradle_use_jacoco() {
let dir = tempfile::tempdir().unwrap();
let p = prepare(dir.path(), &cfg("maven", "./mvnw", &["test", "-Dtest=CalcTest"]), dir.path());
let (l, _) = launch(&p);
assert_eq!(l.args[0], "org.jacoco:jacoco-maven-plugin:prepare-agent");
assert!(l.args.contains(&"org.jacoco:jacoco-maven-plugin:report".to_owned()));
assert!(matches!(
prepare(dir.path(), &cfg("maven", "mvn", &["package"]), dir.path()),
CoveragePrepared::Unsupported { .. }
));
let p = prepare(dir.path(), &cfg("gradle", "./gradlew", &[":app:test", "--tests", "*Calc"]), dir.path());
let (l, report) = launch(&p);
assert_eq!(l.args[0], "--init-script");
assert!(std::fs::read_to_string(&l.args[1]).unwrap().contains("jacoco"));
assert!(matches!(report, CoverageReportSpec::Jacoco { .. }));
}
#[test]
fn vitest_scripts_run_the_runner_with_coverage() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"vitest run","t2":"tsc && jest"}}"#).unwrap();
let config = cfg("npm", "pnpm", &["run", "test", "--", "a.test.ts"]);
// No provider package: install hint.
match prepare(root, &config, root) {
CoveragePrepared::MissingTool { install: Some(i), .. } => {
assert_eq!(i.args, ["add", "-D", "@vitest/coverage-v8"])
}
other => panic!("{other:?}"),
}
// ... pinned to the installed vitest.
let vitest = root.join("node_modules/vitest");
std::fs::create_dir_all(&vitest).unwrap();
std::fs::write(vitest.join("package.json"), r#"{"version":"2.1.9"}"#).unwrap();
match prepare(root, &config, root) {
CoveragePrepared::MissingTool { install: Some(i), .. } => {
assert_eq!(i.args, ["add", "-D", "@vitest/[email protected]"])
}
other => panic!("{other:?}"),
}
let pkg = root.join("node_modules/@vitest/coverage-v8");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(pkg.join("package.json"), "{}").unwrap();
let bin = root.join("node_modules/.bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join(if cfg!(windows) { "vitest.cmd" } else { "vitest" }), "").unwrap();
let p = prepare(root, &config, root);
let (l, _) = launch(&p);
assert!(l.command.contains("node_modules"));
assert_eq!(&l.args[..2], ["run", "a.test.ts"]);
assert!(l.args.contains(&"--coverage.reporter=lcov".to_owned()));
// A compound script: arguments go after `--`.
let p = prepare(root, &cfg("npm", "npm", &["run", "t2"]), root);
let (l, _) = launch(&p);
assert_eq!(l.command, "npm");
assert_eq!(&l.args[..3], ["run", "t2", "--"]);
assert!(l.args.contains(&"--coverage".to_owned()));
}
#[test]
fn cargo_without_llvm_cov_suggests_installing_it() {
let dir = tempfile::tempdir().unwrap();
let mut config = cfg("cargo", "cargo", &["test"]);
// A search path without cargo: the probe fails.
config.env.insert("PATH".into(), dir.path().to_string_lossy().into_owned());
config.command = dir.path().join("cargo").to_string_lossy().into_owned();
match prepare(dir.path(), &config, dir.path()) {
CoveragePrepared::MissingTool { tool, install: Some(i), .. } => {
assert_eq!(tool, "cargo-llvm-cov");
assert_eq!(i.args, ["install", "cargo-llvm-cov"]);
}
other => panic!("{other:?}"),
}
assert!(matches!(
prepare(dir.path(), &cfg("cargo", "cargo", &["build"]), dir.path()),
CoveragePrepared::Unsupported { .. }
));
}
#[test]
fn unknown_commands_are_unsupported() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(
prepare(dir.path(), &cfg("make", "make", &["all"]), dir.path()),
CoveragePrepared::Unsupported { .. }
));
}
}