дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
410 рядки
17 KiB
Rust
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 { .. }
|
|
));
|
|
}
|
|
}
|