diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec437a4..274fe3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} run: | - for c in testless-core testless-lang-ts testless-lang-go testless-lang-rust testless; do + for c in testless-core testless-lang-ts testless-lang-go testless-lang-rust testless-lang-java testless; do set +e out=$(cargo publish -p "$c" --no-verify 2>&1) status=$? diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0417422..2c88a9b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,5 +3,6 @@ "crates/lang-ts": "0.6.0", "crates/lang-go": "0.6.0", "crates/lang-rust": "0.6.0", + "crates/lang-java": "0.6.0", "crates/cli": "0.6.0" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc150fd..4d10520 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,18 +26,28 @@ All three (test, clippy, fmt) must pass clean before a PR is opened. ## Adding a language A language plugin implements the `Language` trait (`crates/core/src/language.rs`). -Five things per language, everything else is shared: +Six things per language, everything else is shared: 1. **Grammar**: the tree-sitter `Language` for the file's extension(s). 2. **Extraction queries**: walk the tree and emit `ExtractedDef`s (functions, methods, tests) plus `ImportRef`s. 3. **Import resolution**: turn a raw import specifier into a repo-relative - path, or `None` if it's external/unresolvable. + path, or `None` if it's external/unresolvable. Returning a *directory* + fans out to every indexed file under it (Go packages, Java wildcards). 4. **Test-ID construction**: build the dotted/segmented ID a test runner would recognize (including subtests, e.g. Go's `t.Run` chains). -5. **Over-approximation triggers**: the specific shapes in this language +5. **Package scope** (`package_key`): return a key when files sharing it see + each other with no import statement. Default `None` means file-scoped + (TS, Rust). Go keys on the directory; Java keys on the package with the + source root stripped, so `src/main/java/com/foo` and + `src/test/java/com/foo` are one scope. +6. **Over-approximation triggers**: the specific shapes in this language that are ambiguous enough to widen selection rather than guess narrowly. +If the language has more than one test runner (Java's Maven vs Gradle), the +mapping lives in `crates/cli/src/runner.rs`, not in the plugin: it depends on +build files, not on syntax. + `crates/lang-go/src/lib.rs` is the smallest reference implementation, read it before starting a new one. diff --git a/Cargo.lock b/Cargo.lock index ffa4389..5f2152f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -638,6 +638,7 @@ dependencies = [ "tempfile", "testless-core", "testless-lang-go", + "testless-lang-java", "testless-lang-rust", "testless-lang-ts", "tree-sitter", @@ -668,6 +669,15 @@ dependencies = [ "tree-sitter-go", ] +[[package]] +name = "testless-lang-java" +version = "0.6.0" +dependencies = [ + "testless-core", + "tree-sitter", + "tree-sitter-java", +] + [[package]] name = "testless-lang-rust" version = "0.6.0" @@ -749,6 +759,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 053975d..78268cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,13 @@ [workspace] resolver = "2" -members = ["crates/core", "crates/lang-ts", "crates/lang-go", "crates/lang-rust", "crates/cli"] +members = [ + "crates/core", + "crates/lang-ts", + "crates/lang-go", + "crates/lang-rust", + "crates/lang-java", + "crates/cli", +] [workspace.package] edition = "2021" diff --git a/README.md b/README.md index 5046ec1..b4ddac1 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Prebuilt binaries on [Releases](https://github.com/itaywol/testless/releases). N ```bash testless index # build the graph (.testless/) testless select --from origin/main # tests impacted by your changes -testless select --from origin/main --format args # runnable vitest / go test / cargo test lines +testless select --from origin/main --format args # runnable vitest / go test / cargo / gradle lines ``` JSON when piped, human text on a TTY. On fallback-to-everything, `--format args` @@ -71,17 +71,27 @@ Optional `testless.toml` at the repo root, for the cases static inference can't ```toml always-run = ["tests/smoke/**", "**/*.e2e.test.ts"] # always select these tests ignore = ["**/generated/**", "*.pb.go"] # never index these files +java-runner = "gradle" # force maven|gradle, else sniffed ``` ## Languages -TypeScript / JavaScript (vitest, jest `-t` patterns) · Go (`go test -run`) · Rust (`cargo test`) +| Language | Tests it reads | Commands it prints | +|---|---|---| +| TypeScript / JavaScript | vitest, jest | `vitest run -t ` | +| Go | `testing`, `t.Run` subtests | `go test ./pkg -run '^Test$/^sub$'` | +| Rust | `#[test]`, module chains | `cargo test path::name -- --exact` | +| Java | JUnit 5 (`@Test`, `@ParameterizedTest`, `@Nested`) | `gradle test --tests C.m` / `mvn test -Dtest='C#m'` | + +Java picks Maven or Gradle from the nearest `pom.xml` / `build.gradle`, per +module; override with `java-runner` in `testless.toml`. Multi-module builds are +scoped automatically (`-pl `, `:module:test`). A new language is roughly one plugin file: see [CONTRIBUTING](CONTRIBUTING.md). ## Status -Young project. The selection engine works end to end on all three languages; +Young project. The selection engine works end to end on all four languages; precision improves release by release. Roadmap lives in [issues](https://github.com/itaywol/testless/issues). diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index d8d3493..1cad273 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -23,6 +23,7 @@ testless-core = { path = "../core", version = "0.6.0" } testless-lang-ts = { path = "../lang-ts", version = "0.6.0" } testless-lang-go = { path = "../lang-go", version = "0.6.0" } testless-lang-rust = { path = "../lang-rust", version = "0.6.0" } +testless-lang-java = { path = "../lang-java", version = "0.6.0" } clap = { version = "4.6.4", features = ["derive"] } anyhow = "1.0.104" serde_json = "1.0.151" diff --git a/crates/cli/src/format.rs b/crates/cli/src/format.rs index cbaca11..2b24a22 100644 --- a/crates/cli/src/format.rs +++ b/crates/cli/src/format.rs @@ -1,7 +1,7 @@ //! `--format args`: render selected tests as ready-to-run test-runner -//! command lines (vitest / `go test` / `cargo test`). Pure string -//! generation: this module never spawns a process, it only builds the -//! strings a human or CI job would paste into a shell. +//! command lines (vitest / `go test` / `cargo test` / `mvn` / `gradle`). +//! Pure string generation: this module never spawns a process, it only +//! builds the strings a human or CI job would paste into a shell. //! //! One line per selected test (v1 keeps it simple: no multi-test `-t` //! grouping), deduplicated (a `computed` entry drops its exactness flag, @@ -107,10 +107,81 @@ fn cargo_line(t: &SelectedTest) -> String { } } +/// Split a Java `test_id` chain into its JVM class name and (when the +/// chain names one) its method. The chain is `[fqcn, nested…, method]`: +/// `["com.foo.OuterTest", "Inner", "adds"]` yields +/// `("com.foo.OuterTest$Inner", Some("adds"))`, matching how the JVM names +/// a JUnit 5 `@Nested` class. +/// +/// A single-segment chain (`["com.foo.BarTest"]`, a class-level TestNG +/// `@Test`) has no method to filter on, so the whole class runs. +fn java_class_and_method(name: &[String]) -> (String, Option<&str>) { + match name.split_last() { + None => (String::new(), None), + Some((_, [])) => (name.join("$"), None), + Some((method, classes)) => (classes.join("$"), Some(method.as_str())), + } +} + +/// `mvn test -pl -Dtest='com.foo.BarTest#adds' -DfailIfNoTests=false`. +/// +/// `-pl` is emitted only for a test inside a build module below the repo +/// root; a single-module repo gets the plain reactor. `-DfailIfNoTests=false` +/// is not optional: without it Surefire *fails* every module in a +/// multi-module reactor whose tests don't match `-Dtest`, which is every +/// module but one for a narrowed selection. +/// +/// `computed` (a `@ParameterizedTest` / `@TestFactory`, whose per-invocation +/// display names aren't statically knowable) drops the `#method` filter and +/// widens to the whole class rather than printing a pattern that would +/// under-select. +fn maven_line(t: &SelectedTest) -> String { + let (class, method) = java_class_and_method(&t.name); + let selector = match method { + Some(m) if !t.computed => format!("{class}#{m}"), + _ => class, + }; + let scope = match &t.module { + Some(m) => format!(" -pl {}", sh_quote(&m.display().to_string())), + None => String::new(), + }; + format!( + "mvn test{scope} -Dtest={} -DfailIfNoTests=false", + sh_quote(&selector) + ) +} + +/// `gradle :services:billing:test --tests 'com.foo.BarTest.adds'`. +/// +/// The task path is scoped to the test's own build module, so sibling +/// projects in a multi-project build aren't asked to match a filter no test +/// of theirs can satisfy (Gradle errors out on that, rather than skipping). +/// A repo-root module gets the bare `test` task. +/// +/// `computed` widens to the whole class, same rationale as [`maven_line`]. +fn gradle_line(t: &SelectedTest) -> String { + let (class, method) = java_class_and_method(&t.name); + let pattern = match method { + Some(m) if !t.computed => format!("{class}.{m}"), + _ => class, + }; + let task = match &t.module { + Some(m) => format!( + ":{}:test", + m.components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join(":") + ), + None => "test".to_string(), + }; + format!("gradle {} --tests {}", sh_quote(&task), sh_quote(&pattern)) +} + /// Render `tests` as one runner-consumable command line each, deduplicated /// and sorted for a deterministic, script-friendly stdout stream. A /// `runner` this module doesn't recognize (only `"unknown"` today, see -/// `runner_for_lang`) is silently skipped: there's no sensible command to +/// `crate::runner::runner_for`) is silently skipped: there's no sensible command to /// print for it, and `select`'s `json`/`text` formats already surface the /// `"unknown"` label for inspection. pub fn command_lines(tests: &[SelectedTest]) -> Vec { @@ -120,6 +191,8 @@ pub fn command_lines(tests: &[SelectedTest]) -> Vec { "vitest" => Some(vitest_line(t)), "gotest" => Some(gotest_line(t)), "cargo" => Some(cargo_line(t)), + "maven" => Some(maven_line(t)), + "gradle" => Some(gradle_line(t)), _ => None, }) .collect(); @@ -139,6 +212,7 @@ mod tests { name: name.iter().map(|s| s.to_string()).collect(), runner, lang: "irrelevant".to_string(), + module: crate::runner::module_dir(std::path::Path::new(file)), computed, } } @@ -230,6 +304,125 @@ mod tests { assert_eq!(command_lines(&[t]), vec!["cargo test math::add_works"]); } + #[test] + fn maven_single_module() { + let t = test( + "src/test/java/com/foo/BarTest.java", + &["com.foo.BarTest", "addsNegatives"], + "maven", + false, + ); + assert_eq!( + command_lines(&[t]), + vec!["mvn test -Dtest='com.foo.BarTest#addsNegatives' -DfailIfNoTests=false"] + ); + } + + #[test] + fn maven_multi_module_scopes_with_pl() { + let t = test( + "services/billing/src/test/java/com/foo/BarTest.java", + &["com.foo.BarTest", "addsNegatives"], + "maven", + false, + ); + assert_eq!( + command_lines(&[t]), + vec![ + "mvn test -pl services/billing -Dtest='com.foo.BarTest#addsNegatives' -DfailIfNoTests=false" + ] + ); + } + + // A JUnit 5 `@Nested` class is `Outer$Inner` on the JVM, so the chain's + // middle segments join with `$`, not `.`. + #[test] + fn maven_nested_class_uses_dollar() { + let t = test( + "src/test/java/com/foo/OuterTest.java", + &["com.foo.OuterTest", "WhenEmpty", "throws"], + "maven", + false, + ); + assert_eq!( + command_lines(&[t]), + vec!["mvn test -Dtest='com.foo.OuterTest$WhenEmpty#throws' -DfailIfNoTests=false"] + ); + } + + #[test] + fn maven_computed_widens_to_class() { + let t = test( + "src/test/java/com/foo/BarTest.java", + &["com.foo.BarTest", "addsNegatives"], + "maven", + true, + ); + assert_eq!( + command_lines(&[t]), + vec!["mvn test -Dtest=com.foo.BarTest -DfailIfNoTests=false"] + ); + } + + #[test] + fn gradle_single_module() { + let t = test( + "src/test/java/com/foo/BarTest.java", + &["com.foo.BarTest", "addsNegatives"], + "gradle", + false, + ); + assert_eq!( + command_lines(&[t]), + vec!["gradle test --tests com.foo.BarTest.addsNegatives"] + ); + } + + #[test] + fn gradle_multi_module_scopes_task_path() { + let t = test( + "services/billing/src/test/java/com/foo/BarTest.java", + &["com.foo.BarTest", "addsNegatives"], + "gradle", + false, + ); + assert_eq!( + command_lines(&[t]), + vec!["gradle :services:billing:test --tests com.foo.BarTest.addsNegatives"] + ); + } + + #[test] + fn gradle_computed_widens_to_class() { + let t = test( + "src/test/java/com/foo/BarTest.java", + &["com.foo.BarTest", "addsNegatives"], + "gradle", + true, + ); + assert_eq!( + command_lines(&[t]), + vec!["gradle test --tests com.foo.BarTest"] + ); + } + + /// A class-level chain (TestNG's class-scoped `@Test`) has no method to + /// filter on and must run the whole class rather than treating the + /// class name as a method name. + #[test] + fn java_class_only_chain_has_no_method_filter() { + let t = test( + "src/test/java/com/foo/BarTest.java", + &["com.foo.BarTest"], + "gradle", + false, + ); + assert_eq!( + command_lines(&[t]), + vec!["gradle test --tests com.foo.BarTest"] + ); + } + // Test names are free-form strings: quotes, dollar signs, backticks, // backslashes and apostrophes all legitimately appear in `it(...)` / // `t.Run(...)` names. `sh_quote` must render them so a shell (or the diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 9b9958f..e070392 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -5,6 +5,7 @@ use anyhow::{Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; mod format; +mod runner; use testless_core::cache::{Cache, CachedExtraction}; use testless_core::classify::{classify, ChangeMode, SeedKind}; @@ -110,6 +111,7 @@ fn registry() -> Registry { Box::new(testless_lang_ts::TsLanguage), Box::new(testless_lang_go::GoLanguage), Box::new(testless_lang_rust::RustLanguage), + Box::new(testless_lang_java::JavaLanguage::default()), ]) } @@ -486,19 +488,6 @@ fn selected_test_defs( Ok(selected.into_iter().collect()) } -/// The test-runner label for a def's file language, per the `select` wire -/// contract: `ts` -> `vitest`, `go` -> `gotest`, `rust` -> `cargo`. Any -/// other/future registered language degrades to `"unknown"` rather than -/// erroring: a missing runner mapping shouldn't crash test selection. -fn runner_for_lang(lang: &str) -> &'static str { - match lang { - "ts" => "vitest", - "go" => "gotest", - "rust" => "cargo", - _ => "unknown", - } -} - /// One selected test, ready to render in either `select` output format. struct SelectedTest { file: std::path::PathBuf, @@ -508,6 +497,11 @@ struct SelectedTest { name: Vec, runner: &'static str, lang: String, + /// The repo-relative build-module directory this test lives in, for the + /// runners whose commands must be scoped to one (`mvn -pl`, `gradle + /// :mod:test`). `None` for a single-module repo and for every + /// module-less language. See `runner::module_dir`. + module: Option, /// Mirrors `Def::computed_name`: set when any segment of `name` was /// truncated because a later segment couldn't be statically resolved /// (e.g. a template-literal test title); consumers should widen their @@ -558,19 +552,31 @@ fn cmd_select(from: String, to: Option, format: Option) -> Resul let total_known = count_tests(&graph); let seed_count = seeds.len(); let test_defs = selected_test_defs(&graph, &seeds, &config)?; + // Runner sniffing reads build files (`pom.xml`, `build.gradle`), so it + // must look at the user's real working tree: with `--to ` the + // indexed root is a temp worktree that's already unwound by now, and + // the commands printed are meant to be run here regardless. + let cwd = std::env::current_dir().context("getting current directory")?; let tests: Vec = test_defs .into_iter() .map(|id| { let def = graph.def(id); let file = &graph.files[def.file.0 as usize]; + let module = runner::module_dir(&file.path); SelectedTest { file: file.path.clone(), name: def .test_id .clone() .unwrap_or_else(|| vec![def.name.clone()]), - runner: runner_for_lang(&file.lang), + runner: runner::runner_for( + &file.lang, + module.as_deref(), + &cwd, + config.java_runner.as_deref(), + ), lang: file.lang.clone(), + module, computed: def.computed_name, } }) diff --git a/crates/cli/src/runner.rs b/crates/cli/src/runner.rs new file mode 100644 index 0000000..3b09c47 --- /dev/null +++ b/crates/cli/src/runner.rs @@ -0,0 +1,175 @@ +//! Which test runner a selected test should be handed to, and which build +//! module it lives in. +//! +//! For TS/Go/Rust the runner falls straight out of the language id: one +//! language, one canonical runner. Java breaks that assumption — the same +//! `.java` file is driven by Maven or by Gradle depending on which build +//! file sits above it — so the mapping takes the test's path and the repo +//! root as well, and `testless.toml`'s `java-runner` can override the +//! sniffing outright. +//! +//! Module detection is pure path arithmetic against the standard +//! Maven/Gradle source layout (`/src/{main,test}/java/...`), never a +//! filesystem walk: `select --to ` indexes a throwaway worktree that is +//! already gone by the time commands are rendered, and the commands are +//! meant to run in the user's real working tree anyway. Only the final +//! maven-vs-gradle question touches the disk, and it deliberately touches +//! the *repo* the user will run in. + +use std::path::{Path, PathBuf}; + +/// The build-module directory a Java source file belongs to: the path +/// prefix sitting above a `src//java` segment triple, as laid +/// out by both Maven and Gradle by convention. +/// +/// `services/billing/src/test/java/com/foo/BarTest.java` yields +/// `Some("services/billing")`; a single-module repo's +/// `src/test/java/com/foo/BarTest.java` yields `None` (the module *is* the +/// repo root, so there's nothing to scope a command to). A file that isn't +/// under a conventional source root at all also yields `None` — the +/// non-conventional layouts this misses degrade to repo-root-scoped +/// commands, which still run the test, just with a wider build. +pub fn module_dir(file: &Path) -> Option { + let parts: Vec<_> = file.components().collect(); + // Scan for the `src//java` triple. Searching from the *end* + // matters: a module legitimately named `src` (or a repo path containing + // one) would otherwise truncate at the wrong segment. + let idx = (0..parts.len().saturating_sub(2)) + .rev() + .find(|&i| parts[i].as_os_str() == "src" && parts[i + 2].as_os_str() == "java")?; + if idx == 0 { + return None; + } + Some(parts[..idx].iter().collect()) +} + +/// Whether `dir` (relative to `repo`) holds a Maven or a Gradle build file. +fn build_tool_at(repo: &Path, dir: &Path) -> Option<&'static str> { + let full = repo.join(dir); + if full.join("pom.xml").is_file() { + return Some("maven"); + } + if full.join("build.gradle").is_file() || full.join("build.gradle.kts").is_file() { + return Some("gradle"); + } + None +} + +/// The runner label for a def's language, per the `select` wire contract: +/// `ts` -> `vitest`, `go` -> `gotest`, `rust` -> `cargo`, and for `java` +/// either `maven` or `gradle`. +/// +/// Java resolution order: an explicit `java-runner` in `testless.toml` +/// wins; otherwise the test's own module directory is sniffed for a build +/// file, then the repo root. A Java repo with neither (or an unrecognized +/// `java-runner` value) degrades to `"unknown"`, exactly like any +/// unregistered language: `select`'s json/text formats still name the test, +/// `--format args` just has no command it can honestly print. +pub fn runner_for( + lang: &str, + module: Option<&Path>, + repo: &Path, + java_override: Option<&str>, +) -> &'static str { + match lang { + "ts" => "vitest", + "go" => "gotest", + "rust" => "cargo", + "java" => { + match java_override { + Some("maven") => return "maven", + Some("gradle") => return "gradle", + _ => {} + } + module + .and_then(|m| build_tool_at(repo, m)) + .or_else(|| build_tool_at(repo, Path::new(""))) + .unwrap_or("unknown") + } + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn module_dir_finds_multi_module_prefix() { + assert_eq!( + module_dir(Path::new( + "services/billing/src/test/java/com/foo/BarTest.java" + )), + Some(PathBuf::from("services/billing")) + ); + } + + #[test] + fn module_dir_is_none_at_repo_root() { + assert_eq!( + module_dir(Path::new("src/test/java/com/foo/BarTest.java")), + None + ); + assert_eq!( + module_dir(Path::new("src/main/java/com/foo/Bar.java")), + None + ); + } + + #[test] + fn module_dir_is_none_for_unconventional_layout() { + assert_eq!(module_dir(Path::new("java/com/foo/BarTest.java")), None); + assert_eq!(module_dir(Path::new("BarTest.java")), None); + } + + #[test] + fn module_dir_prefers_the_last_src_triple() { + // A module directory that is itself named `src` must not truncate + // the prefix at the wrong segment. + assert_eq!( + module_dir(Path::new("src/legacy/src/test/java/com/foo/BarTest.java")), + Some(PathBuf::from("src/legacy")) + ); + } + + #[test] + fn non_java_langs_ignore_path_and_repo() { + let repo = Path::new("/nonexistent"); + assert_eq!(runner_for("ts", None, repo, None), "vitest"); + assert_eq!(runner_for("go", None, repo, None), "gotest"); + assert_eq!(runner_for("rust", None, repo, None), "cargo"); + assert_eq!(runner_for("cobol", None, repo, None), "unknown"); + } + + #[test] + fn java_override_wins_over_sniffing() { + let repo = Path::new("/nonexistent"); + assert_eq!(runner_for("java", None, repo, Some("maven")), "maven"); + assert_eq!(runner_for("java", None, repo, Some("gradle")), "gradle"); + // An unrecognized value falls through to sniffing rather than + // silently pretending to be a runner. + assert_eq!(runner_for("java", None, repo, Some("bazel")), "unknown"); + } + + #[test] + fn java_sniffs_module_then_repo_root() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path(); + std::fs::create_dir_all(repo.join("services/billing")).unwrap(); + std::fs::write(repo.join("build.gradle"), "").unwrap(); + + // Nothing in the module yet: falls back to the root's gradle build. + let module = Path::new("services/billing"); + assert_eq!(runner_for("java", Some(module), repo, None), "gradle"); + + // A module-local pom.xml wins over the root. + std::fs::write(repo.join("services/billing/pom.xml"), "").unwrap(); + assert_eq!(runner_for("java", Some(module), repo, None), "maven"); + } + + #[test] + fn java_with_no_build_file_is_unknown() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(runner_for("java", None, tmp.path(), None), "unknown"); + } +} diff --git a/crates/cli/tests/select.rs b/crates/cli/tests/select.rs index 3dff2ac..202ed2d 100644 --- a/crates/cli/tests/select.rs +++ b/crates/cli/tests/select.rs @@ -1198,3 +1198,279 @@ fn module_init_edit_selects_all_transitive_importer_tests() { "expected exactly 3 selected tests, got {tests:?}" ); } + +// --- Java ----------------------------------------------------------------- +// +// Gradle single-module layout, three packages: +// - `com.example.calc.Calc`: `add` (called by its own test and, across +// packages, by `Report.summarize`) plus an unrelated `triple`. +// - `com.example.report.Report`: imports and calls `Calc`. +// - `com.example.solo.SoloTest`: touches neither. +// +// `CalcTest` deliberately sits in `src/test/java/com/example/calc` — same +// package as `Calc`, different directory — and references `Calc` with *no +// import statement*, which is how essentially every Java unit test is +// written. Only `Language::package_key` makes that edge exist. + +const J_SETTINGS: &str = "rootProject.name = 'app'\n"; +const J_BUILD: &str = "plugins { id 'java' }\ntest { useJUnitPlatform() }\n"; + +const J_CALC: &str = "\ +package com.example.calc; + +public class Calc { + public int add(int a, int b) { return a + b; } + public int triple(int a) { return a * 3; } +} +"; + +const J_CALC_BODY_EDITED: &str = "\ +package com.example.calc; + +public class Calc { + public int add(int a, int b) { return a + b + 1; } + public int triple(int a) { return a * 3; } +} +"; + +const J_REPORT: &str = "\ +package com.example.report; + +import com.example.calc.Calc; + +public class Report { + private final Calc calc; + public Report(Calc calc) { this.calc = calc; } + public String summarize(int a, int b) { return \"sum=\" + calc.add(a, b); } +} +"; + +const J_CALC_TEST: &str = "\ +package com.example.calc; + +import org.junit.jupiter.api.Test; + +class CalcTest { + @Test + void addsNegatives() { + new Calc().add(-1, -2); + } + + @Test + void triples() { + new Calc().triple(2); + } +} +"; + +const J_REPORT_TEST: &str = "\ +package com.example.report; + +import com.example.calc.Calc; +import org.junit.jupiter.api.Test; + +class ReportTest { + @Test + void summarizes() { + new Report(new Calc()).summarize(1, 2); + } +} +"; + +const J_SOLO_TEST: &str = "\ +package com.example.solo; + +import org.junit.jupiter.api.Test; + +class SoloTest { + @Test + void standsAlone() { + int x = 1 + 1; + } +} +"; + +fn init_java_repo() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in [ + "src/main/java/com/example/calc", + "src/main/java/com/example/report", + "src/test/java/com/example/calc", + "src/test/java/com/example/report", + "src/test/java/com/example/solo", + ] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + std::fs::write(root.join("settings.gradle"), J_SETTINGS).unwrap(); + std::fs::write(root.join("build.gradle"), J_BUILD).unwrap(); + std::fs::write( + root.join("src/main/java/com/example/calc/Calc.java"), + J_CALC, + ) + .unwrap(); + std::fs::write( + root.join("src/main/java/com/example/report/Report.java"), + J_REPORT, + ) + .unwrap(); + std::fs::write( + root.join("src/test/java/com/example/calc/CalcTest.java"), + J_CALC_TEST, + ) + .unwrap(); + std::fs::write( + root.join("src/test/java/com/example/report/ReportTest.java"), + J_REPORT_TEST, + ) + .unwrap(); + std::fs::write( + root.join("src/test/java/com/example/solo/SoloTest.java"), + J_SOLO_TEST, + ) + .unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["add", "-A"]); + git(root, &["commit", "-m", "initial"]); + tmp +} + +fn selected_names(root: &std::path::Path) -> Vec> { + let assert = Command::cargo_bin("testless") + .unwrap() + .arg("select") + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap_or_else(|e| { + panic!("expected JSON stdout, got {out:?} ({e})"); + }); + assert_eq!(json["mode"], "selection", "unexpected run-all: {json}"); + json["tests"] + .as_array() + .expect("tests array") + .iter() + .map(|t| { + t["name"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect() + }) + .collect() +} + +/// Editing `Calc.add`'s body selects its own same-package test and the +/// cross-package `ReportTest`, and leaves the unrelated tests alone. +#[test] +fn java_add_body_edit_selects_add_and_report_tests_excludes_others() { + let tmp = init_java_repo(); + let root = tmp.path(); + std::fs::write( + root.join("src/main/java/com/example/calc/Calc.java"), + J_CALC_BODY_EDITED, + ) + .unwrap(); + + let names = selected_names(root); + + assert!( + names.contains(&vec![ + "com.example.calc.CalcTest".to_string(), + "addsNegatives".to_string() + ]), + "expected CalcTest.addsNegatives (same package, no import) in {names:?}" + ); + assert!( + names.contains(&vec![ + "com.example.report.ReportTest".to_string(), + "summarizes".to_string() + ]), + "expected ReportTest.summarizes (cross-package via import) in {names:?}" + ); + assert!( + !names + .iter() + .any(|n| n.last().map(|s| s.as_str()) == Some("standsAlone")), + "SoloTest.standsAlone must NOT be selected, got {names:?}" + ); + assert!( + !names + .iter() + .any(|n| n.last().map(|s| s.as_str()) == Some("triples")), + "CalcTest.triples must NOT be selected, got {names:?}" + ); + assert_eq!( + names.len(), + 2, + "expected exactly 2 selected tests, got {names:?}" + ); +} + +/// `--format args` prints Gradle command lines, scoped by class and method, +/// because a `build.gradle` sits at the repo root. +#[test] +fn java_format_args_emits_gradle_lines() { + let tmp = init_java_repo(); + let root = tmp.path(); + std::fs::write( + root.join("src/main/java/com/example/calc/Calc.java"), + J_CALC_BODY_EDITED, + ) + .unwrap(); + + let assert = Command::cargo_bin("testless") + .unwrap() + .args(["select", "--format", "args"]) + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let lines: Vec<&str> = out.lines().collect(); + + assert!( + lines.contains(&"gradle test --tests com.example.calc.CalcTest.addsNegatives"), + "got {lines:?}" + ); + assert!( + lines.contains(&"gradle test --tests com.example.report.ReportTest.summarizes"), + "got {lines:?}" + ); + assert_eq!(lines.len(), 2, "got {lines:?}"); +} + +/// A `pom.xml` instead of a `build.gradle` flips the same selection to +/// Maven command lines: the runner comes from the build file, not from the +/// language. +#[test] +fn java_maven_repo_emits_mvn_lines() { + let tmp = init_java_repo(); + let root = tmp.path(); + std::fs::remove_file(root.join("build.gradle")).unwrap(); + std::fs::write(root.join("pom.xml"), "\n").unwrap(); + git(root, &["add", "-A"]); + git(root, &["commit", "-m", "switch to maven"]); + std::fs::write( + root.join("src/main/java/com/example/calc/Calc.java"), + J_CALC_BODY_EDITED, + ) + .unwrap(); + + let assert = Command::cargo_bin("testless") + .unwrap() + .args(["select", "--format", "args"]) + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let lines: Vec<&str> = out.lines().collect(); + + assert!( + lines.contains( + &"mvn test -Dtest='com.example.calc.CalcTest#addsNegatives' -DfailIfNoTests=false" + ), + "got {lines:?}" + ); +} diff --git a/crates/core/src/classify.rs b/crates/core/src/classify.rs index ba2d4a9..dff9e13 100644 --- a/crates/core/src/classify.rs +++ b/crates/core/src/classify.rs @@ -16,9 +16,10 @@ //! instead: its old content is diffed directly against its new-path //! content, so rename semantics are unaffected.) //! -//! A deleted **Go** file is a special case (Go imports name package -//! *directories*, never file stems, and same-package sibling files -//! reference each other via nothing at all): see [`seed_go_deletion`]. +//! A deleted file in a **package-scoped** language (Go, Java) is a +//! special case (their imports name package *directories*, never file +//! stems, and same-package sibling files reference each other via +//! nothing at all): see [`seed_package_deletion`]. //! //! A deleted file that *is* indexed but whose name yields no usable scan //! needle (see [`stem_needles`]) can't be soundly narrowed by the stem @@ -170,8 +171,9 @@ enum PerFile { /// or not). Batched up for a single pass over every indexed file's raw /// imports. ScanImporters(PathBuf), - /// A deleted Go file: seeds found directly (its surviving package - /// siblings' `ModuleInit`, see [`seed_go_deletion`]) plus one or more + /// A deleted package-scoped (Go, Java) file: seeds found directly (its + /// surviving package siblings' `ModuleInit`, see + /// [`seed_package_deletion`]) plus one or more /// extra paths for the raw-import stem scan (the deleted file's own /// path, and its package directory). SeedsAndScan(Vec, Vec), @@ -190,8 +192,8 @@ fn classify_one( // importer's now-dangling reference still seeds that importer's // `ModuleInit` (see rule 2's doc comment above). if let Some(lang) = registry.for_path(&c.path) { - if lang.id() == "go" { - return seed_go_deletion(new_graph, &c.path); + if lang.package_key(&c.path).is_some() { + return seed_package_deletion(new_graph, lang, &c.path); } if stem_needles(&c.path).is_empty() { // An indexed file whose name is too short to yield any @@ -334,8 +336,9 @@ fn stem_needles(path: &Path) -> Vec { /// re-parsing) for a reference to any of `changed_paths` (substring match /// against each path's [`stem_needles`]). Each match seeds that importing /// file's `ModuleInit`. `changed_paths` may be files with no registered -/// `Language`, deleted files (indexed or not), or (for a deleted Go file, -/// see [`seed_go_deletion`]) a package directory path — either way there's +/// `Language`, deleted files (indexed or not), or (for a deleted +/// package-scoped file, see [`seed_package_deletion`]) a package directory +/// path — either way there's /// no def-level diff to run, so this stem scan is the only way to find /// who's affected. fn scan_importers( @@ -367,17 +370,21 @@ fn scan_importers( seeds } -/// A deleted **Go** source file: Go imports name package *directories* -/// (e.g. `example.com/m/pkg`), never a file's stem, and files within the -/// same package don't import each other at all (no statement references a -/// same-package sibling by name). So neither half of the ordinary deleted- -/// file handling (rule 2) can find the right seeds on its own: +/// A deleted source file in a **package-scoped** language (see +/// [`crate::language::Language::package_scoped`]; Go and Java today): +/// imports name package *directories* (Go's `example.com/m/pkg`, Java's +/// `import a.b.C` resolving under a source root), never a file's own stem, +/// and files within the same package don't import each other at all (no +/// statement references a same-package sibling by name). So neither half of +/// the ordinary deleted-file handling (rule 2) can find the right seeds on +/// its own: /// /// - The basename/stem scan (`scan_importers` via `stem_needles`) never /// matches a same-package sibling, since nothing imports it by name. /// - It also can't find *other* packages' importers unless it's given the /// package directory's own name as a needle (an import of -/// `example.com/m/pkg` contains `pkg`, the directory's basename). +/// `example.com/m/pkg` contains `pkg`, the directory's basename; a Java +/// `import a.b.C` contains both `b` and `C`). /// /// Fix: (a) directly seed the `ModuleInit` of every surviving `new_graph` /// file in the same directory as `deleted_path` (its package siblings, @@ -387,10 +394,14 @@ fn scan_importers( /// /// If neither the deleted file's own name nor its package directory's name /// yields a usable [`stem_needles`] candidate, this can't soundly rule out -/// an importer in another package — same as the non-Go short-stem case, -/// escalates to `RunAll` rather than silently under-selecting. -fn seed_go_deletion(new_graph: &Graph, deleted_path: &Path) -> Result { - let seeds = sibling_module_inits(new_graph, deleted_path); +/// an importer in another package — same as the file-scoped short-stem +/// case, escalates to `RunAll` rather than silently under-selecting. +fn seed_package_deletion( + new_graph: &Graph, + lang: &dyn Language, + deleted_path: &Path, +) -> Result { + let seeds = sibling_module_inits(new_graph, lang, deleted_path); let mut scan_paths = vec![deleted_path.to_path_buf()]; if let Some(dir) = deleted_path.parent() { @@ -402,7 +413,7 @@ fn seed_go_deletion(new_graph: &Graph, deleted_path: &Path) -> Result Result Vec { - let dir = deleted_path.parent(); +/// +/// Only files of the deleted file's own language are considered: keys are +/// defined per-language and comparing them across languages is meaningless. +fn sibling_module_inits(new_graph: &Graph, lang: &dyn Language, deleted_path: &Path) -> Vec { + let key = lang.package_key(deleted_path); let mut seeds = Vec::new(); for (i, f) in new_graph.files.iter().enumerate() { - if f.path.parent() == dir { + if f.lang == lang.id() && lang.package_key(&f.path) == key { let file_id = FileId(i as u32); if let Some(m) = new_graph.module_init(file_id) { seeds.push(Seed { diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 016f258..7458ad4 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -5,6 +5,7 @@ //! ```toml //! always-run = ["tests/smoke/**", "**/*.e2e.test.ts"] //! ignore = ["**/generated/**", "*.pb.go"] +//! java-runner = "gradle" //! ``` //! //! - `ignore`: discovery-level. Matched (via `globset`) against repo-relative @@ -15,6 +16,9 @@ //! def whose *file* matches one of these globs is added to the selection //! regardless of whether the walk reached it; see //! [`always_run_matches`]. +//! - `java-runner`: rendering-level, and Java-only. `"maven"` or +//! `"gradle"`; overrides the build-file sniffing `--format args` does to +//! decide which command shape to print (see the CLI's `runner` module). //! //! A missing `testless.toml` is not an error: [`Config::load`] returns //! [`Config::default`] (both lists empty, i.e. a no-op). A `testless.toml` @@ -41,6 +45,15 @@ pub struct Config { pub always_run: Vec, #[serde(default)] pub ignore: Vec, + /// `"maven"` or `"gradle"`: forces which build tool `--format args` + /// renders Java test commands for, instead of sniffing for a `pom.xml` + /// / `build.gradle` next to the test's module. Unset (the default) + /// means sniff. Deliberately *not* validated at parse time: an + /// unrecognized value degrades that runner to `"unknown"` (no command + /// printed) rather than failing the whole run, matching how an + /// unregistered language behaves. + #[serde(default)] + pub java_runner: Option, } impl Config { @@ -209,7 +222,7 @@ mod tests { let config = Config { always_run: vec!["tests/smoke/**".to_string()], - ignore: vec![], + ..Config::default() }; let matches = always_run_matches(&g, &config).unwrap(); diff --git a/crates/core/src/indexer.rs b/crates/core/src/indexer.rs index 47df7f5..52efd61 100644 --- a/crates/core/src/indexer.rs +++ b/crates/core/src/indexer.rs @@ -133,14 +133,15 @@ pub fn index_repo_incremental( } // Pass 2: resolve imports now that every file is indexed. A resolved - // path that matches an indexed file exactly gets a single edge; a Go - // package-directory result fans out to every *other* indexed file - // directly under that directory (excluding the importing file itself, - // so a file importing its own package doesn't get a self-edge). - // `seen` dedups repeated imports of the same target from the same file - // (e.g. a type-only import alongside a value import of the same - // module) down to a single `Imports` edge. - // Built once so exact-path and Go dir-fanout import resolution are O(1) + // path that matches an indexed file exactly gets a single edge; a + // directory result (a Go package, or a Java wildcard import like + // `import a.b.*;`) fans out to every *other* indexed file directly + // under that directory (excluding the importing file itself, so a file + // importing its own package doesn't get a self-edge). `seen` dedups + // repeated imports of the same target from the same file (e.g. a + // type-only import alongside a value import of the same module) down to + // a single `Imports` edge. + // Built once so exact-path and dir-fanout import resolution are O(1) // hashmap lookups instead of an O(files) scan per import. let path_to_file: HashMap = graph .files @@ -193,12 +194,13 @@ pub fn index_repo_incremental( // Pass 3: resolve calls/reads to tier-1 candidates. Scope for a ref in // file F is F itself plus every file F `Imports` (reusing `seen`, which - // pass 2 already built as exactly that from/to set) plus, for Go only, - // every sibling file in F's own package directory (see the `lang.id() - // == "go"` scope extension below): Go's unit of visibility is the - // package, not the file, and a file can never `import` its own - // package, so cross-file same-package calls would otherwise always - // resolve as `Unknown` even though they're entirely unambiguous. Defs + // pass 2 already built as exactly that from/to set) plus, for + // package-scoped languages (see `Language::package_key`; Go and Java + // today), every other file in F's own package: their unit of + // visibility is the package, not the file, and a file can never + // `import` its own package, so cross-file same-package calls would + // otherwise always resolve as `Unknown` even though they're entirely + // unambiguous. Defs // are indexed under their *short* name: a method def like `Calc.push` // is indexed under `push` too, so a bare-identifier ref matches both // plain functions and qualified methods whether or not `ref.qualifier` @@ -211,6 +213,22 @@ pub fn index_repo_incremental( imports_of.entry(*from).or_default().push(*to); } + // Package-scoped languages (`Language::package_key`) group by package + // rather than by directory: a Java package legitimately spans two + // parallel source roots (`src/main/java` and `src/test/java`), so the + // directory index built for import fanout above can't answer this. + // Keyed by language id as well, so two languages' keys can never + // collide. + let mut package_to_files: HashMap<(&'static str, PathBuf), Vec> = HashMap::new(); + for (i, (rel_path, lang)) in files.iter().enumerate() { + if let Some(key) = lang.package_key(rel_path) { + package_to_files + .entry((lang.id(), key)) + .or_default() + .push(FileId(i as u32)); + } + } + // Keyed by file first, then short name; lets `candidates_for` look up // `by_short_name.get(f).and_then(|m| m.get(name))` with a borrowed // `&str` instead of allocating a `String` per lookup per scope file. @@ -241,11 +259,9 @@ pub fn index_repo_incremental( if let Some(targets) = imports_of.get(&file_id) { scope.extend(targets.iter().copied()); } - if lang.id() == "go" { - if let Some(dir) = rel_path.parent() { - if let Some(siblings) = dir_to_files.get(dir) { - scope.extend(siblings.iter().copied().filter(|&f| f != file_id)); - } + if let Some(key) = lang.package_key(rel_path) { + if let Some(siblings) = package_to_files.get(&(lang.id(), key)) { + scope.extend(siblings.iter().copied().filter(|&f| f != file_id)); } } let candidates_for = |name: &str| -> Vec { diff --git a/crates/core/src/language.rs b/crates/core/src/language.rs index 44ae4a2..70c51cd 100644 --- a/crates/core/src/language.rs +++ b/crates/core/src/language.rs @@ -61,6 +61,37 @@ pub trait Language: Send + Sync { fn extract(&self, src: &str, tree: &tree_sitter::Tree) -> Extraction; /// raw import specifier -> repo-relative file path, None if external/unresolvable fn resolve_import(&self, from_file: &Path, raw: &str, repo_root: &Path) -> Option; + + /// The *package* `file` belongs to, for languages whose unit of + /// visibility is the package rather than the file: two files sharing a + /// key see each other's definitions with no import statement at all. + /// `None` (the default) means file-scoped — nothing is visible without + /// an explicit import (TS, Rust). + /// + /// Two things key off this, both of which would otherwise under-select: + /// + /// - `indexer`'s pass 3 extends a file's resolution scope to every file + /// sharing its key, so a cross-file same-package call resolves + /// instead of degrading to `Unknown`. + /// - `classify`'s deleted-file rule seeds the surviving package + /// siblings' `ModuleInit` directly, since nothing ever imported the + /// deleted file by its own stem. + /// + /// The key is *not* required to be the file's directory. Go's is (a Go + /// package is a directory), but Java's deliberately isn't: Maven and + /// Gradle split one package across parallel source roots, so + /// `src/main/java/com/foo/Calc.java` and + /// `src/test/java/com/foo/CalcTest.java` are the same package + /// (`com/foo`) in two different directories, and `CalcTest` references + /// `Calc` with no import at all. Keying on the directory would miss + /// exactly the edge that matters most — the one from a class to its own + /// unit test. + /// + /// Keys are only ever compared between files of the same language, so + /// they don't need to be globally unique across languages. + fn package_key(&self, _file: &Path) -> Option { + None + } } pub struct Registry { diff --git a/crates/lang-go/src/lib.rs b/crates/lang-go/src/lib.rs index 3359e2f..d249994 100644 --- a/crates/lang-go/src/lib.rs +++ b/crates/lang-go/src/lib.rs @@ -16,6 +16,13 @@ impl Language for GoLanguage { &["go"] } + /// A Go package *is* a directory, so the directory is the key: + /// same-package siblings reference each other with no import statement + /// at all. + fn package_key(&self, file: &Path) -> Option { + Some(file.parent().unwrap_or(Path::new("")).to_path_buf()) + } + fn grammar(&self, _path: &Path) -> tree_sitter::Language { tree_sitter_go::LANGUAGE.into() } diff --git a/crates/lang-java/Cargo.toml b/crates/lang-java/Cargo.toml new file mode 100644 index 0000000..8ae40e3 --- /dev/null +++ b/crates/lang-java/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "testless-lang-java" +version = "0.6.0" +edition.workspace = true +description = "Java language plugin for testless" +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +testless-core = { path = "../core", version = "0.6.0" } +tree-sitter = "0.26.11" +tree-sitter-java = "0.23.5" diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs new file mode 100644 index 0000000..aedc5e7 --- /dev/null +++ b/crates/lang-java/src/lib.rs @@ -0,0 +1,1123 @@ +//! Java plugin: JUnit 5 tests, Maven/Gradle source layouts. +//! +//! Two things make Java different from the other plugins: +//! +//! - **Package scope.** Same-package classes see each other with no import +//! statement, much like Go — but a Java package is *not* a directory: the +//! same package spans `src/main/java` and `src/test/java`, which is +//! exactly where a class and its unit test live. [`Language::package_key`] +//! is what lets `indexer`/`classify` treat those two directories as one +//! scope. +//! - **Import resolution has to be real.** `walk`'s `Unknown(name)` +//! widening is scoped to the *forward transitive import closure*, so a +//! cross-module reference whose import failed to resolve doesn't widen — +//! it silently drops, which is an under-select, the one failure mode +//! testless doesn't accept. So `resolve_import` genuinely locates the +//! target file rather than giving up on anything outside the current +//! module: it discovers every `*/src/*/java` source root in the repo +//! (memoized per repo root, see [`JavaLanguage::source_roots`]) and +//! probes the fully-qualified name against each. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use testless_core::fingerprint::{module_init_fingerprint, split_fingerprint}; +use testless_core::{DefKind, ExtractedDef, ExtractedRef, Extraction, ImportRef, Language}; +use tree_sitter::Node; + +/// Annotations that mark a method as a JUnit 5 test. Matched on the +/// annotation's *simple* name, so a fully-qualified `@org.junit.jupiter. +/// api.Test` counts the same as a plain `@Test`. +/// +/// `@ParameterizedTest`/`@RepeatedTest`/`@TestFactory`/`@TestTemplate` are +/// included and, unlike vitest's template-literal titles or Go's non-literal +/// `t.Run` argument, they are *not* marked `computed_name`: their generated +/// per-invocation display names vary, but both `mvn -Dtest=Class#method` and +/// `gradle --tests Class.method` filter on the *method* name, which is +/// statically known here. Widening those to the whole class would only +/// over-select. +const TEST_ANNOTATIONS: &[&str] = &[ + "Test", + "ParameterizedTest", + "RepeatedTest", + "TestFactory", + "TestTemplate", +]; + +/// Annotations that say something to the *compiler* and nothing to any +/// runtime container, so they must not be read as "this member is invoked +/// reflectively" (see the containment rule in `handle_type_declaration`). +/// +/// `@Override` is the one that matters: it sits on a large fraction of all +/// Java methods, and counting it would parent nearly every method to its +/// class, which is precisely the blanket widening the annotation rule +/// exists to avoid. Measured on google/gson, treating `@Override` as +/// reflective took a leaf-utility edit from 6 selected tests to 1501. +/// Nullability annotations are here for the same reason: ubiquitous, +/// purely static. +const INERT_ANNOTATIONS: &[&str] = &[ + "Override", + "SuppressWarnings", + "Deprecated", + "SafeVarargs", + "FunctionalInterface", + "Nullable", + "NonNull", + "Nonnull", + "NotNull", + "CheckForNull", +]; + +/// Node kinds that declare a type and therefore become a `DefKind::Class`. +const TYPE_DECLARATIONS: &[&str] = &[ + "class_declaration", + "interface_declaration", + "enum_declaration", + "record_declaration", + "annotation_type_declaration", +]; + +/// Directory names never worth descending into when hunting for source +/// roots: build outputs and VCS/tooling metadata, none of which hold +/// indexable sources. +const SKIP_DIRS: &[&str] = &[ + "target", + "build", + "out", + "bin", + ".git", + ".gradle", + ".idea", + "node_modules", +]; + +/// How deep the source-root scan descends below the repo root before giving +/// up. A conventional root is at depth 3 (`src/main/java`) and a +/// multi-module one at 4-5 (`services/billing/src/main/java`); this leaves +/// headroom for deeply grouped monorepos while keeping a pathological tree +/// from being walked in full. +const MAX_ROOT_SCAN_DEPTH: usize = 8; + +#[derive(Default)] +pub struct JavaLanguage { + /// repo root -> its fully-qualified-name index. Built once per repo and + /// reused; see [`JavaLanguage::repo_index`] for why this can't be a + /// per-import filesystem probe. + index: Mutex>>, +} + +impl Language for JavaLanguage { + fn id(&self) -> &'static str { + "java" + } + + fn extensions(&self) -> &'static [&'static str] { + &["java"] + } + + /// The package path with the source root stripped, so that + /// `src/main/java/com/foo/Calc.java` and + /// `src/test/java/com/foo/CalcTest.java` share the key `com/foo` and + /// resolve each other's names without an import — which is exactly how + /// Java unit tests are laid out, and the single most important edge in + /// the whole graph. A file outside a conventional source root falls + /// back to its directory. + /// + /// Multi-module repos are handled by keeping the module prefix: a key + /// is `/`, so `com.foo` in `services/billing` doesn't + /// silently merge with an unrelated `com.foo` in `services/ledger`. + /// Cross-module references go through real imports instead (see + /// `resolve_import`). + fn package_key(&self, file: &Path) -> Option { + let dir = file.parent().unwrap_or(Path::new("")); + let Some(root) = source_root_of(file) else { + return Some(dir.to_path_buf()); + }; + // `/src//java` -> ``; three components off the + // end of the root, whatever the module prefix is. + let module: PathBuf = root + .components() + .take(root.components().count().saturating_sub(3)) + .collect(); + let package = dir.strip_prefix(&root).unwrap_or(dir); + Some(module.join(package)) + } + + fn grammar(&self, _path: &Path) -> tree_sitter::Language { + tree_sitter_java::LANGUAGE.into() + } + + fn extract(&self, src: &str, tree: &tree_sitter::Tree) -> Extraction { + let root = tree.root_node(); + let src_bytes = src.as_bytes(); + let mut defs = Vec::new(); + + // One `` per file, always. Unlike Go or TS there is very + // little loose top-level code in Java (everything lives inside a + // type), so this hash is near-constant — but the def still earns + // its keep as the seed target for import-level and deletion-level + // changes, which is what `classify` and `walk` reach for. + let module_init_skip = |n: &tree_sitter::Node| { + TYPE_DECLARATIONS.contains(&n.kind()) + || matches!(n.kind(), "import_declaration" | "package_declaration") + }; + defs.push(ExtractedDef { + name: "".to_string(), + kind: DefKind::ModuleInit, + start_line: root.start_position().row as u32 + 1, + end_line: root.end_position().row as u32 + 1, + test_id: None, + computed_name: false, + parent: None, + sig_hash: module_init_fingerprint(root, src_bytes, &module_init_skip), + body_hash: None, + }); + + let package = package_name(root, src_bytes); + + // `scope_of` maps a def's own AST node to its index in `defs`, so + // the refs pass can track the innermost enclosing def. `def_name_ids` + // holds identifiers that merely *name* something (a class, a method, + // a parameter, an annotation) and so must never be scanned as reads. + let mut scope_of: HashMap = HashMap::new(); + let mut def_name_ids: HashSet = HashSet::new(); + // (owning class, field name) for every field this file declares, so + // the refs pass can resolve a bare field name inside its own class + // instead of against every same-named field in the package. + let mut field_owners: HashSet<(String, String)> = HashSet::new(); + + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + if TYPE_DECLARATIONS.contains(&child.kind()) { + handle_type_declaration( + child, + src_bytes, + &mut defs, + &mut scope_of, + &mut def_name_ids, + &mut field_owners, + None, + &[], + package.as_deref(), + ); + } + } + + let mut imports = Vec::new(); + collect_imports(root, src_bytes, &mut imports, &mut def_name_ids); + + let known_names = build_known_names(&defs, &imports); + + let def_class = def_classes(&defs); + let ctx = RefCtx { + src: src_bytes, + scope_of: &scope_of, + def_name_ids: &def_name_ids, + known_names: &known_names, + def_class: &def_class, + field_owners: &field_owners, + }; + let mut calls = Vec::new(); + let mut reads = Vec::new(); + walk_refs(root, &ctx, 0, &mut calls, &mut reads); + + Extraction { + defs, + imports, + calls: dedup_refs(calls), + reads: dedup_refs(reads), + } + } + + /// A dotted Java import (`com.foo.core.Calc`, `com.foo.util.*`, + /// `static org.junit.jupiter.api.Assertions.assertEquals`) looked up in + /// the repo's fully-qualified-name index. + /// + /// Three shapes are tried, in order: + /// + /// 1. `com/foo/core/Calc` as a *type* — an ordinary single-type import, + /// the precise and overwhelmingly common case. + /// 2. `com/foo/util` as a *package* — a wildcard import, which the + /// indexer fans out to every indexed file under the directory (the + /// same dir-fanout Go package imports use). + /// 3. `org/junit/jupiter/api/Assertions` — a static member import, + /// where the last segment names a member rather than a type. + /// + /// Anything that matches nothing (`java.util.List`, a third-party jar) + /// yields `None`, exactly as an unresolvable import should. + fn resolve_import(&self, from_file: &Path, raw: &str, repo_root: &Path) -> Option { + let raw = raw.trim(); + let wildcard = raw.ends_with(".*"); + let dotted = raw.strip_suffix(".*").unwrap_or(raw); + if dotted.is_empty() { + return None; + } + let as_path: PathBuf = dotted.split('.').collect(); + let index = self.repo_index(repo_root); + + if wildcard { + return index + .packages + .get(&as_path) + .and_then(|c| pick(c, from_file)); + } + if let Some(hit) = index.classes.get(&as_path).and_then(|c| pick(c, from_file)) { + return Some(hit); + } + if let Some(hit) = index + .packages + .get(&as_path) + .and_then(|c| pick(c, from_file)) + { + return Some(hit); + } + as_path + .parent() + .and_then(|owner| index.classes.get(owner)) + .and_then(|c| pick(c, from_file)) + } +} + +impl JavaLanguage { + /// The repo's fully-qualified-name index, built once and reused. + /// + /// This has to be an index, not a probe loop. `resolve_import` runs + /// once per import per file; spring-boot is ~8.7k Java files across + /// ~460 Gradle modules, so ~900 source roots. Stat-probing every root + /// for every import is O(imports x roots) filesystem syscalls — on that + /// repo, hundreds of millions, which never finishes. One walk up front + /// turns each import into a hashmap lookup. + /// + /// A poisoned lock (another thread panicked mid-scan) degrades to an + /// uncached rebuild rather than propagating the panic: import + /// resolution is best-effort infrastructure, not a place to take the + /// whole index down. + fn repo_index(&self, repo_root: &Path) -> Arc { + if let Ok(cache) = self.index.lock() { + if let Some(hit) = cache.get(repo_root) { + return Arc::clone(hit); + } + } + let built = Arc::new(build_repo_index(repo_root)); + if let Ok(mut cache) = self.index.lock() { + cache.insert(repo_root.to_path_buf(), Arc::clone(&built)); + } + built + } +} + +/// Every type and package the repo declares, keyed by fully-qualified name +/// as a path (`com/foo/Calc`, `com/foo`). Values are repo-relative paths, +/// and there may be more than one: the same FQN can legitimately appear in +/// a `main` and a `test` source root, or in two unrelated modules. +#[derive(Default)] +struct RepoIndex { + /// `com/foo/Calc` -> the `.java` files declaring it. + classes: HashMap>, + /// `com/foo` -> the package directories holding it. + packages: HashMap>, +} + +/// Choose among several files/directories declaring the same FQN: prefer +/// the importing file's own module (a multi-module repo can define the same +/// class name twice, and the local one is what the compiler would bind), +/// then a `src/main/java` root over a test root, then whatever came first. +fn pick(candidates: &[PathBuf], from_file: &Path) -> Option { + let own_module = module_of(from_file); + candidates + .iter() + .min_by_key(|c| { + let same_module = own_module.is_some() && module_of(c) == own_module; + (!same_module, !is_main_root(c)) + }) + .cloned() +} + +/// The build module a repo-relative path sits in: its source root minus the +/// trailing `src//java`. `None` outside a conventional layout. +fn module_of(path: &Path) -> Option { + let root = source_root_of(path)?; + Some( + root.components() + .take(root.components().count().saturating_sub(3)) + .collect(), + ) +} + +fn is_main_root(path: &Path) -> bool { + source_root_of(path) + .map(|r| r.ends_with("main/java")) + .unwrap_or(false) +} + +/// Walk every source root once, recording each `.java` file under its +/// root-relative fully-qualified name and each package directory under its +/// root-relative package path. +fn build_repo_index(repo_root: &Path) -> RepoIndex { + let mut index = RepoIndex::default(); + for root in scan_source_roots(repo_root) { + collect_types(repo_root, &root, Path::new(""), &mut index); + } + index +} + +fn collect_types(repo_root: &Path, root: &Path, pkg: &Path, index: &mut RepoIndex) { + let Ok(entries) = std::fs::read_dir(repo_root.join(root).join(pkg)) else { + return; + }; + let mut has_types = false; + let mut subdirs = Vec::new(); + for entry in entries.flatten() { + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir { + if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') { + subdirs.push(name); + } + continue; + } + if let Some(stem) = name.strip_suffix(".java") { + has_types = true; + index + .classes + .entry(pkg.join(stem)) + .or_default() + .push(root.join(pkg).join(&name)); + } + } + if has_types { + index + .packages + .entry(pkg.to_path_buf()) + .or_default() + .push(root.join(pkg)); + } + for sub in subdirs { + collect_types(repo_root, root, &pkg.join(sub), index); + } +} + +/// The `<...>/src//java` prefix of a repo-relative Java file, or +/// `None` for a file outside the conventional layout. Searched from the end +/// so a repo path that itself contains a `src` segment can't truncate early. +fn source_root_of(file: &Path) -> Option { + let parts: Vec<_> = file.components().collect(); + let idx = (0..parts.len().saturating_sub(2)) + .rev() + .find(|&i| parts[i].as_os_str() == "src" && parts[i + 2].as_os_str() == "java")?; + Some(parts[..=idx + 2].iter().collect()) +} + +/// Depth-bounded directory walk collecting every `src//java` +/// path under `repo_root`, returned repo-relative. Descent stops at a +/// matched root (nothing below it is another root) and at [`SKIP_DIRS`]. +fn scan_source_roots(repo_root: &Path) -> Vec { + let mut found = Vec::new(); + scan_dir(repo_root, Path::new(""), 0, &mut found); + found.sort(); + found +} + +fn scan_dir(repo_root: &Path, rel: &Path, depth: usize, found: &mut Vec) { + if depth > MAX_ROOT_SCAN_DEPTH { + return; + } + let Ok(entries) = std::fs::read_dir(repo_root.join(rel)) else { + return; + }; + for entry in entries.flatten() { + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + if SKIP_DIRS.contains(&name.as_str()) || name.starts_with('.') { + continue; + } + let child = rel.join(&name); + if source_root_of(&child.join("X.java")).as_deref() == Some(child.as_path()) { + found.push(child); + continue; + } + scan_dir(repo_root, &child, depth + 1, found); + } +} + +/// The file's `package a.b.c;` declaration, if it has one. +fn package_name(root: Node, src: &[u8]) -> Option { + let mut cursor = root.walk(); + let decl = root + .children(&mut cursor) + .find(|c| c.kind() == "package_declaration")?; + let mut inner = decl.walk(); + let name = decl + .children(&mut inner) + .find(|c| matches!(c.kind(), "scoped_identifier" | "identifier"))?; + name.utf8_text(src).ok().map(|s| s.to_string()) +} + +/// A type declaration and everything inside it: the type itself becomes a +/// `Class` def, its methods and constructors become `Method` defs (or +/// `TestCase` defs when annotated), and nested types recurse. +/// +/// `class_chain` is the simple names of the enclosing types, outermost +/// first; combined with `package` it builds a test's `test_id` as +/// `[fqcn, nested…, method]` — the shape `format`'s Maven/Gradle renderers +/// re-join with `$` to reach the JVM's name for a `@Nested` class. +#[allow(clippy::too_many_arguments)] +fn handle_type_declaration( + node: Node, + src: &[u8], + defs: &mut Vec, + scope_of: &mut HashMap, + def_name_ids: &mut HashSet, + field_owners: &mut HashSet<(String, String)>, + parent: Option, + class_chain: &[String], + package: Option<&str>, +) { + let Some(name_node) = node.child_by_field_name("name") else { + return; + }; + let Ok(type_name) = name_node.utf8_text(src) else { + return; + }; + + // A type's own hash deliberately excludes its members' *bodies*. + // `split_fingerprint` would fold the entire class body — every method + // body included — into the type's `body_hash`, so editing one method + // would mark the whole type changed and drag in everything that merely + // holds a reference to it. In TS that's an acceptable over-select + // because most code is top-level; in Java *all* code lives in a type, + // so it collapses selection to "every test that touches this class". + // + // Instead: `sig_hash` is the declaration minus its body (modifiers, + // name, type parameters, `extends`/`implements` — a change to any of + // which really does affect every user of the type), and `body_hash` + // covers only the members that have no def of their own: field + // declarations with their initializers, and static/instance initializer + // blocks. That matches where `scope_of` attributes those refs, and + // leaves method bodies to the method defs that own them. + let member_has_own_def = |n: &Node| { + matches!(n.kind(), "method_declaration" | "constructor_declaration") + || TYPE_DECLARATIONS.contains(&n.kind()) + }; + let (sig_hash, _) = split_fingerprint(node, src); + let body_hash = node + .child_by_field_name("body") + .map(|body| module_init_fingerprint(body, src, &member_has_own_def)); + defs.push(ExtractedDef { + name: type_name.to_string(), + kind: DefKind::Class, + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + test_id: None, + computed_name: false, + parent, + sig_hash, + body_hash, + }); + let class_idx = defs.len() - 1; + def_name_ids.insert(name_node.id()); + // The type's own scope catches refs that don't open a narrower one — + // field initializers, static blocks — so they attribute here rather + // than falling through to ``. Method bodies still win, since + // `walk_refs` re-resolves `scope_of` at every node. + scope_of.insert(node.id(), class_idx); + + let mut chain = class_chain.to_vec(); + chain.push(type_name.to_string()); + + let Some(body) = node.child_by_field_name("body") else { + return; + }; + let mut cursor = body.walk(); + for member in body.children(&mut cursor) { + if TYPE_DECLARATIONS.contains(&member.kind()) { + handle_type_declaration( + member, + src, + defs, + scope_of, + def_name_ids, + field_owners, + Some(class_idx), + &chain, + package, + ); + continue; + } + // Fields get defs of their own, one per declarator. Without them a + // very common Java shape loses its chain entirely: a JUnit class + // builds its fixture in a field initializer + // + // private final ApplicationContextRunner contextRunner = + // new ApplicationContextRunner().withConfiguration( + // AutoConfigurations.of(JacksonAutoConfiguration.class)); + // + // and every test method then works through `this.contextRunner`. + // The dependency on `JacksonAutoConfiguration` lives on the *field*, + // so with no field def the initializer's refs land on the class, + // `Contains` only walks child -> parent, and the test methods are + // never reached. Giving the field a def puts a real `Reads` edge + // between each method and the fixture it uses. + if member.kind() == "field_declaration" { + let annotated = container_invocable(member, src); + let mut fields = member.walk(); + let declarators: Vec = member + .children(&mut fields) + .filter(|c| c.kind() == "variable_declarator") + .collect(); + for declarator in declarators { + let Some(field_name_node) = declarator.child_by_field_name("name") else { + continue; + }; + let Ok(field_name) = field_name_node.utf8_text(src) else { + continue; + }; + def_name_ids.insert(field_name_node.id()); + field_owners.insert((type_name.to_string(), field_name.to_string())); + let idx = push_def( + declarator, + // `Class#field`, not `Class.field`. The indexer keys defs + // on the segment after the last `.`, so `Class.field` + // would be indexed as bare `field` and every same-named + // field in the package would resolve to it — two test + // classes that each hold a `gson` field would cross-link, + // and on google/gson that alone dragged a peripheral edit + // out to 1439 of 1534 tests. A `#` keeps the whole + // qualified string as the key, which matches Java: a bare + // field name is resolved in its own class, never a + // sibling's. + format!("{type_name}#{field_name}"), + DefKind::Method, + src, + defs, + // An injected field (`@Autowired`, `@Mock`, `@Value`) is + // populated by the container, same reasoning as an + // annotated method below. + annotated.then_some(class_idx), + None, + ); + // The initializer's refs belong to the field, not the class. + scope_of.insert(declarator.id(), idx); + } + continue; + } + if !matches!( + member.kind(), + "method_declaration" | "constructor_declaration" + ) { + continue; + } + let Some(member_name_node) = member.child_by_field_name("name") else { + continue; + }; + let Ok(member_name) = member_name_node.utf8_text(src) else { + continue; + }; + def_name_ids.insert(member_name_node.id()); + + let annotations = annotation_names(member, src); + let (kind, test_id) = if annotations + .iter() + .any(|n| TEST_ANNOTATIONS.contains(&n.as_str())) + { + ( + DefKind::TestCase, + Some(test_id_for(package, &chain, member_name)), + ) + } else { + (DefKind::Method, None) + }; + // Qualified `Class.method`, mirroring Go's `Recv.Method`: the + // indexer indexes defs under their short (post-`.`) name anyway, so + // this costs nothing at resolution time and makes `why` output name + // the owning type. + // + // Whether this member hangs off its class in the graph, which + // decides how far a change to it widens. `walk` reverse-propagates + // `Contains{parent, child}` as behavioral embedding: parenting a + // method to its class means "this method body changed" => "the + // class changed" => every test that merely writes `new Calc()` or + // holds a `Calc` field is impacted. + // + // Doing that unconditionally is very wide (in Java *all* code lives + // in a class). Never doing it is *unsound*, which is worse and not + // hypothetical: a Spring `@Bean` method is invoked by the container, + // never by name, so with no containment edge a change to it reaches + // no test at all — a silent under-select, which the selection + // contract forbids. Verified on spring-boot: editing a `@Bean` body + // selected zero of 17,720 tests. + // + // So: a member carrying *any* annotation is treated as reachable + // without being named, and parents to its class. That covers the + // realistic reflective entry points — `@Bean`, `@PostConstruct`, + // `@EventListener`, `@Scheduled`, `@RequestMapping`, JUnit's own + // lifecycle hooks — because framework-invoked Java is + // annotation-driven essentially by construction. A plain, unannotated + // method is reached by name or not at all: ordinary calls and + // interface dispatch both go through `Calls` (short-name matching + // already widens across implementations), and a class running its + // own method from a field initializer or static block records that + // as a `Calls` ref attributed to the class def. Parentless defs get + // wired to the file's `ModuleInit`, which `walk` declines to widen + // through. + // + // Residual gap, accepted and named: `Class.forName(..).getMethod + // ("plainName")` against an *unannotated* method. That's the same + // class of dynamic-reflection risk every language plugin here + // carries, and `testless.toml`'s `always-run` is the escape hatch. + let idx = push_def( + member, + format!("{type_name}.{member_name}"), + kind, + src, + defs, + container_invocable(member, src).then_some(class_idx), + test_id, + ); + scope_of.insert(member.id(), idx); + } +} + +/// `[fqcn, nested…, method]`: the outermost class qualified by the file's +/// package, then each nested type's simple name, then the method. A file +/// with no `package` declaration (the default package) contributes no +/// prefix. +fn test_id_for(package: Option<&str>, chain: &[String], method: &str) -> Vec { + let mut out: Vec = Vec::with_capacity(chain.len() + 1); + match (package, chain.first()) { + (Some(pkg), Some(outer)) => out.push(format!("{pkg}.{outer}")), + (None, Some(outer)) => out.push(outer.clone()), + (_, None) => {} + } + out.extend(chain.iter().skip(1).cloned()); + out.push(method.to_string()); + out +} + +/// Whether `node` carries an annotation implying something other than the +/// compiler invokes it: a Spring `@Bean`, a `@PostConstruct`, an injected +/// `@Autowired` field. Purely static annotations ([`INERT_ANNOTATIONS`]) +/// don't count. +fn container_invocable(node: Node, src: &[u8]) -> bool { + annotation_names(node, src) + .iter() + .any(|n| !INERT_ANNOTATIONS.contains(&n.as_str())) +} + +/// The simple names of every annotation in `node`'s `modifiers` child. +/// A qualified `@org.junit.jupiter.api.Test` reduces to `Test`. +fn annotation_names(node: Node, src: &[u8]) -> Vec { + let mut cursor = node.walk(); + let Some(modifiers) = node.children(&mut cursor).find(|c| c.kind() == "modifiers") else { + return Vec::new(); + }; + let mut inner = modifiers.walk(); + modifiers + .children(&mut inner) + .filter(|c| matches!(c.kind(), "marker_annotation" | "annotation")) + .filter_map(|a| a.child_by_field_name("name")) + .filter_map(|n| n.utf8_text(src).ok()) + .map(|n| n.rsplit('.').next().unwrap_or(n).to_string()) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn push_def( + span: Node, + name: String, + kind: DefKind, + src: &[u8], + defs: &mut Vec, + parent: Option, + test_id: Option>, +) -> usize { + let (sig_hash, body_hash) = split_fingerprint(span, src); + defs.push(ExtractedDef { + name, + kind, + start_line: span.start_position().row as u32 + 1, + end_line: span.end_position().row as u32 + 1, + test_id, + // Java test identity is the method name, which is always static — + // there is no template-literal / runtime-string equivalent here. + computed_name: false, + parent, + sig_hash, + body_hash, + }); + defs.len() - 1 +} + +/// Collect every `import_declaration`'s dotted text (wildcards keep their +/// trailing `.*` so `resolve_import` can tell them apart; the `static` +/// keyword is dropped, as it says nothing about *where* the target lives). +/// +/// Annotation *name* identifiers are folded into `def_name_ids` in the same +/// pass: `@Test` would otherwise read as a reference to a symbol named +/// `Test`. An annotation's arguments are deliberately left alone, since +/// those hold real references (`@ExtendWith(MockitoExtension.class)`). +fn collect_imports( + node: Node, + src: &[u8], + imports: &mut Vec, + def_name_ids: &mut HashSet, +) { + match node.kind() { + "import_declaration" => { + let mut cursor = node.walk(); + let children: Vec = node.children(&mut cursor).collect(); + let Some(path) = children + .iter() + .find(|c| matches!(c.kind(), "scoped_identifier" | "identifier")) + else { + return; + }; + let Ok(text) = path.utf8_text(src) else { + return; + }; + let wildcard = children.iter().any(|c| c.kind() == "asterisk"); + imports.push(ImportRef { + raw: if wildcard { + format!("{text}.*") + } else { + text.to_string() + }, + line: node.start_position().row as u32 + 1, + }); + return; + } + "marker_annotation" | "annotation" => { + if let Some(name) = node.child_by_field_name("name") { + mark_identifiers(name, def_name_ids); + } + } + _ => {} + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_imports(child, src, imports, def_name_ids); + } +} + +/// Record `node` and every identifier beneath it as "names something" +/// (a qualified annotation name is a `scoped_identifier` tree, not a +/// single token). +fn mark_identifiers(node: Node, def_name_ids: &mut HashSet) { + def_name_ids.insert(node.id()); + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + mark_identifiers(child, def_name_ids); + } +} + +/// The allow-list that keeps bare-identifier read extraction from flooding +/// on local variables: this file's own def names (both the qualified +/// `Class.method` form and its short tail) plus each import's last segment, +/// which is the simple name a call site actually writes. +fn build_known_names(defs: &[ExtractedDef], imports: &[ImportRef]) -> HashSet { + let mut names: HashSet = HashSet::new(); + for d in defs { + if d.kind == DefKind::ModuleInit { + continue; + } + names.insert(d.name.clone()); + if let Some(short) = d.name.rsplit('.').next() { + names.insert(short.to_string()); + } + } + for imp in imports { + let raw = imp.raw.strip_suffix(".*").unwrap_or(&imp.raw); + if let Some(last) = raw.rsplit('.').next() { + if !last.is_empty() { + names.insert(last.to_string()); + } + } + } + names +} + +struct RefCtx<'a> { + src: &'a [u8], + scope_of: &'a HashMap, + def_name_ids: &'a HashSet, + known_names: &'a HashSet, + /// Owning class name per def index, so a ref can be resolved relative to + /// the class it appears in. + def_class: &'a [Option], + /// (class, field) pairs this file declares; see `field_ref`. + field_owners: &'a HashSet<(String, String)>, +} + +impl RefCtx<'_> { + /// How to record a reference to the bare name `name` seen inside def + /// `def`. + /// + /// When `name` is a field of the very class the reference sits in, it + /// resolves to that class's field def and nothing else — that is Java's + /// own rule, and keeping it exact is what stops two classes' identically + /// named fields from linking together. + /// + /// Otherwise it falls back to the plain name gated by `known_names`: an + /// *inherited* field (declared by a superclass, so absent from + /// `field_owners`) still has to reach its declaration, and matching by + /// bare name over-approximates rather than missing it. + fn field_ref(&self, def: usize, name: &str) -> Option { + if let Some(Some(class)) = self.def_class.get(def) { + if self + .field_owners + .contains(&(class.clone(), name.to_string())) + { + return Some(format!("{class}#{name}")); + } + } + self.known_names.contains(name).then(|| name.to_string()) + } +} + +/// The class each def belongs to, derived from the `Class#field` / +/// `Class.member` naming above; a type's own def maps to itself, so refs in +/// a static initializer resolve against that type's fields. +fn def_classes(defs: &[ExtractedDef]) -> Vec> { + defs.iter() + .map(|d| match d.kind { + DefKind::ModuleInit => None, + DefKind::Class => Some(d.name.clone()), + _ => d + .name + .split_once('#') + .or_else(|| d.name.split_once('.')) + .map(|(class, _)| class.to_string()), + }) + .collect() +} + +fn node_text<'a>(node: Node, src: &'a [u8]) -> &'a str { + node.utf8_text(src).unwrap_or_default() +} + +/// Walk the tree recording `calls` and `reads`, threading `current_def`: +/// the index of the innermost enclosing def, via `ctx.scope_of`. +/// +/// `type_identifier` nodes are emitted as reads *unfiltered*, unlike bare +/// identifiers. That's deliberate and it's the edge that makes Java +/// dependency-injection wiring visible: a field, parameter or return type +/// is a genuine structural dependency on that class even when no method of +/// it is ever called by name in this file, and a type position can never be +/// a local variable, so there's no noise to filter out. Types the repo +/// doesn't define (`String`, `List`) simply resolve to nothing. +fn walk_refs( + node: Node, + ctx: &RefCtx, + current_def: usize, + calls: &mut Vec, + reads: &mut Vec, +) { + let def = ctx.scope_of.get(&node.id()).copied().unwrap_or(current_def); + let line = node.start_position().row as u32 + 1; + + match node.kind() { + // A declaration, not a reference: `import a.b.C;` ends in an + // identifier `C` that would otherwise scan as a read of `C` from + // ``. The dependency it expresses is already carried by the + // `Imports` edge `resolve_import` produces. + "import_declaration" | "package_declaration" => {} + "method_invocation" => { + if let Some(name) = node.child_by_field_name("name") { + let object = node.child_by_field_name("object"); + calls.push(ExtractedRef { + from_def: def, + name: node_text(name, ctx.src).to_string(), + qualifier: object + .filter(|o| matches!(o.kind(), "identifier" | "field_access")) + .map(|o| node_text(o, ctx.src).to_string()), + line, + }); + match object { + // `contextRunner.run()`: the receiver names a field or + // an imported type, and that *is* a dependency of this + // method — the qualifier alone carries no edge, since + // resolution matches on `name`. Record it as a read so + // a method using a fixture field links to that field. + Some(object) if object.kind() == "identifier" => { + let text = node_text(object, ctx.src); + if let Some(name) = ctx.field_ref(def, text) { + reads.push(ExtractedRef { + from_def: def, + name, + qualifier: None, + line, + }); + } + } + // Anything richer (`a().b()`, `new Foo().bar()`) can + // hide further refs, so recurse into it. + Some(object) => walk_refs(object, ctx, def, calls, reads), + None => {} + } + } + if let Some(args) = node.child_by_field_name("arguments") { + walk_refs(args, ctx, def, calls, reads); + } + } + "object_creation_expression" => { + // `new Calc()` is a call into `Calc`'s constructor and, for our + // purposes, a dependency on the class itself. Recorded under + // the type's simple name so it matches the `Class` def. + if let Some(ty) = node.child_by_field_name("type") { + calls.push(ExtractedRef { + from_def: def, + name: simple_type_name(node_text(ty, ctx.src)), + qualifier: None, + line, + }); + } + if let Some(args) = node.child_by_field_name("arguments") { + walk_refs(args, ctx, def, calls, reads); + } + } + "type_identifier" => { + reads.push(ExtractedRef { + from_def: def, + name: node_text(node, ctx.src).to_string(), + qualifier: None, + line, + }); + } + "field_access" => { + if let (Some(object), Some(field)) = ( + node.child_by_field_name("object"), + node.child_by_field_name("field"), + ) { + match object.kind() { + "identifier" => { + let obj_name = node_text(object, ctx.src).to_string(); + let field_name = node_text(field, ctx.src).to_string(); + if ctx.known_names.contains(&obj_name) + || ctx.known_names.contains(&field_name) + { + reads.push(ExtractedRef { + from_def: def, + name: field_name, + qualifier: Some(obj_name), + line, + }); + } + } + // `this.contextRunner`: unqualified access to one of + // this class's own fields. Without this arm the whole + // `this.`-prefixed style — which is how a lot of Java + // reads its own state — contributes no edges at all. + "this" => { + let field_name = node_text(field, ctx.src); + if let Some(name) = ctx.field_ref(def, field_name) { + reads.push(ExtractedRef { + from_def: def, + name, + qualifier: None, + line, + }); + } + } + _ => walk_refs(object, ctx, def, calls, reads), + } + } + } + "identifier" => { + if !ctx.def_name_ids.contains(&node.id()) { + let text = node_text(node, ctx.src); + if let Some(name) = ctx.field_ref(def, text) { + reads.push(ExtractedRef { + from_def: def, + name, + qualifier: None, + line, + }); + } + } + } + _ => { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_refs(child, ctx, def, calls, reads); + } + } + } +} + +/// `com.foo.Calc` -> `Calc`, `List` -> `List`: the simple name a +/// `Class` def is indexed under. +fn simple_type_name(text: &str) -> String { + let base = text.split('<').next().unwrap_or(text); + base.rsplit('.').next().unwrap_or(base).trim().to_string() +} + +/// Keep the first occurrence of each `(from_def, name, qualifier)` triple: +/// a def may reference the same symbol many times, but the graph only needs +/// the edge once. +fn dedup_refs(refs: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::with_capacity(refs.len()); + for r in refs { + let key = (r.from_def, r.name.clone(), r.qualifier.clone()); + if seen.insert(key) { + out.push(r); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_root_of_finds_conventional_layout() { + assert_eq!( + source_root_of(Path::new( + "services/billing/src/test/java/com/foo/BarTest.java" + )), + Some(PathBuf::from("services/billing/src/test/java")) + ); + assert_eq!( + source_root_of(Path::new("src/main/java/com/foo/Bar.java")), + Some(PathBuf::from("src/main/java")) + ); + assert_eq!(source_root_of(Path::new("java/com/foo/Bar.java")), None); + } + + #[test] + fn simple_type_name_strips_package_and_generics() { + assert_eq!(simple_type_name("Calc"), "Calc"); + assert_eq!(simple_type_name("com.foo.Calc"), "Calc"); + assert_eq!(simple_type_name("List"), "List"); + assert_eq!(simple_type_name("java.util.List"), "List"); + } + + #[test] + fn test_id_qualifies_outer_class_and_keeps_nesting() { + assert_eq!( + test_id_for(Some("com.foo"), &["BarTest".to_string()], "adds"), + vec!["com.foo.BarTest", "adds"] + ); + assert_eq!( + test_id_for( + Some("com.foo"), + &["BarTest".to_string(), "WhenEmpty".to_string()], + "throws" + ), + vec!["com.foo.BarTest", "WhenEmpty", "throws"] + ); + // Default package: no prefix to qualify with. + assert_eq!( + test_id_for(None, &["BarTest".to_string()], "adds"), + vec!["BarTest", "adds"] + ); + } +} diff --git a/crates/lang-java/tests/extract.rs b/crates/lang-java/tests/extract.rs new file mode 100644 index 0000000..be8057e --- /dev/null +++ b/crates/lang-java/tests/extract.rs @@ -0,0 +1,303 @@ +use std::path::{Path, PathBuf}; +use testless_core::{DefKind, Language}; +use testless_lang_java::JavaLanguage; + +fn extract(src: &str) -> testless_core::Extraction { + let lang = JavaLanguage::default(); + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&lang.grammar(Path::new("X.java"))) + .unwrap(); + let tree = parser.parse(src, None).unwrap(); + lang.extract(src, &tree) +} + +fn fixture(rel: &str) -> String { + std::fs::read_to_string(format!("../../fixtures/java-app/{rel}")).unwrap() +} + +#[test] +fn extracts_classes_and_methods() { + let ex = extract(&fixture("src/main/java/com/example/calc/Calc.java")); + let names: Vec<(&str, DefKind)> = ex.defs.iter().map(|d| (d.name.as_str(), d.kind)).collect(); + assert!(names.contains(&("Calc", DefKind::Class))); + assert!(names.contains(&("Calc.add", DefKind::Method))); + assert!(names.contains(&("Calc.addTwice", DefKind::Method))); + assert!(names.contains(&("", DefKind::ModuleInit))); + // Nothing in a non-test class should be a TestCase. + assert!(!names.iter().any(|(_, k)| *k == DefKind::TestCase)); +} + +#[test] +fn constructors_are_methods() { + let ex = extract(&fixture("src/main/java/com/example/report/Report.java")); + let names: Vec<&str> = ex.defs.iter().map(|d| d.name.as_str()).collect(); + assert!(names.contains(&"Report.Report")); +} + +#[test] +fn extracts_junit_tests_with_fqcn_test_ids() { + let ex = extract(&fixture("src/test/java/com/example/calc/CalcTest.java")); + let ids: Vec> = ex + .defs + .iter() + .filter(|d| d.kind == DefKind::TestCase) + .map(|d| d.test_id.clone().unwrap()) + .collect(); + + assert!(ids.contains(&vec![ + "com.example.calc.CalcTest".to_string(), + "addsNegatives".to_string() + ])); + assert!(ids.contains(&vec![ + "com.example.calc.CalcTest".to_string(), + "multiplies".to_string() + ])); + // A @ParameterizedTest is still filterable by method name. + assert!(ids.contains(&vec![ + "com.example.calc.CalcTest".to_string(), + "addsZeroIdentity".to_string() + ])); + // A @Nested class contributes its simple name as a middle segment. + assert!(ids.contains(&vec![ + "com.example.calc.CalcTest".to_string(), + "WhenDoubling".to_string(), + "doublesTheSum".to_string() + ])); +} + +/// Java test identity is always a static method name — there is no +/// template-literal equivalent — so nothing should ever come back +/// `computed_name`, including the parameterized and nested cases. +#[test] +fn java_tests_are_never_computed() { + let ex = extract(&fixture("src/test/java/com/example/calc/CalcTest.java")); + assert!(ex + .defs + .iter() + .filter(|d| d.kind == DefKind::TestCase) + .all(|d| !d.computed_name)); +} + +#[test] +fn fully_qualified_test_annotation_still_counts() { + let src = r#"package com.foo; +class BarTest { + @org.junit.jupiter.api.Test + void works() {} +} +"#; + let ex = extract(src); + let ids: Vec> = ex + .defs + .iter() + .filter(|d| d.kind == DefKind::TestCase) + .map(|d| d.test_id.clone().unwrap()) + .collect(); + assert_eq!(ids, vec![vec!["com.foo.BarTest", "works"]]); +} + +/// An unannotated method in a test class is a plain `Method`, not a test: +/// selecting it as a runnable test would print a command that matches +/// nothing. +#[test] +fn helper_methods_in_test_classes_are_not_tests() { + let src = r#"package com.foo; +import org.junit.jupiter.api.Test; +class BarTest { + private int helper() { return 1; } + @Test + void works() {} +} +"#; + let ex = extract(src); + let tests: Vec<&str> = ex + .defs + .iter() + .filter(|d| d.kind == DefKind::TestCase) + .map(|d| d.name.as_str()) + .collect(); + assert_eq!(tests, vec!["BarTest.works"]); + assert!(ex + .defs + .iter() + .any(|d| d.name == "BarTest.helper" && d.kind == DefKind::Method)); +} + +#[test] +fn default_package_test_id_has_no_prefix() { + let src = r#"import org.junit.jupiter.api.Test; +class BarTest { + @Test + void works() {} +} +"#; + let ex = extract(src); + let ids: Vec> = ex + .defs + .iter() + .filter(|d| d.kind == DefKind::TestCase) + .map(|d| d.test_id.clone().unwrap()) + .collect(); + assert_eq!(ids, vec![vec!["BarTest", "works"]]); +} + +#[test] +fn collects_imports_including_wildcard_and_static() { + let src = r#"package com.foo; + +import com.foo.core.Calc; +import com.foo.util.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BarTest {} +"#; + let ex = extract(src); + let raws: Vec<&str> = ex.imports.iter().map(|i| i.raw.as_str()).collect(); + assert_eq!( + raws, + vec![ + "com.foo.core.Calc", + "com.foo.util.*", + "org.junit.jupiter.api.Assertions.assertEquals" + ] + ); +} + +/// The `@Test` annotation names a JUnit type, not a symbol this file +/// references; treating it as a read would attach a bogus edge to every +/// test method. +#[test] +fn annotation_names_are_not_reads() { + let src = r#"package com.foo; +import org.junit.jupiter.api.Test; +class BarTest { + @Test + void works() {} +} +"#; + let ex = extract(src); + assert!( + !ex.reads.iter().any(|r| r.name == "Test"), + "annotation name leaked into reads: {:?}", + ex.reads + ); +} + +/// An annotation's *arguments*, unlike its name, hold real references — +/// `@ExtendWith(MockitoExtension.class)` is a genuine dependency. +#[test] +fn annotation_arguments_are_still_scanned() { + let src = r#"package com.foo; +import org.junit.jupiter.api.extension.ExtendWith; +class BarTest { + @ExtendWith(MockitoExtension.class) + void works() {} +} +"#; + let ex = extract(src); + assert!( + ex.reads.iter().any(|r| r.name == "MockitoExtension"), + "annotation argument type was dropped: {:?}", + ex.reads + ); +} + +#[test] +fn package_key_spans_parallel_source_roots() { + let lang = JavaLanguage::default(); + let main = lang.package_key(Path::new("src/main/java/com/example/calc/Calc.java")); + let test = lang.package_key(Path::new("src/test/java/com/example/calc/CalcTest.java")); + assert_eq!(main, test); + assert_eq!(main, Some(PathBuf::from("com/example/calc"))); +} + +#[test] +fn package_key_keeps_modules_apart() { + let lang = JavaLanguage::default(); + let billing = lang.package_key(Path::new("services/billing/src/main/java/com/foo/A.java")); + let ledger = lang.package_key(Path::new("services/ledger/src/main/java/com/foo/A.java")); + assert_ne!(billing, ledger); + assert_eq!(billing, Some(PathBuf::from("services/billing/com/foo"))); +} + +#[test] +fn package_key_falls_back_to_directory_outside_source_roots() { + let lang = JavaLanguage::default(); + assert_eq!( + lang.package_key(Path::new("scripts/Tool.java")), + Some(PathBuf::from("scripts")) + ); +} + +// --- containment rule (discovered on spring-boot / gson) ----------------- + +fn parent_name(ex: &testless_core::Extraction, name: &str) -> Option { + let d = ex.defs.iter().find(|d| d.name == name)?; + d.parent.map(|p| ex.defs[p].name.clone()) +} + +/// A framework-annotated member is invoked by a container, never by name, so +/// it must hang off its class — otherwise a change to it reaches no test at +/// all. Regression for a real spring-boot under-select: editing a `@Bean` +/// body selected 0 of 17,720 tests. +#[test] +fn framework_annotated_members_parent_to_their_class() { + let src = r#"package com.foo; +class Config { + @Bean + JsonFactory jsonFactory() { return new JsonFactory(); } + + @Autowired + private Helper helper; +} +"#; + let ex = extract(src); + assert_eq!( + parent_name(&ex, "Config.jsonFactory").as_deref(), + Some("Config") + ); + assert_eq!(parent_name(&ex, "Config#helper").as_deref(), Some("Config")); +} + +/// `@Override` is on a huge share of Java methods and means nothing to any +/// runtime, so it must NOT imply container invocation. Regression for a real +/// gson over-select: counting it took a leaf edit from 6 to 1501 of 1534. +#[test] +fn inert_annotations_do_not_imply_containment() { + let src = r#"package com.foo; +class Impl { + @Override + public String toString() { return "x"; } + + @SuppressWarnings("unchecked") + void unchecked() {} + + void plain() {} +} +"#; + let ex = extract(src); + for m in ["Impl.toString", "Impl.unchecked", "Impl.plain"] { + assert_eq!(parent_name(&ex, m), None, "{m} should not parent to class"); + } +} + +/// Fields become defs, named `Class#field` rather than `Class.field`: the +/// indexer keys on the segment after the last `.`, so the dotted form would +/// be indexed as bare `field` and two classes' same-named fields would +/// cross-link. +#[test] +fn fields_become_class_scoped_defs() { + let src = r#"package com.foo; +class T { + private final Runner contextRunner = new Runner(); + private int a = 1, b = 2; +} +"#; + let ex = extract(src); + let names: Vec<&str> = ex.defs.iter().map(|d| d.name.as_str()).collect(); + assert!(names.contains(&"T#contextRunner"), "{names:?}"); + // One def per declarator, not per statement. + assert!(names.contains(&"T#a"), "{names:?}"); + assert!(names.contains(&"T#b"), "{names:?}"); +} diff --git a/crates/lang-java/tests/refs.rs b/crates/lang-java/tests/refs.rs new file mode 100644 index 0000000..a925aae --- /dev/null +++ b/crates/lang-java/tests/refs.rs @@ -0,0 +1,327 @@ +use std::path::{Path, PathBuf}; +use testless_core::Language; +use testless_lang_java::JavaLanguage; + +fn extract(src: &str) -> testless_core::Extraction { + let lang = JavaLanguage::default(); + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&lang.grammar(Path::new("X.java"))) + .unwrap(); + let tree = parser.parse(src, None).unwrap(); + lang.extract(src, &tree) +} + +fn fixture(rel: &str) -> String { + std::fs::read_to_string(format!("../../fixtures/java-app/{rel}")).unwrap() +} + +const FIXTURE_ROOT: &str = "../../fixtures/java-app"; + +#[test] +fn extracts_qualified_method_calls() { + let ex = extract(&fixture("src/test/java/com/example/calc/CalcTest.java")); + let from = ex + .defs + .iter() + .position(|d| d.name == "CalcTest.addsNegatives") + .unwrap(); + assert!( + ex.calls.iter().any(|c| c.name == "add" + && c.qualifier.as_deref() == Some("calc") + && c.from_def == from), + "calls: {:?}", + ex.calls + ); +} + +/// `new Calc()` is both a constructor call and a dependency on the class, +/// recorded under the type's simple name so it matches the `Class` def. +#[test] +fn object_creation_is_a_call_on_the_type() { + let ex = extract(&fixture("src/test/java/com/example/report/ReportTest.java")); + let names: Vec<&str> = ex.calls.iter().map(|c| c.name.as_str()).collect(); + assert!(names.contains(&"Calc"), "calls: {:?}", ex.calls); + assert!(names.contains(&"Report"), "calls: {:?}", ex.calls); +} + +#[test] +fn qualified_object_creation_uses_simple_name() { + let src = r#"package com.foo; +class A { + void go() { Object x = new com.example.calc.Calc(); } +} +"#; + let ex = extract(src); + assert!( + ex.calls.iter().any(|c| c.name == "Calc"), + "calls: {:?}", + ex.calls + ); +} + +/// A field or parameter *type* is a real structural dependency even when no +/// method of it is called by name — this is the edge that makes +/// dependency-injected Java code visible to the graph at all. +#[test] +fn field_and_parameter_types_are_reads() { + let ex = extract(&fixture("src/main/java/com/example/report/Report.java")); + assert!( + ex.reads.iter().any(|r| r.name == "Calc"), + "reads: {:?}", + ex.reads + ); +} + +#[test] +fn injected_type_is_seen_even_with_no_call() { + let src = r#"package com.foo; +class Service { + private final Repo repo; + Service(Repo repo) { this.repo = repo; } +} +"#; + let ex = extract(src); + assert!( + ex.reads.iter().any(|r| r.name == "Repo"), + "reads: {:?}", + ex.reads + ); +} + +/// Refs must attribute to the enclosing method, not to the class or to +/// ``, or impact would widen to every test in the file. +#[test] +fn refs_attribute_to_the_enclosing_method() { + let ex = extract(&fixture("src/test/java/com/example/calc/CalcTest.java")); + let multiplies = ex + .defs + .iter() + .position(|d| d.name == "CalcTest.multiplies") + .unwrap(); + let mul_call = ex + .calls + .iter() + .find(|c| c.name == "mul") + .expect("mul call missing"); + assert_eq!(mul_call.from_def, multiplies); +} + +#[test] +fn nested_class_method_refs_attribute_to_the_nested_method() { + let ex = extract(&fixture("src/test/java/com/example/calc/CalcTest.java")); + let nested = ex + .defs + .iter() + .position(|d| d.name == "WhenDoubling.doublesTheSum") + .unwrap(); + let call = ex + .calls + .iter() + .find(|c| c.name == "addTwice") + .expect("addTwice call missing"); + assert_eq!(call.from_def, nested); +} + +// --- resolve_import ------------------------------------------------------ + +#[test] +fn resolves_same_module_import_to_the_class_file() { + let lang = JavaLanguage::default(); + let resolved = lang.resolve_import( + Path::new("src/test/java/com/example/report/ReportTest.java"), + "com.example.calc.Calc", + Path::new(FIXTURE_ROOT), + ); + assert_eq!( + resolved, + Some(PathBuf::from("src/main/java/com/example/calc/Calc.java")) + ); +} + +/// The importing file's own source root is tried first, but a class that +/// only exists in the *other* root must still resolve — that cross-root +/// hop is how every test reaches the code it tests. +#[test] +fn resolves_across_source_roots() { + let lang = JavaLanguage::default(); + let resolved = lang.resolve_import( + Path::new("src/main/java/com/example/report/Report.java"), + "com.example.calc.Calc", + Path::new(FIXTURE_ROOT), + ); + assert_eq!( + resolved, + Some(PathBuf::from("src/main/java/com/example/calc/Calc.java")) + ); +} + +#[test] +fn resolves_wildcard_import_to_the_package_directory() { + let lang = JavaLanguage::default(); + let resolved = lang.resolve_import( + Path::new("src/test/java/com/example/report/ReportTest.java"), + "com.example.calc.*", + Path::new(FIXTURE_ROOT), + ); + assert_eq!( + resolved, + Some(PathBuf::from("src/main/java/com/example/calc")) + ); +} + +/// `import static a.b.C.member` names a member, so the last segment has to +/// be dropped before the file probe can hit `C.java`. +#[test] +fn resolves_static_member_import_to_its_owning_class() { + let lang = JavaLanguage::default(); + let resolved = lang.resolve_import( + Path::new("src/test/java/com/example/report/ReportTest.java"), + "com.example.calc.Calc.add", + Path::new(FIXTURE_ROOT), + ); + assert_eq!( + resolved, + Some(PathBuf::from("src/main/java/com/example/calc/Calc.java")) + ); +} + +#[test] +fn external_imports_do_not_resolve() { + let lang = JavaLanguage::default(); + for raw in [ + "java.util.List", + "org.junit.jupiter.api.Test", + "com.nope.Missing", + ] { + assert_eq!( + lang.resolve_import( + Path::new("src/test/java/com/example/calc/CalcTest.java"), + raw, + Path::new(FIXTURE_ROOT), + ), + None, + "{raw} should not resolve" + ); + } +} + +// --- field wiring (discovered on spring-boot / gson) --------------------- + +/// The JUnit fixture-field shape: the dependency lives on a field +/// initializer and every test reaches it through `this.`. Without +/// both halves — a def for the field, and extraction of `this.x` — the +/// chain from the fixture to the tests does not exist. +#[test] +fn this_field_access_links_methods_to_the_fixture_field() { + let src = r#"package com.foo; +import org.junit.jupiter.api.Test; + +class BarTest { + private final Runner contextRunner = Auto.of(Subject.class); + + @Test + void usesFixture() { + this.contextRunner.run(); + } + + @Test + void usesFixtureUnqualified() { + contextRunner.run(); + } +} +"#; + let ex = extract(src); + let field = ex + .defs + .iter() + .position(|d| d.name == "BarTest#contextRunner") + .expect("field def missing"); + // The initializer's dependency attributes to the field, not the class. + assert!( + ex.reads + .iter() + .any(|r| r.name == "Subject" && r.from_def == field), + "reads: {:?}", + ex.reads + ); + // Both `this.x` and bare `x` link their method to the field def. + for method in ["BarTest.usesFixture", "BarTest.usesFixtureUnqualified"] { + let idx = ex.defs.iter().position(|d| d.name == method).unwrap(); + assert!( + ex.reads + .iter() + .any(|r| r.name == "BarTest#contextRunner" && r.from_def == idx), + "{method} did not read the fixture field: {:?}", + ex.reads + ); + } +} + +/// Two classes each holding a same-named field must not link together: a +/// bare field name resolves in its own class, never a sibling's. Regression +/// for a real gson over-select, where every test class's `gson` field +/// cross-linked. +#[test] +fn same_named_fields_in_different_classes_do_not_collide() { + let src = r#"package com.foo; +class A { + private final Gson gson = new Gson(); + void useA() { gson.toJson(1); } +} +class B { + private final Gson gson = new Gson(); + void useB() { gson.toJson(2); } +} +"#; + let ex = extract(src); + let use_a = ex.defs.iter().position(|d| d.name == "A.useA").unwrap(); + let use_b = ex.defs.iter().position(|d| d.name == "B.useB").unwrap(); + assert!(ex + .reads + .iter() + .any(|r| r.name == "A#gson" && r.from_def == use_a)); + assert!(ex + .reads + .iter() + .any(|r| r.name == "B#gson" && r.from_def == use_b)); + // Neither method may reference the other class's field. + assert!(!ex + .reads + .iter() + .any(|r| r.name == "B#gson" && r.from_def == use_a)); + assert!(!ex + .reads + .iter() + .any(|r| r.name == "A#gson" && r.from_def == use_b)); +} + +/// An *inherited* field isn't in this file's own class, so it must fall back +/// to bare-name matching rather than resolving to nothing — over-approximate +/// rather than miss the declaration. +#[test] +fn inherited_field_falls_back_to_bare_name() { + let src = r#"package com.foo; +import org.junit.jupiter.api.Test; + +class ChildTest extends AbstractBaseTest { + @Test + void usesInherited() { gson.toJson(1); } +} +"#; + let ex = extract(src); + let idx = ex + .defs + .iter() + .position(|d| d.name == "ChildTest.usesInherited") + .unwrap(); + // Not `ChildTest#gson` — the class doesn't declare it — but the bare + // name must still be recorded so the superclass's field is reachable. + assert!( + !ex.reads + .iter() + .any(|r| r.name == "ChildTest#gson" && r.from_def == idx), + "reads: {:?}", + ex.reads + ); +} diff --git a/fixtures/java-app/build.gradle b/fixtures/java-app/build.gradle new file mode 100644 index 0000000..03975fa --- /dev/null +++ b/fixtures/java-app/build.gradle @@ -0,0 +1,16 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' +} + +test { + useJUnitPlatform() +} diff --git a/fixtures/java-app/settings.gradle b/fixtures/java-app/settings.gradle new file mode 100644 index 0000000..f3eb5c4 --- /dev/null +++ b/fixtures/java-app/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'java-app' diff --git a/fixtures/java-app/src/main/java/com/example/calc/Calc.java b/fixtures/java-app/src/main/java/com/example/calc/Calc.java new file mode 100644 index 0000000..973d498 --- /dev/null +++ b/fixtures/java-app/src/main/java/com/example/calc/Calc.java @@ -0,0 +1,28 @@ +package com.example.calc; + +import java.util.ArrayList; +import java.util.List; + +public class Calc { + private final List history = new ArrayList<>(); + + public int add(int a, int b) { + int sum = a + b; + history.add(sum); + return sum; + } + + public int mul(int a, int b) { + return a * b; + } + + /// Same-package reference with no import statement: only the package + /// scope extension makes this edge resolvable. + public int addTwice(int a, int b) { + return Doubler.twice(add(a, b)); + } + + public int size() { + return history.size(); + } +} diff --git a/fixtures/java-app/src/main/java/com/example/calc/Doubler.java b/fixtures/java-app/src/main/java/com/example/calc/Doubler.java new file mode 100644 index 0000000..da9c157 --- /dev/null +++ b/fixtures/java-app/src/main/java/com/example/calc/Doubler.java @@ -0,0 +1,7 @@ +package com.example.calc; + +public class Doubler { + public static int twice(int n) { + return n * 2; + } +} diff --git a/fixtures/java-app/src/main/java/com/example/report/Report.java b/fixtures/java-app/src/main/java/com/example/report/Report.java new file mode 100644 index 0000000..fd8f1d5 --- /dev/null +++ b/fixtures/java-app/src/main/java/com/example/report/Report.java @@ -0,0 +1,18 @@ +package com.example.report; + +import com.example.calc.Calc; + +/// Cross-package dependency: reaches `Calc` only through a real import, and +/// holds it as a *field type* too, which is the shape dependency-injected +/// Java code takes. +public class Report { + private final Calc calc; + + public Report(Calc calc) { + this.calc = calc; + } + + public String summarize(int a, int b) { + return "sum=" + calc.add(a, b); + } +} diff --git a/fixtures/java-app/src/test/java/com/example/calc/CalcTest.java b/fixtures/java-app/src/test/java/com/example/calc/CalcTest.java new file mode 100644 index 0000000..0818963 --- /dev/null +++ b/fixtures/java-app/src/test/java/com/example/calc/CalcTest.java @@ -0,0 +1,39 @@ +package com.example.calc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/// Lives in package `com.example.calc` but under `src/test/java`, so it +/// references `Calc` with no import at all — the parallel-source-root case +/// that a directory-keyed package scope would miss. +class CalcTest { + private final Calc calc = new Calc(); + + @Test + void addsNegatives() { + assertEquals(-3, calc.add(-1, -2)); + } + + @Test + void multiplies() { + assertEquals(6, calc.mul(2, 3)); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3}) + void addsZeroIdentity(int n) { + assertEquals(n, calc.add(n, 0)); + } + + @Nested + class WhenDoubling { + @Test + void doublesTheSum() { + assertEquals(6, calc.addTwice(1, 2)); + } + } +} diff --git a/fixtures/java-app/src/test/java/com/example/report/ReportTest.java b/fixtures/java-app/src/test/java/com/example/report/ReportTest.java new file mode 100644 index 0000000..c35c04f --- /dev/null +++ b/fixtures/java-app/src/test/java/com/example/report/ReportTest.java @@ -0,0 +1,13 @@ +package com.example.report; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.example.calc.Calc; +import org.junit.jupiter.api.Test; + +class ReportTest { + @Test + void summarizes() { + assertEquals("sum=3", new Report(new Calc()).summarize(1, 2)); + } +} diff --git a/release-please-config.json b/release-please-config.json index 4c775ed..5645594 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -6,6 +6,7 @@ "crates/lang-ts": { "package-name": "testless-lang-ts", "component": "testless-lang-ts" }, "crates/lang-go": { "package-name": "testless-lang-go", "component": "testless-lang-go" }, "crates/lang-rust": { "package-name": "testless-lang-rust", "component": "testless-lang-rust" }, + "crates/lang-java": { "package-name": "testless-lang-java", "component": "testless-lang-java" }, "crates/cli": { "package-name": "testless", "component": "testless" } }, "plugins": [ @@ -13,7 +14,7 @@ { "type": "linked-versions", "groupName": "testless", - "components": ["testless-core", "testless-lang-ts", "testless-lang-go", "testless-lang-rust", "testless"] + "components": ["testless-core", "testless-lang-ts", "testless-lang-go", "testless-lang-rust", "testless-lang-java", "testless"] } ] } diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 8fd2857..dba0851 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -5,7 +5,7 @@ const repo = "https://github.com/itaywol/testless"; const siteUrl = "https://testless.itaywol.tools/"; const title = "testless: cut your test time to the minimum"; const description = - "testless statically analyzes your code to pick only the tests a change could break: a superset of the truly impacted tests. TypeScript/JavaScript, Go, Rust."; + "testless statically analyzes your code to pick only the tests a change could break: a superset of the truly impacted tests. TypeScript/JavaScript, Go, Rust, Java."; const ogImage = "https://testless.itaywol.tools/social-card.png"; const ogImageWidth = "1280"; const ogImageHeight = "640";