- binds names via tree-sitter (default, namespace, named + alias, type-only), keeps a statement when ANY bound name occurs outside it (word scan over comments/strings included): under-delete is the safe direction, mirroring the Java rule's asymmetry - ESM caveat encoded in the policy: mixed live/dead statements are not touched — removing a specifier would still drop the module's side effects - side-effect imports and 'export .. from' re-exports are never candidates - parser/ts became a folder (mod/extract/unused_imports) to keep the 4-entries-per-module rule; word_occurs shared via crate::parser - java/rules/unused_imports now reuses the shared word scanner (DRY)
273 lines
9.5 KiB
Rust
273 lines
9.5 KiB
Rust
//! End-to-end tests for `jmove fix` against a Java fixture.
|
|
|
|
mod common;
|
|
|
|
use common::{in_root, jmove, read};
|
|
use predicates::prelude::*;
|
|
|
|
fn dirty_project() -> tempfile::TempDir {
|
|
common::copy_fixture("java", "fix")
|
|
}
|
|
|
|
#[test]
|
|
fn fix_dry_run_previews_deletions_without_writing() {
|
|
let tmp = dirty_project();
|
|
jmove(&tmp, &["fix", "--dry-run"])
|
|
.success()
|
|
.stdout(predicate::str::contains(
|
|
"-import com.example.unused.Ghost;",
|
|
))
|
|
.stdout(predicate::str::contains(" import java.util.List;"));
|
|
// Disk untouched: the unused import is still there.
|
|
let app = read(&in_root(
|
|
tmp.path(),
|
|
"src/main/java/com/example/app/App.java",
|
|
));
|
|
assert!(app.contains("import com.example.unused.Ghost;"), "{app}");
|
|
}
|
|
|
|
#[test]
|
|
fn fix_removes_unused_and_keeps_used_and_string_mentions() {
|
|
let tmp = dirty_project();
|
|
jmove(&tmp, &["fix"])
|
|
.success()
|
|
.stdout(predicate::str::contains("fixed 1 issue in 1 file"));
|
|
|
|
let app = read(&in_root(
|
|
tmp.path(),
|
|
"src/main/java/com/example/app/App.java",
|
|
));
|
|
// Unused single-type import is gone, whole line removed.
|
|
assert!(!app.contains("com.example.unused.Ghost"), "{app}");
|
|
// Used import stays.
|
|
assert!(app.contains("import com.example.Text;"), "{app}");
|
|
// `List` only appears inside a string literal => kept (safe direction).
|
|
assert!(app.contains("import java.util.List;"), "{app}");
|
|
assert!(app.contains("\"java.util.List\""), "{app}");
|
|
|
|
// Run 2: convergence — the deferred `java/import-order` fix (it
|
|
// overlapped the deletion in run 1) now applies on the clean block.
|
|
jmove(&tmp, &["fix"])
|
|
.success()
|
|
.stdout(predicate::str::contains("fixed 1 issue in 1 file"));
|
|
let app = read(&in_root(
|
|
tmp.path(),
|
|
"src/main/java/com/example/app/App.java",
|
|
));
|
|
assert!(
|
|
app.contains(
|
|
"import static com.example.Text.shout;\n\nimport com.example.Text;\nimport java.util.List;\n"
|
|
),
|
|
"{app}"
|
|
);
|
|
// Run 3: fixed point — nothing left to change.
|
|
jmove(&tmp, &["fix"])
|
|
.success()
|
|
.stdout(predicate::str::contains("nothing to change"));
|
|
// The project still checks clean.
|
|
jmove(&tmp, &["check"]).success();
|
|
}
|
|
|
|
#[test]
|
|
fn fix_json_reports_candidates_and_applied_count() {
|
|
let tmp = dirty_project();
|
|
jmove(&tmp, &["fix", "--dry-run", "--json"])
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("\"status\": \"dry_run\"")
|
|
.and(predicate::str::contains("\"operation\": \"fix\""))
|
|
.and(predicate::str::contains("\"rule\": \"java/unused-import\""))
|
|
.and(predicate::str::contains("\"would_fix\": 1")),
|
|
);
|
|
|
|
jmove(&tmp, &["fix", "--json"]).success().stdout(
|
|
predicate::str::contains("\"status\": \"ok\"")
|
|
.and(predicate::str::contains("\"fixes\": 1"))
|
|
.and(predicate::str::contains("\"files_changed\": 1")),
|
|
);
|
|
let app = read(&in_root(
|
|
tmp.path(),
|
|
"src/main/java/com/example/app/App.java",
|
|
));
|
|
assert!(!app.contains("Ghost"), "{app}");
|
|
}
|
|
|
|
#[test]
|
|
fn fix_unknown_rule_is_rejected() {
|
|
let tmp = dirty_project();
|
|
jmove(&tmp, &["fix", "--rule", "does/not-exist"])
|
|
.failure()
|
|
.code(1)
|
|
.stderr(predicate::str::contains(
|
|
"unknown fix rule 'does/not-exist'",
|
|
))
|
|
.stderr(predicate::str::contains("java/unused-import"));
|
|
// Nothing on disk changed.
|
|
let app = read(&in_root(
|
|
tmp.path(),
|
|
"src/main/java/com/example/app/App.java",
|
|
));
|
|
assert!(app.contains("Ghost"), "{app}");
|
|
}
|
|
|
|
#[test]
|
|
fn fix_scoped_to_rule_and_reports_guava_like_sibling() {
|
|
// `Text` is referenced only by a static member import of the same type,
|
|
// so neither import is provably dead (mirrors the Guava smoke note).
|
|
let tmp = dirty_project();
|
|
let app = read(&in_root(
|
|
tmp.path(),
|
|
"src/main/java/com/example/app/App.java",
|
|
));
|
|
assert!(
|
|
app.contains("import static com.example.Text.shout;"),
|
|
"{app}"
|
|
);
|
|
jmove(&tmp, &["fix", "--rule", "java/unused-import"])
|
|
.success()
|
|
.stdout(predicate::str::contains("fixed 1 issue in 1 file"));
|
|
let app = read(&in_root(
|
|
tmp.path(),
|
|
"src/main/java/com/example/app/App.java",
|
|
));
|
|
assert!(app.contains("import com.example.Text;"), "{app}");
|
|
assert!(
|
|
app.contains("import static com.example.Text.shout;"),
|
|
"{app}"
|
|
);
|
|
}
|
|
|
|
fn missing_project() -> tempfile::TempDir {
|
|
common::copy_fixture("java", "fix_missing")
|
|
}
|
|
|
|
#[test]
|
|
fn missing_import_adds_unique_and_reports_ambiguous() {
|
|
let tmp = missing_project();
|
|
jmove(&tmp, &["fix", "--rule", "java/missing-import", "--json"])
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("\"rule\": \"java/missing-import\"")
|
|
.and(predicate::str::contains("\"applied\": false"))
|
|
.and(predicate::str::contains("\"com.example.a.Config\""))
|
|
.and(predicate::str::contains("\"com.example.b.Config\"")),
|
|
);
|
|
let calc = read(&in_root(
|
|
tmp.path(),
|
|
"src/main/java/com/example/app/Calc.java",
|
|
));
|
|
assert!(
|
|
calc.contains("package com.example.app;\nimport com.example.util.Maths;\n"),
|
|
"{calc}"
|
|
);
|
|
// The ambiguous `Config` is never guessed at.
|
|
let refer = read(&in_root(
|
|
tmp.path(),
|
|
"src/main/java/com/example/c/Refer.java",
|
|
));
|
|
assert!(!refer.contains("import com.example."), "{refer}");
|
|
jmove(&tmp, &["check"]).success();
|
|
}
|
|
|
|
#[test]
|
|
fn missing_import_dry_run_then_stable_reapply() {
|
|
let tmp = missing_project();
|
|
jmove(&tmp, &["fix", "--dry-run"])
|
|
.success()
|
|
.stdout(predicate::str::contains("+import com.example.util.Maths;"));
|
|
jmove(&tmp, &["fix"])
|
|
.success()
|
|
.stdout(predicate::str::contains("fixed 3 issues in 2 files"));
|
|
// Only manual (ambiguous) findings remain: nothing further applies.
|
|
jmove(&tmp, &["fix"])
|
|
.success()
|
|
.stdout(predicate::str::contains("nothing to change"));
|
|
}
|
|
|
|
#[test]
|
|
fn unused_delete_and_missing_insert_coexist_in_one_file() {
|
|
// Dual.java: the unused import is deleted while the missing `Maths`
|
|
// import lands at the very byte of the deleted line's end — adjacent,
|
|
// not overlapping, so one atomic plan carries both.
|
|
let tmp = missing_project();
|
|
let dual = "src/main/java/com/example/app/Dual.java";
|
|
jmove(&tmp, &["fix"]).success();
|
|
let text = read(&in_root(tmp.path(), dual));
|
|
assert!(!text.contains("Gone"), "{text}");
|
|
assert!(text.contains("import com.example.util.Maths;"), "{text}");
|
|
jmove(&tmp, &["check"]).success();
|
|
}
|
|
|
|
#[test]
|
|
fn three_rules_converge_on_one_project() {
|
|
// App.java: unused import inside an out-of-order block (the order
|
|
// rewrite overlaps the deletion, so it defers to run 2).
|
|
// C.java: out-of-order block + a bare `Maths` reference (insertion at
|
|
// the block boundary coexists with the rewrite in run 1).
|
|
let tmp = common::copy_fixture("java", "order");
|
|
jmove(&tmp, &["fix", "--dry-run", "--json"])
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("\"rule\": \"java/import-order\"")
|
|
.and(predicate::str::contains("\"applied\": false"))
|
|
.and(predicate::str::contains("skipped")),
|
|
);
|
|
let app = "src/main/java/com/example/app/App.java";
|
|
let c = "src/main/java/com/example/app/C.java";
|
|
jmove(&tmp, &["fix"])
|
|
.success()
|
|
.stdout(predicate::str::contains("fixed 3 issues in 2 files"));
|
|
let app_text = read(&in_root(tmp.path(), app));
|
|
// Unused is gone; the deferred order fix has not touched the block.
|
|
assert!(!app_text.contains("Unneeded"), "{app_text}");
|
|
assert!(
|
|
app_text.contains("import java.util.List;\nimport com.example.util.Maths;"),
|
|
"{app_text}"
|
|
);
|
|
jmove(&tmp, &["fix"])
|
|
.success()
|
|
.stdout(predicate::str::contains("fixed 2 issues in 2 files"));
|
|
let c_text = read(&in_root(tmp.path(), c));
|
|
assert!(
|
|
c_text.contains(
|
|
"import com.example.util.Maths;\nimport java.util.List;\nimport java.util.Map;\n"
|
|
),
|
|
"{c_text}"
|
|
);
|
|
let app_text = read(&in_root(tmp.path(), app));
|
|
assert!(
|
|
app_text.contains(
|
|
"import com.example.util.Maths;\nimport java.util.List;\nimport java.util.Map;\n"
|
|
),
|
|
"{app_text}"
|
|
);
|
|
// Fixed point.
|
|
jmove(&tmp, &["fix"])
|
|
.success()
|
|
.stdout(predicate::str::contains("nothing to change"));
|
|
jmove(&tmp, &["check"]).success();
|
|
}
|
|
|
|
fn ts_unused() -> tempfile::TempDir {
|
|
common::copy_fixture("typescript", "unused")
|
|
}
|
|
|
|
#[test]
|
|
fn ts_unused_import_deletes_dead_type_import_and_keeps_the_rest() {
|
|
let tmp = ts_unused();
|
|
// The whole-statement delete must fire for the dead `Ghost` type import.
|
|
jmove(&tmp, &["fix", "--rule", "ts/unused-import", "--json"])
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("\"rule\": \"ts/unused-import\"")
|
|
.and(predicate::str::contains("\"applied\": true")),
|
|
);
|
|
let app = read(&in_root(tmp.path(), "src/app.ts"));
|
|
assert!(!app.contains("Ghost"), "{app}");
|
|
// Side-effect import, the mixed used/unused statement and the default
|
|
// class import all stay (under-delete safety).
|
|
assert!(app.contains("import './side-effects';"), "{app}");
|
|
assert!(app.contains("unused"), "{app}");
|
|
assert!(app.contains("import Logger"), "{app}");
|
|
jmove(&tmp, &["check"]).success();
|
|
}
|