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:
parent
0216fd6523
commit
9dda5f4562
37 changed files with 2521 additions and 234 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
164
src/core/apply/git.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
212
src/core/fix/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
126
src/core/mod.rs
126
src/core/mod.rs
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue