feat: fix engine (Phase 1.6) + git-aware mv + windows-safe path output

- Edit engine: generic span edits (replace/insert/delete) with stale-index
  guard; mv and fix share one atomic apply/rollback/diff/--json pipeline
- jmove mv|check|fix commands; java rules: unused-import, missing-import
  (unique-FQN insert, ambiguous -> candidates for agents), import-order
  (google style; cross-rule byte conflicts prune by severity and converge)
- mv of tracked files goes through git mv (staged rename, --no-git opt-out,
  moved_via/would_move_via in --json, GIT_ERROR code)
- fix java extract_package losing single-segment packages (bare identifier,
  not scoped_identifier) — broke the FQN index for 'package p;' projects
- core::rel_str: project-relative paths rendered with '/' on every platform
  (human/JSON/diff headers/git pathspecs), Path::display() no longer used
  for output; e2e fixtures: java/fix, java/fix_missing, java/order
This commit is contained in:
loki5512344 2026-09-15 16:10:04 +02:00
parent 0216fd6523
commit 9dda5f4562
Signed by: boba
GPG key ID: 253067914055423B
37 changed files with 2521 additions and 234 deletions

View file

@ -1,30 +1,45 @@
//! Unified-diff rendering of a plan, used by `mv --dry-run`.
//! Unified-diff rendering of edit plans, used by `--dry-run` of every
//! command: [`render_edits_diff`] is the generic engine over per-file edit
//! groups, [`render_diff`] adds the `mv` rename line.
use similar::TextDiff;
use crate::core::JmoveResult;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::core::Edit;
use crate::core::apply::fsops::{group_by_file, rewrite_bytes};
use crate::core::plan::MovePlan;
use std::path::Path;
use crate::core::{JmoveResult, rel_str};
/// Render the plan as a unified diff per rewritten file plus a final
/// `move <source> -> <target>` line, for dry-run. A plan without rewrites
/// renders the empty string.
pub fn render_diff(root: &Path, plan: &MovePlan) -> JmoveResult<String> {
let mut out = render_edits_diff(root, &group_by_file(plan))?;
if !out.is_empty() {
let (src, dst) = (rel_str(&plan.source), rel_str(&plan.target));
out.push_str(&format!("move {src} -> {dst}\n"));
}
Ok(out)
}
/// Render pre-grouped edits (the `fix` dry-run shape) as one unified diff
/// per changed file; files with empty edit groups render nothing.
pub fn render_edits_diff(
root: &Path,
by_file: &BTreeMap<PathBuf, Vec<Edit>>,
) -> JmoveResult<String> {
let mut out = String::new();
for (file, rewrites) in group_by_file(plan) {
let original = std::fs::read(root.join(&file))?;
let patched = rewrite_bytes(&original, &rewrites)?;
let name = file.display().to_string();
for (file, edits) in by_file {
let original = std::fs::read(root.join(file))?;
let patched = rewrite_bytes(file, &original, edits)?;
let name = rel_str(file);
let old = String::from_utf8_lossy(&original);
let new = String::from_utf8_lossy(&patched);
let text = TextDiff::from_lines(&old, &new);
out.push_str(&text.unified_diff().header(&name, &name).to_string());
}
if !out.is_empty() {
let (src, dst) = (plan.source.display(), plan.target.display());
out.push_str(&format!("move {src} -> {dst}\n"));
}
Ok(out)
}

View file

@ -5,14 +5,16 @@ use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::core::plan::{MovePlan, Rewrite};
use crate::core::{JmoveError, JmoveResult};
use crate::core::Edit;
use crate::core::plan::MovePlan;
use crate::core::{JmoveError, JmoveResult, rel_str};
// Group rewrites by file; the BTreeMap keeps the order deterministic.
pub(super) fn group_by_file(plan: &MovePlan) -> BTreeMap<PathBuf, Vec<&Rewrite>> {
let mut m: BTreeMap<PathBuf, Vec<&Rewrite>> = BTreeMap::new();
// Group plan rewrites as generic per-file edits; the BTreeMap keeps the
// order deterministic.
pub(super) fn group_by_file(plan: &MovePlan) -> BTreeMap<PathBuf, Vec<Edit>> {
let mut m: BTreeMap<PathBuf, Vec<Edit>> = BTreeMap::new();
for r in &plan.rewrites {
m.entry(r.file.clone()).or_default().push(r);
m.entry(r.file.clone()).or_default().push(r.into());
}
m
}
@ -24,24 +26,46 @@ pub(super) fn sibling_temp(path: &Path) -> PathBuf {
PathBuf::from(temp)
}
// Apply byte-span replacements in reverse offset order so earlier spans
// stay valid; a span/content mismatch means the plan is stale. Valid
// UTF-8 needles can only match on char boundaries, so a successful
// rewrite of valid UTF-8 stays valid UTF-8.
pub(super) fn rewrite_bytes(original: &[u8], rewrites: &[&Rewrite]) -> JmoveResult<Vec<u8>> {
// Byte `i` is a UTF-8 char boundary iff it sits at/after the end or is
// not a continuation byte (`10xx_xxxx`).
fn is_char_boundary(bytes: &[u8], i: usize) -> bool {
bytes.get(i).is_none_or(|b| b & 0xC0 != 0x80)
}
// Apply edits in reverse span order so earlier offsets stay valid; a
// span/content mismatch means the plan is stale. Equal starts (insertions
// at one offset) keep their input order in the output because the
// stable-ascending sort is applied back-to-front. Overlapping or
// malformed spans are generator bugs and rejected before any byte of
// `out` changes. Non-empty needles can only match valid UTF-8 on char
// boundaries; the explicit boundary check pins insertions the same way,
// so patching UTF-8 stays UTF-8.
pub(super) fn rewrite_bytes(file: &Path, original: &[u8], edits: &[Edit]) -> JmoveResult<Vec<u8>> {
let mut sorted: Vec<&Edit> = edits.iter().collect();
sorted.sort_by_key(|e| (e.span.start, e.span.end));
if sorted.iter().any(|e| e.span.start > e.span.end)
|| sorted.windows(2).any(|w| w[1].span.start < w[0].span.end)
{
return Err(JmoveError::PlanRejected(format!(
"overlapping or malformed edits in '{}': {:?}",
rel_str(file),
edits.iter().map(|e| e.span.clone()).collect::<Vec<_>>()
)));
}
let mut out = original.to_vec();
let mut sorted = rewrites.to_vec();
sorted.sort_by_key(|r| std::cmp::Reverse(r.span.start));
for r in sorted {
if r.span.end > out.len() || &out[r.span.clone()] != r.old_text.as_bytes() {
for e in sorted.iter().rev() {
if !is_char_boundary(&out, e.span.start)
|| e.span.end > out.len()
|| &out[e.span.clone()] != e.old_text.as_bytes()
{
return Err(JmoveError::StaleIndex(format!(
"'{}' changed since indexing (expected {:?} at {:?})",
r.file.display(),
r.old_text,
r.span
rel_str(file),
e.old_text,
e.span
)));
}
out.splice(r.span.clone(), r.new_text.as_bytes().iter().copied());
out.splice(e.span.clone(), e.new_text.as_bytes().iter().copied());
}
Ok(out)
}
@ -69,3 +93,69 @@ pub(super) fn create_missing_dirs(dst: &Path) -> JmoveResult<Vec<PathBuf>> {
}
Ok(created)
}
#[cfg(test)]
mod tests {
use super::rewrite_bytes;
use crate::core::{Edit, JmoveError};
use std::path::Path;
fn file() -> &'static Path {
Path::new("f.txt")
}
fn edit(start: usize, end: usize, old: &str, new: &str) -> Edit {
Edit {
span: start..end,
old_text: old.into(),
new_text: new.into(),
}
}
fn text(bytes: &[u8]) -> String {
String::from_utf8(bytes.to_vec()).expect("valid utf-8")
}
#[test]
fn replaces_inserts_and_deletes_in_one_pass() {
// "one\ntwo\nthree\n": insert a line at 0, replace `two`, delete
// the whole `three` line via its span absorbing the trailing `\n`.
let src = b"one\ntwo\nthree\n";
let edits = [
edit(4, 7, "two", "TWO"),
edit(8, 14, "three\n", ""),
edit(0, 0, "", "zero\n"),
];
let out = rewrite_bytes(file(), src, &edits).unwrap();
assert_eq!(text(&out), "zero\none\nTWO\n");
}
#[test]
fn insertions_at_one_offset_keep_input_order() {
let src = b"head\ntail\n";
let edits = [edit(5, 5, "", "b\n"), edit(5, 5, "", "a\n")];
let out = rewrite_bytes(file(), src, &edits).unwrap();
assert_eq!(text(&out), "head\nb\na\ntail\n");
}
#[test]
fn overlapping_or_malformed_spans_are_rejected() {
let src = b"abcdefgh";
let overlap = [edit(2, 6, "cdef", "X"), edit(4, 8, "efgh", "Y")];
let err = rewrite_bytes(file(), src, &overlap).unwrap_err();
assert!(matches!(err, JmoveError::PlanRejected(_)), "{err}");
let malformed = [edit(6, 2, "", "X")];
let err = rewrite_bytes(file(), src, &malformed).unwrap_err();
assert!(matches!(err, JmoveError::PlanRejected(_)), "{err}");
}
#[test]
fn stale_or_off_boundary_edits_are_rejected() {
// Wrong expectation at the span => the file moved under the plan.
let err = rewrite_bytes(file(), b"abcd", &[edit(0, 4, "wrong", "X")]).unwrap_err();
assert!(matches!(err, JmoveError::StaleIndex(_)), "{err}");
// Insertion inside the 2-byte `ä` is not a char boundary.
let err = rewrite_bytes(file(), "ä\n".as_bytes(), &[edit(1, 1, "", "x")]).unwrap_err();
assert!(matches!(err, JmoveError::StaleIndex(_)), "{err}");
}
}

164
src/core/apply/git.rs Normal file
View file

@ -0,0 +1,164 @@
//! Git integration: move tracked files through `git mv` so the rename
//! lands in the index (staged, with history detection) instead of being a
//! plain filesystem rename. Shells out to the system `git` — no new
//! dependency, and `git` is the only sane implementation of its own index.
use std::path::Path;
use std::process::Command;
use crate::core::{JmoveError, JmoveResult, rel_str};
/// Whether `mv` may involve git in the physical rename.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GitMode {
/// `git mv` when the source is tracked; plain rename otherwise.
#[default]
Auto,
/// Never touch git (CLI `--no-git`).
Disabled,
}
impl GitMode {
/// CLI mapping: the `--no-git` flag switches to [`GitMode::Disabled`].
#[must_use]
pub const fn from_no_git(no_git: bool) -> Self {
if no_git { Self::Disabled } else { Self::Auto }
}
}
/// True when `rel` (root-relative) is tracked in the git index of the
/// repository containing `root`. A missing git binary, a non-repository
/// root or an untracked file all count as "not tracked".
pub(super) fn is_tracked(root: &Path, rel: &Path) -> bool {
let rel = rel_str(rel);
git_output(root, &["ls-files", "--", &rel]).is_some_and(|out| !out.trim().is_empty())
}
/// True when a rename of `rel` under `root` would go through `git mv`.
#[must_use]
pub fn would_use_git(root: &Path, rel: &Path, mode: GitMode) -> bool {
mode == GitMode::Auto && is_tracked(root, rel)
}
/// `git mv <src> <dst>` with root-relative paths; stderr on failure is
/// surfaced as [`JmoveError::Git`].
pub(super) fn mv(root: &Path, src: &Path, dst: &Path) -> JmoveResult<()> {
let (src, dst) = (rel_str(src), rel_str(dst));
git_run(root, &["mv", "--", &src, &dst])
}
// Run git inside `root`; None when git is absent or exits non-zero.
fn git_output(root: &Path, args: &[&str]) -> Option<String> {
let ok = git_command(root, args).output().ok()?;
ok.status
.success()
.then(|| String::from_utf8_lossy(&ok.stdout).into_owned())
}
fn git_run(root: &Path, args: &[&str]) -> JmoveResult<()> {
let out = git_command(root, args).output()?;
if out.status.success() {
return Ok(());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_owned();
Err(JmoveError::Git(format!(
"`git {}` failed: {err}",
args.first().copied().unwrap_or("")
)))
}
fn git_command(root: &Path, args: &[&str]) -> Command {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(root).args(args);
cmd
}
#[cfg(test)]
mod tests {
use super::{GitMode, is_tracked, mv, would_use_git};
use std::fs;
use std::path::Path;
use std::process::Command;
// A real throwaway repository: init, one committed file, no user
// identity needed beyond the inline -c overrides.
fn repo() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().expect("tempdir");
git(dir.path(), &["init", "-q", "-b", "main", "."]);
fs::write(dir.path().join("a.txt"), "one\n").expect("write");
git(dir.path(), &["add", "a.txt"]);
git(
dir.path(),
&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-qm",
"init",
],
);
dir
}
fn git(root: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.status()
.expect("git");
assert!(status.success(), "`git {args:?}` failed");
}
fn stdout(root: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.expect("git");
assert!(out.status.success(), "`git {args:?}` failed");
String::from_utf8(out.stdout).expect("utf-8")
}
#[test]
fn tracks_only_indexed_files() {
let dir = repo();
let root = dir.path();
assert!(is_tracked(root, Path::new("a.txt")));
assert!(!is_tracked(root, Path::new("untracked.txt")));
// Not a repository at all.
let plain = tempfile::TempDir::new().expect("tempdir");
assert!(!is_tracked(plain.path(), Path::new("a.txt")));
}
#[test]
fn would_use_git_respects_mode_and_tracking() {
let dir = repo();
let root = dir.path();
assert!(would_use_git(root, Path::new("a.txt"), GitMode::Auto));
assert!(!would_use_git(root, Path::new("a.txt"), GitMode::Disabled));
assert!(!would_use_git(root, Path::new("nope.txt"), GitMode::Auto));
}
#[test]
fn from_no_git_maps_the_cli_flag() {
assert_eq!(GitMode::from_no_git(false), GitMode::Auto);
assert_eq!(GitMode::from_no_git(true), GitMode::Disabled);
assert_eq!(GitMode::default(), GitMode::Auto);
}
#[test]
fn mv_renames_on_disk_and_stages_the_rename() {
let dir = repo();
let root = dir.path();
fs::create_dir_all(root.join("sub")).expect("mkdir");
mv(root, Path::new("a.txt"), Path::new("sub/a.txt")).expect("git mv");
assert!(!root.join("a.txt").exists());
assert!(root.join("sub/a.txt").is_file());
let staged = stdout(root, &["diff", "--cached", "-M", "--name-status"]);
assert_eq!(staged, "R100\ta.txt\tsub/a.txt\n");
}
}

View file

@ -1,22 +1,29 @@
//! Atomic apply with rollback, plus unified-diff rendering for dry-run.
//!
//! Rewrites land on importer files first (each atomically via temp-file +
//! rename), the `source -> target` rename happens last, and any failure
//! Two entry points share one engine: [`apply`] writes a `mv` plan (its
//! rewrites land on importer files first, each atomically via temp-file +
//! rename, and the `source -> target` rename happens last — through
//! `git mv` for tracked files, see [`GitMode`]), [`apply_edits`] writes
//! plain edit groups without a move (the `fix` flavour). Any failure
//! mid-way rolls back everything already written.
mod diff;
mod fsops;
mod git;
pub use diff::render_diff;
pub use diff::{render_diff, render_edits_diff};
pub use git::{GitMode, would_use_git};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::core::Edit;
use crate::core::apply::fsops::{
create_missing_dirs, group_by_file, rewrite_bytes, sibling_temp, write_durable,
};
use crate::core::plan::{MovePlan, Rewrite};
use crate::core::{JmoveError, JmoveResult};
use crate::core::plan::MovePlan;
use crate::core::{JmoveError, JmoveResult, rel_str};
/// Summary of a successfully applied plan.
#[derive(Debug, Clone)]
@ -25,6 +32,8 @@ pub struct Applied {
pub files_rewritten: usize,
/// The moved file's new project-relative path.
pub new_path: PathBuf,
/// Whether the physical rename went through `git mv`.
pub via_git: bool,
}
// Rollback state for one run: originals of rewritten files (newest last),
@ -34,46 +43,80 @@ struct Run {
root: PathBuf,
backups: Vec<(PathBuf, Vec<u8>)>,
dirs: Vec<PathBuf>,
// Root-relative (src, dst) of the executed rename.
moved: Option<(PathBuf, PathBuf)>,
via_git: bool,
}
/// Apply `plan` under `root` atomically (see module docs). Rollback is
/// best-effort: on restore failure the error names the files that need
/// manual recovery.
pub fn apply(root: &Path, plan: &MovePlan) -> JmoveResult<Applied> {
/// manual recovery. `git` selects between `git mv` and plain rename.
pub fn apply(root: &Path, plan: &MovePlan, git: GitMode) -> JmoveResult<Applied> {
let mut run = Run {
root: root.to_path_buf(),
..Default::default()
};
match run.try_apply(plan) {
match run.try_apply(plan, git) {
Ok(applied) => Ok(applied),
Err(err) => Err(run.undo(err)),
}
}
/// Apply pre-grouped edits under `root` atomically, without any move;
/// returns the number of files written. Same rollback contract as
/// [`apply`].
pub fn apply_edits(root: &Path, by_file: &BTreeMap<PathBuf, Vec<Edit>>) -> JmoveResult<usize> {
let mut run = Run {
root: root.to_path_buf(),
..Default::default()
};
match run.try_fix(by_file) {
Ok(files) => Ok(files),
Err(err) => Err(run.undo(err)),
}
}
impl Run {
fn try_apply(&mut self, plan: &MovePlan) -> JmoveResult<Applied> {
fn try_apply(&mut self, plan: &MovePlan, mode: GitMode) -> JmoveResult<Applied> {
let by_file = group_by_file(plan);
for (file, rewrites) in &by_file {
self.rewrite_one(file, rewrites)?;
}
self.write_all(&by_file)?;
// The move comes last, after every importer was rewritten.
let (src, dst) = (self.root.join(&plan.source), self.root.join(&plan.target));
self.dirs = create_missing_dirs(&dst)?;
fs::rename(&src, &dst)?;
self.moved = Some((src, dst));
// git mv needs the destination dir to exist; tracked sources are
// renamed through git so the change lands staged in the index.
self.via_git = would_use_git(&self.root, &plan.source, mode);
if self.via_git {
git::mv(&self.root, &plan.source, &plan.target)?;
} else {
fs::rename(&src, &dst)?;
}
self.moved = Some((plan.source.clone(), plan.target.clone()));
Ok(Applied {
files_rewritten: by_file.len(),
new_path: plan.target.clone(),
via_git: self.via_git,
})
}
fn try_fix(&mut self, by_file: &BTreeMap<PathBuf, Vec<Edit>>) -> JmoveResult<usize> {
self.write_all(by_file)?;
Ok(by_file.len())
}
fn write_all(&mut self, by_file: &BTreeMap<PathBuf, Vec<Edit>>) -> JmoveResult<()> {
for (file, edits) in by_file {
self.rewrite_one(file, edits)?;
}
Ok(())
}
// Patch one file in memory, then temp-file + fsync + rename over it;
// the original bytes go to `backups` for rollback.
fn rewrite_one(&mut self, file: &Path, rewrites: &[&Rewrite]) -> JmoveResult<()> {
fn rewrite_one(&mut self, file: &Path, edits: &[Edit]) -> JmoveResult<()> {
let path = self.root.join(file);
let original = fs::read(&path)?;
let patched = rewrite_bytes(&original, rewrites)?;
let patched = rewrite_bytes(file, &original, edits)?;
self.backups.push((file.to_path_buf(), original));
let temp = sibling_temp(&path); // same dir => rename stays atomic
write_durable(&temp, &patched)?;
@ -88,14 +131,19 @@ impl Run {
// problems to its message.
fn undo(&mut self, err: JmoveError) -> JmoveError {
let mut problems = Vec::new();
if let Some((src, dst)) = self.moved.take()
&& let Err(e) = fs::rename(&dst, &src)
{
problems.push(format!("could not move back {}: {e}", dst.display()));
if let Some((src, dst)) = self.moved.take() {
let back = if self.via_git {
git::mv(&self.root, &dst, &src)
} else {
fs::rename(self.root.join(&dst), self.root.join(&src)).map_err(Into::into)
};
if let Err(e) = back {
problems.push(format!("could not move back {}: {e}", rel_str(&dst)));
}
}
for (file, bytes) in self.backups.drain(..).rev() {
if let Err(e) = fs::write(self.root.join(&file), &bytes) {
problems.push(format!("could not restore {}: {e}", file.display()));
problems.push(format!("could not restore {}: {e}", rel_str(&file)));
}
}
for dir in self.dirs.drain(..).rev() {
@ -111,7 +159,7 @@ impl Run {
#[cfg(test)]
mod tests {
use super::apply;
use super::{GitMode, apply};
use crate::core::JmoveResult;
use crate::core::plan::{MovePlan, Rewrite};
use std::fs;
@ -151,7 +199,7 @@ mod tests {
let root = dir.path();
mk(root, "src/app.ts", OLD)?;
mk(root, "lib/fmt.ts", "export const fmt = 1;\n")?;
let applied = apply(root, &plan())?;
let applied = apply(root, &plan(), GitMode::Disabled)?;
assert_eq!(
(applied.files_rewritten, &applied.new_path),
(1, &PathBuf::from("deep/fmt.ts"))
@ -172,7 +220,7 @@ mod tests {
// Missing source: the last rename fails after the rewrite landed.
let dir = tempfile::TempDir::new()?;
mk(dir.path(), "src/app.ts", OLD)?;
let err = apply(dir.path(), &plan()).expect_err("missing source");
let err = apply(dir.path(), &plan(), GitMode::Disabled).expect_err("missing source");
assert!(matches!(err, crate::core::JmoveError::Io(_)), "{err}");
// Importer restored to its exact original bytes; created dirs gone.
assert_eq!(fs::read_to_string(dir.path().join("src/app.ts"))?, OLD);
@ -189,7 +237,7 @@ mod tests {
let root = dir.path();
mk(root, "src/app.ts", other)?;
mk(root, "lib/fmt.ts", "export const fmt = 1;\n")?;
let err = apply(root, &plan()).expect_err("span mismatch");
let err = apply(root, &plan(), GitMode::Disabled).expect_err("span mismatch");
assert!(
matches!(err, crate::core::JmoveError::StaleIndex(_)),
"{err}"

212
src/core/fix/mod.rs Normal file
View file

@ -0,0 +1,212 @@
//! Fix planning: run the language rules over the indexed files and gather
//! their candidates. Like a [`crate::core::plan::MovePlan`], a [`FixPlan`]
//! is pure data, so `--dry-run` and `--json` render it without any write,
//! and applying reuses the atomic engine behind `mv`.
//!
//! Overlap policy: candidates from different rules may touch the same
//! bytes (`import-order` rewrites the whole block a `unused-import`
//! deletion sits in). [`prune_overlaps`] resolves that per file — urgent
//! rules win, the loser is downgraded to a manual "skipped" report and
//! converges on the next run — instead of rejecting the whole plan. The
//! apply engine's `PLAN_REJECTED` guard stays as the backstop.
use std::collections::BTreeMap;
use std::fs;
use std::ops::Range;
use std::path::PathBuf;
use crate::core::Edit;
use crate::core::index::Index;
use crate::parser::{FixCandidate, SourceLanguage, fixers_for};
/// Every candidate found by one `jmove fix` run, grouped per file: files
/// in sorted order, candidates of a file in source order.
#[derive(Debug, Default)]
pub struct FixPlan {
/// Project-relative file → its candidates (non-empty groups only).
pub files: BTreeMap<PathBuf, Vec<FixCandidate>>,
}
impl FixPlan {
/// `true` when no rule matched anything.
#[must_use]
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
/// Total candidates across all files.
#[must_use]
pub fn total(&self) -> usize {
self.files.values().map(Vec::len).sum()
}
/// The auto-fixable candidates as per-file edit groups, ready for the
/// apply/diff engine; files with only manual candidates drop out.
#[must_use]
pub fn auto_edits(&self) -> BTreeMap<PathBuf, Vec<Edit>> {
self.files
.iter()
.map(|(file, candidates)| {
let edits: Vec<Edit> = candidates
.iter()
.filter(|c| c.auto_fixable)
.flat_map(|c| c.edits.iter().cloned())
.collect();
(file.clone(), edits)
})
.filter(|(_, edits)| !edits.is_empty())
.collect()
}
}
/// Run every rule for each indexed file's language — or only the rule
/// named by `rule` — over freshly-read contents. Files that vanished or
/// are not valid UTF-8 after indexing are skipped, mirroring the scanner.
#[must_use]
pub fn plan_fix(index: &Index, rule: Option<&str>) -> FixPlan {
let mut plan = FixPlan::default();
for file in index.files.sorted() {
let Some(lang) = SourceLanguage::for_path(&file) else {
continue;
};
let Ok(source) = fs::read_to_string(index.root.join(&file)) else {
continue;
};
let mut found: Vec<FixCandidate> = fixers_for(lang)
.iter()
.filter(|fixer| rule.is_none_or(|name| fixer.rule() == name))
.flat_map(|fixer| fixer.fixes(&file, &source, index))
.collect();
if found.is_empty() {
continue;
}
prune_overlaps(&mut found);
found.sort_by_key(|candidate| candidate.span.start);
plan.files.insert(file, found);
}
plan
}
// Resolve cross-rule byte conflicts before the engine ever sees them:
// accept candidates by (severity, position), and downgrade any whose edits
// touch an accepted span to a manual report ("skipped") with its edits
// dropped. The apply run is deterministic and the next `fix` sees the
// re-written file, so overlapping fixes converge over consecutive runs
// instead of rejecting the whole plan. Empty spans (insertions) conflict
// only when strictly inside another span, so inserts at a replaced block's
// boundary coexist with the replacement.
fn prune_overlaps(candidates: &mut [FixCandidate]) {
let mut order: Vec<usize> = (0..candidates.len()).collect();
order.sort_by_key(|&i| (candidates[i].severity.rank(), candidates[i].span.start));
let mut accepted: Vec<Range<usize>> = Vec::new();
for i in order {
let candidate = &mut candidates[i];
let conflict = candidate.edits.iter().any(|e| {
accepted
.iter()
.any(|a| a.start < e.span.end && e.span.start < a.end)
});
if conflict {
candidate.edits.clear();
candidate.auto_fixable = false;
candidate
.message
.push_str(" (skipped: overlaps a higher-priority fix, re-run after applying)");
} else {
accepted.extend(candidate.edits.iter().map(|e| e.span.clone()));
}
}
}
#[cfg(test)]
mod tests {
use super::{plan_fix, prune_overlaps};
use crate::core::Edit;
use crate::core::JmoveResult;
use crate::core::index::Index;
use crate::parser::FixCandidate;
use std::fs;
fn rule_fixture(source: &str) -> JmoveResult<tempfile::TempDir> {
let dir = tempfile::TempDir::new()?;
let file = dir.path().join("src/main/java/p/A.java");
fs::create_dir_all(file.parent().unwrap())?;
fs::write(file, source)?;
Ok(dir)
}
#[test]
fn plan_groups_candidates_and_filters_by_rule() -> JmoveResult<()> {
let dir = rule_fixture("package p;\n\nimport a.b.User;\n\nclass C {}\n")?;
let index = Index::build(dir.path())?;
let plan = plan_fix(&index, None);
assert_eq!(plan.total(), 1);
assert_eq!(plan.auto_edits().len(), 1);
// Filtering on a rule that exists keeps the candidate...
assert_eq!(plan_fix(&index, Some("java/unused-import")).total(), 1);
// ...and an unknown one yields an empty plan.
assert!(plan_fix(&index, Some("ts/unused-import")).is_empty());
Ok(())
}
#[test]
fn clean_project_yields_empty_plan() -> JmoveResult<()> {
let dir = rule_fixture("package p;\n\nimport a.b.User;\n\nclass C { User u; }\n")?;
let index = Index::build(dir.path())?;
assert!(plan_fix(&index, None).is_empty());
Ok(())
}
fn candidate(
rule: &'static str,
severity: crate::parser::Severity,
edits: Vec<Edit>,
) -> FixCandidate {
let span = edits.first().map_or(0..0, |e| e.span.clone());
FixCandidate {
rule,
message: rule.to_owned(),
severity,
auto_fixable: !edits.is_empty(),
span,
edits,
candidates: Vec::new(),
}
}
fn edit(start: usize, end: usize) -> Edit {
Edit {
span: start..end,
old_text: String::new(),
new_text: String::new(),
}
}
#[test]
fn overlapping_lower_severity_candidate_is_deferred() {
use crate::parser::Severity;
let mut found = vec![
candidate("order", Severity::Info, vec![edit(0, 40)]),
candidate("unused", Severity::Warning, vec![edit(10, 20)]),
];
prune_overlaps(&mut found);
// The urgent deletion survives; the whole-block rewrite waits.
assert!(found[1].auto_fixable);
assert!(!found[0].auto_fixable);
assert!(found[0].edits.is_empty());
assert!(found[0].message.contains("skipped"));
}
#[test]
fn boundary_touching_and_disjoint_edits_coexist() {
use crate::parser::Severity;
// Insertion exactly at the replaced block's end byte: not a
// conflict (the engine sorts stable and applies back-to-front).
let mut found = vec![
candidate("order", Severity::Info, vec![edit(0, 40)]),
candidate("missing", Severity::Error, vec![edit(40, 40)]),
];
prune_overlaps(&mut found);
assert!(found.iter().all(|c| c.auto_fixable), "no deferral expected");
}
}

View file

@ -45,6 +45,9 @@ pub struct Index {
pub imports: HashMap<PathBuf, Vec<ResolvedImport>>,
/// Declared `package` of each Java file that has one.
pub packages: HashMap<PathBuf, PackageDecl>,
/// FQN → file map for every indexed Java class; the fix rules use it
/// for candidate lookup (empty when the project has no Java sources).
pub java_classes: JavaClassIndex,
}
impl Index {
@ -57,6 +60,7 @@ impl Index {
files: FileSet::default(),
imports: HashMap::new(),
packages: HashMap::new(),
java_classes: JavaClassIndex::default(),
};
index.scan()?;
// Resolution needs the complete file set (extension/index guessing)
@ -74,6 +78,7 @@ impl Index {
};
}
}
index.java_classes = java_classes;
Ok(index)
}

View file

@ -1,4 +1,4 @@
//! Core engine: project indexing, dependency graph, move planning and
//! Core engine: project indexing, dependency graph, move/fix planning and
//! atomic apply with rollback.
//!
//! Path convention used across the crate: every `PathBuf` produced by
@ -7,11 +7,13 @@
//! from OS walking.
pub mod apply;
pub mod fix;
pub mod index;
pub mod plan;
use std::ffi::OsStr;
use std::io;
use std::ops::Range;
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
@ -31,11 +33,39 @@ pub enum JmoveError {
/// The planned move cannot be applied safely.
#[error("plan rejected: {0}")]
PlanRejected(String),
/// A git operation (`git mv`) failed.
#[error("git error: {0}")]
Git(String),
}
/// Result alias used throughout the crate.
pub type JmoveResult<T> = Result<T, JmoveError>;
/// One in-file byte-span edit: replaces the contents of `span` with
/// `new_text`, after verifying the bytes there still equal `old_text`.
///
/// This is the shared currency of every edit generator: `mv` produces
/// specifier replacements, the `fix` command will produce line deletions
/// and insertions. Engine semantics:
/// - **replace**: non-empty `span`, `old_text` is the current span content;
/// - **insert**: empty span (`start == end`) with empty `old_text` —
/// `new_text` lands at `span.start`;
/// - **delete**: empty `new_text`; to drop a whole line the generator
/// extends the span over its trailing `\n` and puts the exact line bytes
/// in `old_text` (the engine itself never grows spans).
///
/// The `old_text` check is the staleness guard: a plan whose file changed
/// since indexing is rejected before anything is written.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
/// Byte range in the original file contents.
pub span: Range<usize>,
/// Exact bytes the span must currently hold (empty for insertions).
pub old_text: String,
/// Replacement bytes (empty for deletions).
pub new_text: String,
}
/// Normalize a project-relative path: strip `.` segments, collapse `..`
/// where possible and reject paths that escape the project root.
/// Returns `None` if the result would be empty, absolute or above the root.
@ -70,9 +100,76 @@ pub fn normalize_rel_path(path: &Path) -> Option<PathBuf> {
(!stack.is_empty()).then(|| stack.iter().collect::<PathBuf>())
}
/// Format a project-relative path for anything that crosses the CLI
/// boundary (human messages, JSON payloads, diff headers, git pathspecs):
/// always `/`-separated, on every platform. `Path::display()` would leak
/// `\\` on Windows into texts where `/` is the contract — and git even
/// reads backslashes in pathspecs as escapes — so nothing user-facing
/// may use it for project-relative paths.
///
/// # Examples
///
/// ```
/// use std::path::Path;
/// use jmove::core::rel_str;
///
/// assert_eq!(rel_str(Path::new("src/main/java/A.java")), "src/main/java/A.java");
/// ```
#[must_use]
pub fn rel_str(path: &Path) -> String {
path.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
/// Convert a user-supplied path to a normalized project-relative path.
/// Relative paths are taken against `root`; absolute ones must live
/// underneath it. Shared by CLI commands that accept paths.
///
/// # Errors
///
/// [`JmoveError::InvalidArgument`] when the path is outside `root` or
/// normalizes to nothing.
pub fn rel_from_root(root: &Path, path: &Path) -> JmoveResult<PathBuf> {
let joined = if path.is_absolute() {
path.into()
} else {
root.join(path)
};
let outside = || {
JmoveError::InvalidArgument(format!(
"path '{}' is outside the project root",
path.display()
))
};
let abs = collapse(&joined);
let rel = abs.strip_prefix(root).map_err(|_| outside())?;
normalize_rel_path(rel).ok_or_else(|| {
JmoveError::InvalidArgument(format!("invalid project path '{}'", path.display()))
})
}
/// Lexically normalize a path: drop `.` segments, apply `..` where possible.
fn collapse(path: &Path) -> PathBuf {
let mut stack: Vec<Component<'_>> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if stack.last() != Some(&Component::ParentDir) {
stack.pop();
}
}
other => stack.push(other),
}
}
stack.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::normalize_rel_path;
use super::{normalize_rel_path, rel_from_root, rel_str};
use std::path::{Path, PathBuf};
#[test]
@ -99,4 +196,29 @@ mod tests {
fn rejects_absolute_paths() {
assert_eq!(normalize_rel_path(Path::new("/etc/passwd")), None);
}
#[test]
fn rel_str_uses_forward_slashes() {
assert_eq!(
rel_str(Path::new("src/main/java/A.java")),
"src/main/java/A.java"
);
assert_eq!(rel_str(Path::new("a.ts")), "a.ts");
assert_eq!(rel_str(Path::new("")), "");
}
#[test]
fn rel_from_root_resolves_inside_and_rejects_outside() {
let root = Path::new("/tmp/proj");
assert_eq!(
rel_from_root(root, Path::new("src/a.ts")).unwrap(),
PathBuf::from("src/a.ts")
);
assert_eq!(
rel_from_root(root, Path::new("/tmp/proj/./src/../src/b.ts")).unwrap(),
PathBuf::from("src/b.ts")
);
assert!(rel_from_root(root, Path::new("/elsewhere/x.ts")).is_err());
assert!(rel_from_root(root, Path::new("../x.ts")).is_err());
}
}

View file

@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
use crate::core::index::Index;
use crate::core::plan::Rewrite;
use crate::core::{JmoveError, JmoveResult};
use crate::core::{JmoveError, JmoveResult, rel_str};
/// Rewrite set for moving `source.java` to `target.java` (both
/// project-relative, validated by the caller).
@ -28,7 +28,7 @@ pub(super) fn java_rewrites(
if target.extension().is_none_or(|e| e != "java") {
return Err(rejected(format!(
"'{}' is a .java file, the target must keep the .java extension",
source.display()
rel_str(source)
)));
}
let sdir = source.parent().unwrap_or(Path::new(""));
@ -40,7 +40,7 @@ pub(super) fn java_rewrites(
} else {
Err(rejected(format!(
"'{}' has no `package` declaration (default package); it can only be renamed inside its directory",
source.display()
rel_str(source)
)))
};
};
@ -50,15 +50,15 @@ pub(super) fn java_rewrites(
if !sdir.ends_with(Path::new(&pkg_path)) {
return Err(rejected(format!(
"package '{pkg}' does not match directory '{}'",
sdir.display()
rel_str(sdir)
)));
}
let src_root = strip_package_dir(sdir, pkg);
let rest = tdir.strip_prefix(&src_root).map_err(|_| {
rejected(format!(
"target directory '{}' is outside the Java source root '{}'",
tdir.display(),
src_root.display()
rel_str(tdir),
rel_str(&src_root)
))
})?;
let new_pkg = package_of(rest);
@ -124,7 +124,7 @@ fn file_stem(path: &Path) -> JmoveResult<String> {
.and_then(|s| s.to_str())
.map(str::to_owned)
.ok_or_else(|| {
JmoveError::InvalidArgument(format!("invalid file name '{}'", path.display()))
JmoveError::InvalidArgument(format!("invalid file name '{}'", rel_str(path)))
})
}

View file

@ -69,7 +69,7 @@ fn move_between_packages_rewrites_package_and_all_importers() {
rewrites
.iter()
.map(|r| (
r.file.display().to_string(),
crate::core::rel_str(&r.file),
r.old_text.clone(),
r.new_text.clone()
))

View file

@ -15,7 +15,7 @@ use std::ops::Range;
use std::path::{Path, PathBuf};
use crate::core::index::Index;
use crate::core::{JmoveError, JmoveResult, normalize_rel_path};
use crate::core::{Edit, JmoveError, JmoveResult, normalize_rel_path, rel_str};
use crate::parser::SourceLanguage;
/// One in-file replacement of an import specifier. Only the specifier text
@ -32,6 +32,16 @@ pub struct Rewrite {
pub new_text: String,
}
impl From<&Rewrite> for Edit {
fn from(rewrite: &Rewrite) -> Self {
Edit {
span: rewrite.span.clone(),
old_text: rewrite.old_text.clone(),
new_text: rewrite.new_text.clone(),
}
}
}
/// Complete plan for moving `source` to `target`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovePlan {
@ -60,7 +70,7 @@ pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<Mov
};
let (source, target) = (rel("source path", source)?, rel("target path", target)?);
if !index.files.contains(&source) {
let s = source.display();
let s = rel_str(&source);
return Err(JmoveError::InvalidArgument(format!(
"source '{s}' is not an indexed file"
)));
@ -71,7 +81,7 @@ pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<Mov
));
}
if index.files.contains(&target) {
let t = target.display();
let t = rel_str(&target);
return Err(JmoveError::PlanRejected(format!(
"target '{t}' already exists"
)));
@ -141,7 +151,7 @@ mod tests {
let keys: Vec<(String, usize)> = plan
.rewrites
.iter()
.map(|r| (r.file.display().to_string(), r.span.start))
.map(|r| (crate::core::rel_str(&r.file), r.span.start))
.collect();
assert_eq!(
keys,