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:
parent
9d8f1e0bba
commit
55670030c3
37 changed files with 1851 additions and 139 deletions
|
|
@ -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
80
src/core/apply/diff.rs
Normal 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
71
src/core/apply/fsops.rs
Normal 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
201
src/core/apply/mod.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
187
src/core/plan/mod.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/core/plan/specifier.rs
Normal file
93
src/core/plan/specifier.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue