jmove/tests/cli_fix.rs
loki5512344 7c8498bc2a
feat(ts): ts/unused-import rule — whole-statement delete of dead TS/JS imports
- 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)
2026-09-15 17:07:46 +02:00

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();
}