feat: MVP — move files, rewrite imports, atomically

- core: gitignore-aware index (ignore crate), forward/reverse import graph,
  relative-specifier math, pure-data move plans
- apply: rewrite-first + rename-last with fsync, temp+rename, full rollback
  (incl. stale-plan detection before any write); unified diff for dry-run
- cli: mv + check commands, human output, --json envelope with stable error
  codes (SOURCE_NOT_FOUND, TARGET_EXISTS, ...) and hints; exit codes 0/1/2
- parser/core split into submodules to keep files <=250 lines
- tests: 37 unit + 10 e2e + 2 doctests; fixtures tests/typescript/{basic,normal,complex}
- docs: SKILL.md synced (no --force in Phase 1), EXAMPLES.md
- plan: AI/agent support done; Phase 1.5 = Java niche
This commit is contained in:
loki5512344 2026-09-10 11:41:03 +02:00
parent 9d8f1e0bba
commit 55670030c3
Signed by: boba
GPG key ID: 253067914055423B
37 changed files with 1851 additions and 139 deletions

View file

@ -1,35 +0,0 @@
//! Atomic apply with rollback, plus unified-diff rendering for dry-run.
//!
//! Order matters: specifier rewrites are applied to importer files first
//! (each written atomically via temp-file + rename), and the actual
//! `source -> target` rename happens last. Any failure mid-way triggers
//! rollback of everything already written.
use std::path::Path;
use crate::core::JmoveResult;
use crate::core::plan::MovePlan;
/// Summary of a successfully applied plan.
#[derive(Debug, Clone)]
pub struct Applied {
/// Number of files whose imports were rewritten.
pub files_rewritten: usize,
/// The moved file's new project-relative path.
pub new_path: std::path::PathBuf,
}
/// Apply `plan` under `root` atomically (see module docs). Rollback is
/// best-effort: on restore failure the error message states which files
/// need manual recovery.
pub fn apply(root: &Path, plan: &MovePlan) -> JmoveResult<Applied> {
let _ = (root, plan);
todo!("index agent: atomic apply + rollback")
}
/// Render the plan as a unified diff (rewrites + file rename) for dry-run.
#[must_use]
pub fn render_diff(root: &Path, plan: &MovePlan) -> JmoveResult<String> {
let _ = (root, plan);
todo!("index agent: diff rendering via `similar`")
}

80
src/core/apply/diff.rs Normal file
View file

@ -0,0 +1,80 @@
//! Unified-diff rendering of a plan, used by `mv --dry-run`.
use similar::TextDiff;
use crate::core::JmoveResult;
use crate::core::apply::fsops::{group_by_file, rewrite_bytes};
use crate::core::plan::MovePlan;
use std::path::Path;
/// 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 = 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();
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)
}
#[cfg(test)]
mod tests {
use super::render_diff;
use crate::core::JmoveResult;
use crate::core::plan::{MovePlan, Rewrite};
use std::path::Path;
const OLD: &str = "import {\n fmt,\n} from '../lib/fmt';\n";
fn plan() -> MovePlan {
MovePlan {
source: "lib/fmt.ts".into(),
target: "deep/fmt.ts".into(),
rewrites: vec![Rewrite {
file: "src/app.ts".into(),
span: 24..34,
old_text: "../lib/fmt".into(),
new_text: "../deep/fmt".into(),
}],
}
}
#[test]
fn render_diff_smoke() -> JmoveResult<()> {
let dir = tempfile::TempDir::new()?;
std::fs::create_dir_all(dir.path().join("src"))?;
std::fs::write(dir.path().join("src/app.ts"), OLD)?;
let diff = render_diff(dir.path(), &plan())?;
assert!(diff.contains("--- src/app.ts") && diff.contains("+++ src/app.ts"));
assert!(diff.contains("@@"));
assert!(diff.contains("-} from '../lib/fmt';") && diff.contains("+} from '../deep/fmt';"));
assert!(diff.ends_with("move lib/fmt.ts -> deep/fmt.ts\n"));
let mut empty = plan();
empty.rewrites.clear();
assert_eq!(render_diff(dir.path(), &empty)?, "");
Ok(())
}
#[test]
fn render_diff_reads_from_root() -> JmoveResult<()> {
// Relative-root sanity: same content, root passed as `.` style path.
let dir = tempfile::TempDir::new()?;
std::fs::create_dir_all(dir.path().join("src"))?;
std::fs::write(dir.path().join("src/app.ts"), OLD)?;
let abs: &Path = dir.path();
let diff = render_diff(abs, &plan())?;
assert!(diff.contains("../deep/fmt"));
Ok(())
}
}

71
src/core/apply/fsops.rs Normal file
View file

@ -0,0 +1,71 @@
//! Low-level byte and filesystem helpers shared by apply and diff.
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::core::plan::{MovePlan, Rewrite};
use crate::core::{JmoveError, JmoveResult};
// 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();
for r in &plan.rewrites {
m.entry(r.file.clone()).or_default().push(r);
}
m
}
// `<file>.jmove-tmp` next to `path` (same dir => same filesystem).
pub(super) fn sibling_temp(path: &Path) -> PathBuf {
let mut temp = path.as_os_str().to_os_string();
temp.push(".jmove-tmp");
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>> {
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() {
return Err(JmoveError::StaleIndex(format!(
"'{}' changed since indexing (expected {:?} at {:?})",
r.file.display(),
r.old_text,
r.span
)));
}
out.splice(r.span.clone(), r.new_text.as_bytes().iter().copied());
}
Ok(out)
}
// Write `bytes` to `temp` durably: create, write, flush, fsync.
pub(super) fn write_durable(temp: &Path, bytes: &[u8]) -> JmoveResult<()> {
let mut file = fs::File::create(temp)?;
file.write_all(bytes)?;
file.flush()?;
Ok(file.sync_all()?)
}
// Create the missing parent dirs of `dst`; returns the ones actually
// created (innermost last) so rollback can remove them in reverse.
pub(super) fn create_missing_dirs(dst: &Path) -> JmoveResult<Vec<PathBuf>> {
let mut created = Vec::new();
let mut current = dst.parent().unwrap_or(Path::new("")).to_path_buf();
while !current.exists() {
created.push(current.clone());
let Some(up) = current.parent() else { break };
current = up.to_path_buf();
}
for dir in created.iter().rev() {
fs::create_dir(dir)?;
}
Ok(created)
}

201
src/core/apply/mod.rs Normal file
View file

@ -0,0 +1,201 @@
//! 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
//! mid-way rolls back everything already written.
mod diff;
mod fsops;
pub use diff::render_diff;
use std::fs;
use std::path::{Path, PathBuf};
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};
/// Summary of a successfully applied plan.
#[derive(Debug, Clone)]
pub struct Applied {
/// Number of files whose imports were rewritten.
pub files_rewritten: usize,
/// The moved file's new project-relative path.
pub new_path: PathBuf,
}
// Rollback state for one run: originals of rewritten files (newest last),
// dirs created for the target, and the final rename once it happened.
#[derive(Default)]
struct Run {
root: PathBuf,
backups: Vec<(PathBuf, Vec<u8>)>,
dirs: Vec<PathBuf>,
moved: Option<(PathBuf, PathBuf)>,
}
/// 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> {
let mut run = Run {
root: root.to_path_buf(),
..Default::default()
};
match run.try_apply(plan) {
Ok(applied) => Ok(applied),
Err(err) => Err(run.undo(err)),
}
}
impl Run {
fn try_apply(&mut self, plan: &MovePlan) -> JmoveResult<Applied> {
let by_file = group_by_file(plan);
for (file, rewrites) in &by_file {
self.rewrite_one(file, rewrites)?;
}
// 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));
Ok(Applied {
files_rewritten: by_file.len(),
new_path: plan.target.clone(),
})
}
// 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<()> {
let path = self.root.join(file);
let original = fs::read(&path)?;
let patched = rewrite_bytes(&original, rewrites)?;
self.backups.push((file.to_path_buf(), original));
let temp = sibling_temp(&path); // same dir => rename stays atomic
write_durable(&temp, &patched)?;
if let Err(err) = fs::rename(&temp, &path) {
let _ = fs::remove_file(&temp); // no stray temp behind
return Err(err.into());
}
Ok(())
}
// Undo newest-first; keep the original error, appending any rollback
// 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()));
}
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()));
}
}
for dir in self.dirs.drain(..).rev() {
let _ = fs::remove_dir(&dir); // best-effort: only empty dirs
}
if problems.is_empty() {
return err;
}
let msg = format!("{err}; rollback incomplete: {}", problems.join("; "));
JmoveError::Io(std::io::Error::other(msg))
}
}
#[cfg(test)]
mod tests {
use super::apply;
use crate::core::JmoveResult;
use crate::core::plan::{MovePlan, Rewrite};
use std::fs;
use std::path::{Path, PathBuf};
const OLD: &str = "import {\n fmt,\n} from '../lib/fmt';\n";
const NEW: &str = "import {\n fmt,\n} from '../deep/fmt';\n";
// Byte span of `../lib/fmt` (between the quotes) inside OLD.
const SPAN: std::ops::Range<usize> = 24..34;
// Plan moving lib/fmt.ts -> deep/fmt.ts, rewriting src/app.ts.
fn plan() -> MovePlan {
let rewrite = Rewrite {
file: "src/app.ts".into(),
span: SPAN,
old_text: "../lib/fmt".into(),
new_text: "../deep/fmt".into(),
};
MovePlan {
source: "lib/fmt.ts".into(),
target: "deep/fmt.ts".into(),
rewrites: vec![rewrite],
}
}
fn mk(dir: &Path, rel: &str, body: &str) -> JmoveResult<()> {
let path = dir.join(rel);
fs::create_dir_all(path.parent().unwrap())?;
fs::write(path, body)?;
Ok(())
}
#[test]
fn apply_rewrites_spans_creates_dirs_and_moves_last() -> JmoveResult<()> {
assert_eq!(&OLD[SPAN], "../lib/fmt"); // sanity: the span is real
let dir = tempfile::TempDir::new()?;
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())?;
assert_eq!(
(applied.files_rewritten, &applied.new_path),
(1, &PathBuf::from("deep/fmt.ts"))
);
assert!(!root.join("lib/fmt.ts").exists());
assert_eq!(
fs::read_to_string(root.join("deep/fmt.ts"))?,
"export const fmt = 1;\n"
);
// Only the specifier bytes changed; the layout is kept byte-exact.
assert_eq!(fs::read_to_string(root.join("src/app.ts"))?, NEW);
assert!(!root.join("src/app.ts.jmove-tmp").exists());
Ok(())
}
#[test]
fn apply_rolls_back_when_the_move_fails() -> JmoveResult<()> {
// 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");
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);
assert!(!dir.path().join("deep").exists());
Ok(())
}
#[test]
fn apply_rejects_a_stale_plan_without_writing() -> JmoveResult<()> {
// SPAN was computed on OLD's layout; a single-line importer has
// different bytes there, so the run fails before any write.
let other = "import { fmt } from '../lib/fmt';\n";
let dir = tempfile::TempDir::new()?;
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");
assert!(
matches!(err, crate::core::JmoveError::StaleIndex(_)),
"{err}"
);
assert_eq!(fs::read_to_string(root.join("src/app.ts"))?, other);
assert!(root.join("lib/fmt.ts").exists());
Ok(())
}
}

View file

@ -5,11 +5,15 @@
//! parsed through [`crate::parser`] and its specifiers resolved through
//! [`crate::parser::resolve`].
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use crate::core::JmoveResult;
use crate::parser::ImportRecord;
use ignore::WalkBuilder;
use crate::core::{JmoveResult, normalize_rel_path};
use crate::parser::resolve::resolve_module;
use crate::parser::{ImportRecord, Language, SourceLanguage, frontend_for};
/// Indexed source files with O(1) membership lookups.
#[derive(Debug, Default)]
@ -62,18 +66,185 @@ pub struct Index {
impl Index {
/// Scan `root`, parse every supported source file and build the graph.
/// Unparseable files are skipped, not fatal.
/// Unreadable or unparseable files are skipped, not fatal.
pub fn build(root: &Path) -> JmoveResult<Self> {
let _ = root;
todo!(
"index agent: scan with `ignore`, parse via crate::parser, resolve via parser::resolve"
)
let root = root.canonicalize()?;
let mut index = Self {
root,
files: FileSet::default(),
imports: HashMap::new(),
};
index.scan()?;
// Resolution needs the complete file set (extension/index guessing),
// so it runs as a second pass over the staged records.
for (importer, imports) in &mut index.imports {
for resolved in imports {
resolved.target =
resolve_module(importer, &resolved.record.specifier, &index.files);
}
}
Ok(index)
}
// Walk the project and parse each supported source file, staging the
// raw records with `target: None` for the resolution pass above.
fn scan(&mut self) -> JmoveResult<()> {
// Sorted map: deterministic discovery order.
let mut found: BTreeMap<PathBuf, SourceLanguage> = BTreeMap::new();
for entry in WalkBuilder::new(&self.root).require_git(false).build() {
// Walker errors (unreadable dirs, etc.) simply skip the entry.
let Ok(entry) = entry else { continue };
if entry.path_is_symlink() || !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let stripped = entry
.path()
.strip_prefix(&self.root)
.unwrap_or(Path::new(""));
let Some(rel) = normalize_rel_path(stripped) else {
continue;
};
if let Some(lang) = SourceLanguage::for_path(&rel) {
found.insert(rel, lang);
}
}
// One frontend per language, created lazily as languages appear.
let mut frontends: Vec<(SourceLanguage, Box<dyn Language>)> = Vec::new();
for (rel, lang) in found {
let Ok(text) = fs::read_to_string(self.root.join(&rel)) else {
continue; // non-UTF-8 or vanished between scan and read
};
if !frontends.iter().any(|(l, _)| *l == lang) {
frontends.push((lang, frontend_for(lang)));
}
let frontend = &mut frontends
.iter_mut()
.find(|(l, _)| *l == lang)
.expect("frontend was just ensured")
.1;
let records = frontend.extract_imports(&text);
self.files.add(rel.clone());
self.imports.insert(
rel,
records
.into_iter()
.map(|record| ResolvedImport {
record,
target: None,
})
.collect(),
);
}
Ok(())
}
/// Reverse edge lookup: every indexed file that imports `target`.
#[must_use]
pub fn importers_of(&self, target: &Path) -> Vec<PathBuf> {
let _ = target;
todo!("index agent: reverse-edge lookup")
let mut importers: Vec<PathBuf> = self
.imports
.iter()
.filter(|(_, imports)| imports.iter().any(|r| r.target.as_deref() == Some(target)))
.map(|(file, _)| file.clone())
.collect();
importers.sort();
importers
}
}
#[cfg(test)]
mod tests {
use super::{Index, ResolvedImport};
use crate::core::JmoveResult;
use crate::parser::ImportRecord;
use std::fs;
use std::path::{Path, PathBuf};
// Write `rel` (creating parent dirs) inside `root`.
fn write_file(root: &Path, rel: &str, body: &str) -> JmoveResult<()> {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap())?;
fs::write(path, body)?;
Ok(())
}
// Stub edge with resolved target `target` (parser-independent).
fn edge(target: &str) -> ResolvedImport {
let record = ImportRecord {
specifier: format!("./{target}"),
span: 0..0,
is_dynamic: false,
};
ResolvedImport {
record,
target: Some(PathBuf::from(target)),
}
}
#[test]
fn build_skips_ignored_and_unsupported_files() -> JmoveResult<()> {
let dir = tempfile::TempDir::new()?;
let root = dir.path();
fs::create_dir(root.join(".git"))?;
write_file(root, ".gitignore", "ignored/\n")?;
write_file(root, "src/a.ts", "import { b } from './b';\n")?;
write_file(root, "src/b.ts", "export const b = 1;\n")?;
write_file(root, "src/legacy.js", "const a = require('./a');\n")?;
write_file(root, "ignored/c.ts", "export const c = 1;\n")?;
write_file(root, "docs/note.md", "not source\n")?;
fs::write(root.join("src/binary.ts"), [0xff_u8, 0xfe, 0x00, 0x01])?;
let index = Index::build(root)?;
assert!(index.files.contains(Path::new("src/a.ts")));
assert!(index.files.contains(Path::new("src/b.ts")));
assert!(index.files.contains(Path::new("src/legacy.js")));
// gitignored, non-source and non-UTF-8 files must never be indexed.
for skipped in ["ignored/c.ts", "docs/note.md", "src/binary.ts"] {
assert!(!index.files.contains(Path::new(skipped)), "{skipped}");
}
Ok(())
}
#[test]
fn build_resolves_relative_specifier_to_indexed_file() -> JmoveResult<()> {
let dir = tempfile::TempDir::new()?;
let root = dir.path();
write_file(root, "src/a.ts", "import { b } from './b';\n")?;
write_file(root, "src/b.ts", "export const b = 1;\n")?;
let index = Index::build(root)?;
let imports = index
.imports
.get(Path::new("src/a.ts"))
.expect("a.ts must be indexed");
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].record.specifier, "./b");
assert_eq!(imports[0].target.as_deref(), Some(Path::new("src/b.ts")));
assert_eq!(index.root, fs::canonicalize(root)?);
Ok(())
}
#[test]
fn importers_of_returns_sorted_reverse_edges() -> JmoveResult<()> {
let dir = tempfile::TempDir::new()?;
let mut index = Index::build(dir.path())?;
// Stub the graph so reverse-edge logic is independent of the parser.
index.imports.clear();
let edges = [
("z.ts", "shared.ts"),
("a.ts", "shared.ts"),
("m.ts", "other.ts"),
];
for (file, target) in edges {
index
.imports
.insert(PathBuf::from(file), vec![edge(target)]);
}
assert_eq!(
index.importers_of(Path::new("shared.ts")),
vec![PathBuf::from("a.ts"), PathBuf::from("z.ts")]
);
assert!(index.importers_of(Path::new("missing.ts")).is_empty());
Ok(())
}
}

View file

@ -43,7 +43,7 @@ pub type JmoveResult<T> = Result<T, JmoveError>;
/// # Examples
///
/// ```
/// use std::path::Path;
/// use std::path::{Path, PathBuf};
/// use jmove::core::normalize_rel_path;
///
/// assert_eq!(
@ -59,9 +59,8 @@ pub fn normalize_rel_path(path: &Path) -> Option<PathBuf> {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if stack.pop().is_none() {
return None; // would escape the project root
}
// `?` on the popped Option: escaping the root yields None.
stack.pop()?;
}
Component::Normal(piece) => stack.push(piece),
// Absolute paths and Windows prefixes are not project-relative.

View file

@ -1,46 +0,0 @@
//! Move planning: decide which import specifiers must be rewritten.
//!
//! A plan is pure data (no disk writes), so dry-run and `--json` can render
//! it without touching the filesystem.
use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use crate::core::JmoveResult;
use crate::core::index::Index;
/// One in-file replacement of an import specifier. Only the specifier text
/// between the quotes is touched — the statement layout is never reformatted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rewrite {
/// Project-relative file to modify.
pub file: PathBuf,
/// Byte range of the old specifier text (without quotes).
pub span: Range<usize>,
/// Specifier as currently written.
pub old_text: String,
/// Specifier after the move.
pub new_text: String,
}
/// Complete plan for moving `source` to `target`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovePlan {
/// Project-relative path being moved.
pub source: PathBuf,
/// Project-relative destination path.
pub target: PathBuf,
/// Specifier rewrites, grouped per importer file.
pub rewrites: Vec<Rewrite>,
}
/// Compute the rewrite plan for `source -> target`.
///
/// Every indexed import whose resolved target is `source` gets a new
/// relative specifier computed from the *importer's* directory to `target`.
/// Rewrites whose result equals the old specifier are dropped.
pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<MovePlan> {
let _ = (index, source, target);
todo!("index agent: implement planner incl. relative-specifier math")
}

187
src/core/plan/mod.rs Normal file
View file

@ -0,0 +1,187 @@
//! Move planning: decide which import specifiers must be rewritten.
//!
//! A plan is pure data (no disk writes), so dry-run and `--json` can render
//! it without touching the filesystem. Specifier arithmetic lives in
//! [`specifier`].
mod specifier;
pub use specifier::relative_specifier;
use std::ops::Range;
use std::path::{Path, PathBuf};
use crate::core::index::Index;
use crate::core::{JmoveError, JmoveResult, normalize_rel_path};
/// One in-file replacement of an import specifier. Only the specifier text
/// between the quotes is touched — the statement layout is never reformatted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rewrite {
/// Project-relative file to modify.
pub file: PathBuf,
/// Byte range of the old specifier text (without quotes).
pub span: Range<usize>,
/// Specifier as currently written.
pub old_text: String,
/// Specifier after the move.
pub new_text: String,
}
/// Complete plan for moving `source` to `target`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovePlan {
/// Project-relative path being moved.
pub source: PathBuf,
/// Project-relative destination path.
pub target: PathBuf,
/// Specifier rewrites, sorted by (file, span).
pub rewrites: Vec<Rewrite>,
}
/// Compute the rewrite plan for `source -> target`.
///
/// Every indexed import whose resolved target equals `source` gets a new
/// relative specifier from the importer's directory to `target` (see
/// [`relative_specifier`]). Rewrites whose result equals the old specifier
/// are dropped; the result is sorted by (file, span).
pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<MovePlan> {
let rel = |label: &str, p: &Path| match normalize_rel_path(p) {
Some(r) => Ok(r),
None => Err(JmoveError::InvalidArgument(format!(
"invalid {label} '{}'",
p.display()
))),
};
let (source, target) = (rel("source path", source)?, rel("target path", target)?);
if !index.files.contains(&source) {
let s = source.display();
return Err(JmoveError::InvalidArgument(format!(
"source '{s}' is not an indexed file"
)));
}
if source == target {
return Err(JmoveError::PlanRejected(
"source and target are the same".into(),
));
}
if index.files.contains(&target) {
let t = target.display();
return Err(JmoveError::PlanRejected(format!(
"target '{t}' already exists"
)));
}
let mut rewrites = Vec::new();
for importer in index.importers_of(&source) {
let edges = index.imports[&importer]
.iter()
.filter(|e| e.target.as_deref() == Some(source.as_path()));
for edge in edges {
let new_text = relative_specifier(&importer, &target);
if new_text == edge.record.specifier {
continue; // no-op rewrite, never reaches the plan
}
let record = &edge.record;
rewrites.push(Rewrite {
file: importer.clone(),
span: record.span.clone(),
old_text: record.specifier.clone(),
new_text,
});
}
}
rewrites.sort_by_key(|r| (r.file.clone(), r.span.start));
Ok(MovePlan {
source,
target,
rewrites,
})
}
#[cfg(test)]
mod tests {
use super::{Rewrite, plan_move};
use crate::core::JmoveError;
use crate::core::index::{Index, ResolvedImport};
use crate::parser::ImportRecord;
use std::ops::Range;
use std::path::{Path, PathBuf};
// Hand-wired edges: planner tests never touch the parser.
fn edge(spec: &str, span: Range<usize>, target: &str) -> ResolvedImport {
let record = ImportRecord {
specifier: spec.into(),
span,
is_dynamic: false,
};
ResolvedImport {
record,
target: Some(target.into()),
}
}
fn index_with(files: &[&str], imports: &[(&str, Vec<ResolvedImport>)]) -> Index {
let mut ix = Index::default();
for f in files {
ix.files.add(PathBuf::from(f));
}
ix.imports
.extend(imports.iter().map(|(f, r)| (PathBuf::from(*f), r.clone())));
ix
}
#[test]
fn plan_rewrites_sorted_by_file_then_span() {
let z = vec![edge("./s", 30..33, "s.ts"), edge("./s", 5..8, "s.ts")];
let a = vec![edge("./s", 0..3, "s.ts")];
let index = index_with(&["s.ts", "z.ts", "a.ts"], &[("z.ts", z), ("a.ts", a)]);
let plan = plan_move(&index, Path::new("s.ts"), Path::new("sub/deep/s.ts")).unwrap();
let keys: Vec<(String, usize)> = plan
.rewrites
.iter()
.map(|r| (r.file.display().to_string(), r.span.start))
.collect();
assert_eq!(
keys,
[
("a.ts".to_string(), 0usize),
("z.ts".into(), 5),
("z.ts".into(), 30)
]
);
assert_eq!(
plan.rewrites[0],
Rewrite {
file: "a.ts".into(),
span: 0..3,
old_text: "./s".into(),
new_text: "./sub/deep/s".into(),
}
);
}
#[test]
fn plan_drops_noop_rewrites() {
// fmt.ts -> fmt.js keeps `./lib/fmt` valid: no specifier edits.
let edges = vec![edge("./lib/fmt", 0..0, "lib/fmt.ts")];
let index = index_with(&["lib/fmt.ts", "app.ts"], &[("app.ts", edges)]);
let plan = plan_move(&index, Path::new("lib/fmt.ts"), Path::new("lib/fmt.js")).unwrap();
assert!(plan.rewrites.is_empty());
assert_eq!(plan.target, PathBuf::from("lib/fmt.js"));
}
#[test]
fn plan_rejects_bad_source_and_target() {
let edges = vec![edge("./old", 7..12, "src/old.ts")];
let index = index_with(&["src/old.ts", "src/app.ts"], &[("src/app.ts", edges)]);
// A missing source is an invalid argument...
let err = plan_move(&index, Path::new("ghost.ts"), Path::new("x.ts")).unwrap_err();
assert!(matches!(err, JmoveError::InvalidArgument(_)));
// ...an existing or identical target is a plan rejection.
for target in ["src/app.ts", "src/old.ts"] {
let err = plan_move(&index, Path::new("src/old.ts"), Path::new(target)).unwrap_err();
assert!(matches!(err, JmoveError::PlanRejected(_)), "{target}");
}
}
}

View file

@ -0,0 +1,93 @@
// Pure specifier arithmetic: TS module-stem conventions and directory
// component math. Separated from planner logic to keep plan/ files small.
use std::path::{Component, Path};
// Module stem of a file name: `x.d.ts` -> `x`, `x.ts` -> `x`, unknown or
// missing extension kept. `index` is never stripped (stays conservative).
fn module_stem(name: &str) -> &str {
if let Some(s) = name.strip_suffix(".d.ts") {
return s;
}
match name.rsplit_once('.') {
Some((s, "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs")) => s,
_ => name,
}
}
// Normal directory components of a project-relative path.
fn dir_parts(path: &Path) -> Vec<String> {
let parent = path.parent().unwrap_or(Path::new(""));
parent
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect()
}
/// Compute the TS/JS relative specifier from `importer`'s directory to
/// `target`, prefixed with `./` or `../` as needed. Extension stripping
/// follows TS module-resolution convention: `.ts`/`.tsx`/`.js`/`.jsx`/
/// `.mjs`/`.cjs` are removed from file targets (an importer that wrote the
/// extension may keep doing so — the specifier stays valid).
///
/// ```
/// # use std::path::Path;
/// # use jmove::core::plan::relative_specifier;
/// assert_eq!(relative_specifier(Path::new("src/services/a.ts"), Path::new("src/utils/fmt.ts")), "../utils/fmt");
/// ```
#[must_use]
pub fn relative_specifier(importer: &Path, target: &Path) -> String {
// Component math: consume the common directory prefix, one `..` per
// leftover importer dir; the stem joins last, never as a directory.
let name = target
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let (stem, dirs, mdirs) = (module_stem(&name), dir_parts(importer), dir_parts(target));
let common = dirs.iter().zip(&mdirs).take_while(|(a, b)| a == b).count();
let up = dirs.len() - common;
let mut parts = vec!["..".to_string(); up];
parts.extend_from_slice(&mdirs[common..]);
parts.push(stem.to_string());
let joined = parts.join("/");
if up > 0 {
joined // already starts with `../`
} else {
format!("./{joined}")
}
}
#[cfg(test)]
mod tests {
use super::relative_specifier;
use std::path::Path;
#[test]
fn relative_specifier_table() {
let cases = [
("src/a.ts", "src/b.ts", "./b"), // sibling
("src/a.ts", "src/lib/b.ts", "./lib/b"), // child
("src/lib/a.ts", "src/b.ts", "../b"), // parent
("a/b/c/x.ts", "root/file.ts", "../../../root/file"), // `..` chain
("main.ts", "src/util.ts", "./src/util"), // root importer
("src/main.ts", "util.js", "../util"), // root target
("src/a.ts", "src/foo/index.ts", "./foo/index"), // index kept
("src/a/deep.ts", "src/foo/index.ts", "../foo/index"),
("src/a.ts", "src/shim.d.ts", "./shim"), // .d.ts dropped
("src/a.ts", "types/global.d.ts", "../types/global"),
("src/a.ts", "src/b.mts", "./b.mts"), // unknown ext kept
("src/a.ts", "data/config.json", "../data/config.json"),
];
for (i, t, want) in cases {
assert_eq!(
relative_specifier(Path::new(i), Path::new(t)),
want,
"{i} -> {t}"
);
}
}
}