feat(java): Phase 1.5 — tree-sitter Java frontend, package-aware mv, e2e fixtures

This commit is contained in:
loki5512344 2026-09-13 17:29:07 +02:00
parent 55670030c3
commit 603f916ee9
Signed by: boba
GPG key ID: 253067914055423B
25 changed files with 1288 additions and 366 deletions

View file

@ -1,250 +0,0 @@
//! Project indexing: gitignore-aware filesystem scan plus the import graph.
//!
//! Built fresh on every command (disk cache is Phase 2). `ignore::WalkBuilder`
//! handles `.gitignore`/hidden-file rules; every indexed TS/JS source file is
//! parsed through [`crate::parser`] and its specifiers resolved through
//! [`crate::parser::resolve`].
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
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)]
pub struct FileSet {
paths: HashSet<PathBuf>,
}
impl FileSet {
/// Add a normalized project-relative path; `false` if already present.
pub fn add(&mut self, path: PathBuf) -> bool {
self.paths.insert(path)
}
/// Whether `path` is a known indexed source file.
#[must_use]
pub fn contains(&self, path: &Path) -> bool {
self.paths.contains(path)
}
/// All files in deterministic sorted order (stable for tests and diffs).
#[must_use]
pub fn sorted(&self) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = self.paths.iter().cloned().collect();
v.sort();
v
}
}
/// One import occurrence plus the project file it resolves to.
/// `target: None` means "external" — a bare package specifier or a path
/// that does not exist in the index.
#[derive(Debug, Clone)]
pub struct ResolvedImport {
/// Raw record from the parser (specifier text + byte span).
pub record: ImportRecord,
/// Project-relative resolved file, if any.
pub target: Option<PathBuf>,
}
/// Full in-memory project index: file set and forward import edges.
#[derive(Debug, Default)]
pub struct Index {
/// Absolute project root the index was built for.
pub root: PathBuf,
/// All indexed source files.
pub files: FileSet,
/// For each file, the imports it declares (in source order).
pub imports: HashMap<PathBuf, Vec<ResolvedImport>>,
}
impl Index {
/// Scan `root`, parse every supported source file and build the graph.
/// Unreadable or unparseable files are skipped, not fatal.
pub fn build(root: &Path) -> JmoveResult<Self> {
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 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(())
}
}

31
src/core/index/files.rs Normal file
View file

@ -0,0 +1,31 @@
//! Indexed source files with O(1) membership lookups.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// The set of project-relative source files discovered by [`Index::build`](super::Index::build).
#[derive(Debug, Default)]
pub struct FileSet {
paths: HashSet<PathBuf>,
}
impl FileSet {
/// Add a normalized project-relative path; `false` if already present.
pub fn add(&mut self, path: PathBuf) -> bool {
self.paths.insert(path)
}
/// Whether `path` is a known indexed source file.
#[must_use]
pub fn contains(&self, path: &Path) -> bool {
self.paths.contains(path)
}
/// All files in deterministic sorted order (stable for tests and diffs).
#[must_use]
pub fn sorted(&self) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = self.paths.iter().cloned().collect();
v.sort();
v
}
}

147
src/core/index/mod.rs Normal file
View file

@ -0,0 +1,147 @@
//! Project indexing: gitignore-aware filesystem scan plus the import graph.
//!
//! Built fresh on every command (disk cache is Phase 2). `ignore::WalkBuilder`
//! handles `.gitignore`/hidden-file rules; every indexed source file is parsed
//! through [`crate::parser`] and its specifiers resolved — TS/JS relative
//! specifiers via [`crate::parser::resolve`], Java FQNs via the declared
//! package map ([`crate::parser::java`]).
mod files;
#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::core::{JmoveResult, normalize_rel_path};
use crate::parser::java::JavaClassIndex;
use crate::parser::resolve::resolve_module;
use crate::parser::{ImportRecord, Language, PackageDecl, SourceLanguage, frontend_for};
pub use files::FileSet;
/// One import occurrence plus the project file it resolves to.
/// `target: None` means "external" — a bare package specifier or a path
/// that does not exist in the index.
#[derive(Debug, Clone)]
pub struct ResolvedImport {
/// Raw record from the parser (specifier text + byte span).
pub record: ImportRecord,
/// Project-relative resolved file, if any.
pub target: Option<PathBuf>,
}
/// Full in-memory project index: file set and forward import edges.
#[derive(Debug, Default)]
pub struct Index {
/// Absolute project root the index was built for.
pub root: PathBuf,
/// All indexed source files.
pub files: FileSet,
/// For each file, the imports it declares (in source order).
pub imports: HashMap<PathBuf, Vec<ResolvedImport>>,
/// Declared `package` of each Java file that has one.
pub packages: HashMap<PathBuf, PackageDecl>,
}
impl Index {
/// Scan `root`, parse every supported source file and build the graph.
/// Unreadable or unparseable files are skipped, not fatal.
pub fn build(root: &Path) -> JmoveResult<Self> {
let root = root.canonicalize()?;
let mut index = Self {
root,
files: FileSet::default(),
imports: HashMap::new(),
packages: HashMap::new(),
};
index.scan()?;
// Resolution needs the complete file set (extension/index guessing)
// and the full package map, so it runs as a second pass.
let java_classes = JavaClassIndex::new(&index.files, &index.packages);
for (importer, imports) in &mut index.imports {
let is_java = SourceLanguage::for_path(importer) == Some(SourceLanguage::Java);
for resolved in imports {
resolved.target = if is_java {
java_classes
.resolve(&resolved.record.specifier)
.map(PathBuf::from)
} else {
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());
if let Some(decl) = frontend.extract_package(&text) {
self.packages.insert(rel.clone(), decl);
}
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 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
}
}

121
src/core/index/tests.rs Normal file
View file

@ -0,0 +1,121 @@
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 java_imports_resolve_through_declared_packages() -> JmoveResult<()> {
let dir = tempfile::TempDir::new()?;
let root = dir.path();
write_file(
root,
"src/main/java/com/example/App.java",
"package com.example;\n\nimport com.example.util.Text;\nimport java.util.List;\n\npublic class App {}\n",
)?;
write_file(
root,
"src/main/java/com/example/util/Text.java",
"package com.example.util;\n\npublic class Text {}\n",
)?;
let index = Index::build(root)?;
let decl = &index.packages[Path::new("src/main/java/com/example/util/Text.java")];
assert_eq!(decl.name, "com.example.util");
let imports = &index.imports[Path::new("src/main/java/com/example/App.java")];
assert_eq!(
imports[0].target.as_deref(),
Some(Path::new("src/main/java/com/example/util/Text.java"))
);
// External (jdk) imports stay unresolved, like TS bare specifiers.
assert_eq!(imports[1].target, None);
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(())
}

141
src/core/plan/java/mod.rs Normal file
View file

@ -0,0 +1,141 @@
//! Java move planning: the package/directory-coupled flavour of
//! [`super::plan_move`].
//!
//! A Java move is three coordinated edits (see `docs/PLAN.md`):
//! 1. the moved file's `package` declaration,
//! 2. every `import <fqn>` that resolves to the moved class,
//! 3. the physical move itself (apply layer).
//!
//! The new package is derived through the *source root* — the directory the
//! old package is relative to (`src/main/java`, `src`, ...). Importers speak
//! absolute FQNs, so unlike TS no per-importer relative math is needed.
use std::path::{Path, PathBuf};
use crate::core::index::Index;
use crate::core::plan::Rewrite;
use crate::core::{JmoveError, JmoveResult};
/// Rewrite set for moving `source.java` to `target.java` (both
/// project-relative, validated by the caller).
pub(super) fn java_rewrites(
index: &Index,
source: &Path,
target: &Path,
) -> JmoveResult<Vec<Rewrite>> {
let rejected = |what: String| JmoveError::PlanRejected(format!("Java move: {what}"));
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()
)));
}
let sdir = source.parent().unwrap_or(Path::new(""));
let tdir = target.parent().unwrap_or(Path::new(""));
let Some(decl) = index.packages.get(source) else {
// Default package: un-importable, so only an in-place rename is safe.
return if sdir == tdir {
Ok(Vec::new())
} else {
Err(rejected(format!(
"'{}' has no `package` declaration (default package); it can only be renamed inside its directory",
source.display()
)))
};
};
let pkg = decl.name.as_str();
let pkg_path = pkg.replace('.', "/");
if !sdir.ends_with(Path::new(&pkg_path)) {
return Err(rejected(format!(
"package '{pkg}' does not match directory '{}'",
sdir.display()
)));
}
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()
))
})?;
let new_pkg = package_of(rest);
if new_pkg.is_empty() {
return Err(rejected(
"the target directory maps to the default package; importers could not reference the class"
.to_owned(),
));
}
let stem_old = file_stem(source)?;
let stem_new = file_stem(target)?;
let (old_fqn, new_fqn) = (format!("{pkg}.{stem_old}"), format!("{new_pkg}.{stem_new}"));
let mut rewrites = Vec::new();
// 1. the moved file's own package declaration.
if new_pkg != pkg {
rewrites.push(Rewrite {
file: source.to_path_buf(),
span: decl.span.clone(),
old_text: pkg.to_owned(),
new_text: new_pkg,
});
}
// 2. every importer edge that resolves to the moved class.
for importer in index.importers_of(source) {
for edge in index.imports[&importer]
.iter()
.filter(|e| e.target.as_deref() == Some(source))
{
let new_text = rewrite_fqn(&old_fqn, &new_fqn, &edge.record.specifier);
if new_text == edge.record.specifier {
continue; // no-op rewrite, never reaches the plan
}
rewrites.push(Rewrite {
file: importer.clone(),
span: edge.record.span.clone(),
old_text: edge.record.specifier.clone(),
new_text,
});
}
}
rewrites.sort_by_key(|r| (r.file.clone(), r.span.start));
Ok(rewrites)
}
// Directory `sdir` minus the trailing components of package `pkg`.
fn strip_package_dir(sdir: &Path, pkg: &str) -> PathBuf {
let keep = sdir.components().count() - pkg.split('.').count();
sdir.components().take(keep).collect()
}
// `"com/example"` -> `"com.example"` (empty dir -> empty/default package).
fn package_of(dir: &Path) -> String {
dir.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join(".")
}
fn file_stem(path: &Path) -> JmoveResult<String> {
path.file_stem()
.and_then(|s| s.to_str())
.map(str::to_owned)
.ok_or_else(|| {
JmoveError::InvalidArgument(format!("invalid file name '{}'", path.display()))
})
}
// Swap the class-FQN prefix inside an import specifier; member imports
// (`com.example.Parser.parse` from `import static`) keep their member part.
fn rewrite_fqn(old_fqn: &str, new_fqn: &str, specifier: &str) -> String {
match specifier.strip_prefix(old_fqn) {
Some(rest) if rest.starts_with('.') => format!("{new_fqn}{rest}"),
_ => new_fqn.to_owned(),
}
}
#[cfg(test)]
mod tests;

183
src/core/plan/java/tests.rs Normal file
View file

@ -0,0 +1,183 @@
//! Unit tests for the Java move planner.
use super::{java_rewrites, rewrite_fqn};
use crate::core::index::Index;
use crate::core::plan::tests_support::edge;
use crate::parser::PackageDecl;
use std::ops::Range;
use std::path::{Path, PathBuf};
const UTILS: &str = "src/main/java/com/example/utils";
const CORE: &str = "src/main/java/com/example/core";
// Byte span of a package name in `package com.example.utils;`
const PKG_SPAN: Range<usize> = 8..25;
// Index: Parser.java in com.example.utils, imported by App.java (exact)
// and by Service.java (static member import).
fn index() -> Index {
let mut ix = Index::default();
for f in [
"src/main/java/App.java",
&format!("{UTILS}/Parser.java"),
&format!("{UTILS}/Text.java"),
&format!("{CORE}/Service.java"),
] {
ix.files.add(PathBuf::from(f));
}
for (f, pkg) in [
(format!("{UTILS}/Parser.java"), "com.example.utils"),
(format!("{UTILS}/Text.java"), "com.example.utils"),
(format!("{CORE}/Service.java"), "com.example.core"),
] {
ix.packages.insert(
PathBuf::from(f),
PackageDecl {
name: pkg.into(),
span: PKG_SPAN,
},
);
}
ix.imports.insert(
PathBuf::from("src/main/java/App.java"),
vec![edge("com.example.utils.Parser", 9..30, &parser_str())],
);
ix.imports.insert(
PathBuf::from(format!("{CORE}/Service.java")),
vec![edge(
"com.example.utils.Parser.parse",
23..51,
&parser_str(),
)],
);
ix
}
fn parser_str() -> String {
format!("{UTILS}/Parser.java")
}
fn parser() -> PathBuf {
PathBuf::from(parser_str())
}
#[test]
fn move_between_packages_rewrites_package_and_all_importers() {
let target_str = format!("{CORE}/Parser.java");
let target = Path::new(&target_str);
let rewrites = java_rewrites(&index(), &parser(), target).unwrap();
assert_eq!(
rewrites
.iter()
.map(|r| (
r.file.display().to_string(),
r.old_text.clone(),
r.new_text.clone()
))
.collect::<Vec<_>>(),
vec![
(
"src/main/java/App.java".into(),
"com.example.utils.Parser".into(),
"com.example.core.Parser".into()
),
(
format!("{CORE}/Service.java"),
"com.example.utils.Parser.parse".into(),
"com.example.core.Parser.parse".into()
),
(
format!("{UTILS}/Parser.java"),
"com.example.utils".into(),
"com.example.core".into()
),
]
);
// The package rewrite carries the declaration's own span.
assert_eq!(rewrites[2].span, PKG_SPAN);
}
#[test]
fn rename_inside_same_package_keeps_package_and_imports_fqn() {
let target_str = format!("{UTILS}/Reader.java");
let target = Path::new(&target_str);
let rewrites = java_rewrites(&index(), &parser(), target).unwrap();
let texts: Vec<(&str, &str)> = rewrites
.iter()
.map(|r| (r.old_text.as_str(), r.new_text.as_str()))
.collect();
assert_eq!(
texts,
[
("com.example.utils.Parser", "com.example.utils.Reader"),
(
"com.example.utils.Parser.parse",
"com.example.utils.Reader.parse"
),
]
);
}
#[test]
fn target_outside_source_root_is_rejected() {
let err = java_rewrites(&index(), &parser(), Path::new("other/Parser.java")).unwrap_err();
assert!(
matches!(err, crate::core::JmoveError::PlanRejected(_)),
"{err}"
);
assert!(err.to_string().contains("source root"), "{err}");
}
#[test]
fn target_without_java_extension_is_rejected() {
let err = java_rewrites(
&index(),
&parser(),
Path::new("src/main/java/com/example/core/Parser.class"),
)
.unwrap_err();
assert!(err.to_string().contains(".java extension"), "{err}");
}
#[test]
fn package_dir_mismatch_is_rejected() {
let mut ix = index();
ix.packages.insert(
parser(),
PackageDecl {
name: "com.wrong.pkg".into(),
span: PKG_SPAN,
},
);
let target_str = format!("{CORE}/Parser.java");
let err = java_rewrites(&ix, &parser(), Path::new(&target_str)).unwrap_err();
assert!(
err.to_string().contains("does not match directory"),
"{err}"
);
}
#[test]
fn default_package_allows_only_in_place_rename() {
let mut ix = index();
ix.packages.remove(&parser());
let same_dir_str = format!("{UTILS}/Reader.java");
let same_dir = Path::new(&same_dir_str);
assert!(java_rewrites(&ix, &parser(), same_dir).unwrap().is_empty());
let other_str = format!("{CORE}/Parser.java");
let other = Path::new(&other_str);
assert!(
java_rewrites(&ix, &parser(), other)
.unwrap_err()
.to_string()
.contains("default package")
);
}
#[test]
fn fqn_prefix_swap_keeps_member_suffix() {
assert_eq!(rewrite_fqn("a.b.C", "x.y.C", "a.b.C"), "x.y.C".to_owned());
assert_eq!(
rewrite_fqn("a.b.C", "x.y.C", "a.b.C.method"),
"x.y.C.method".to_owned()
);
}

View file

@ -2,9 +2,12 @@
//!
//! 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`].
//! [`specifier`]; the Java package/directory flavour in [`java`].
mod java;
mod specifier;
#[cfg(test)]
pub(crate) mod tests_support;
pub use specifier::relative_specifier;
@ -13,6 +16,7 @@ use std::path::{Path, PathBuf};
use crate::core::index::Index;
use crate::core::{JmoveError, JmoveResult, normalize_rel_path};
use crate::parser::SourceLanguage;
/// One in-file replacement of an import specifier. Only the specifier text
/// between the quotes is touched — the statement layout is never reformatted.
@ -41,10 +45,11 @@ pub struct MovePlan {
/// 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).
/// TS/JS: every indexed import whose resolved target equals `source` gets a
/// new relative specifier from the importer's directory to `target` (see
/// [`relative_specifier`]). Java moves additionally rewrite the moved file's
/// `package` declaration (see [`java`]). 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),
@ -72,13 +77,27 @@ pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<Mov
)));
}
let rewrites = if SourceLanguage::for_path(&source) == Some(SourceLanguage::Java) {
java::java_rewrites(index, &source, &target)?
} else {
ts_rewrites(index, &source, &target)
};
Ok(MovePlan {
source,
target,
rewrites,
})
}
// Relative-specifier rewrites for the TS/JS flavour of the graph.
fn ts_rewrites(index: &Index, source: &Path, target: &Path) -> Vec<Rewrite> {
let mut rewrites = Vec::new();
for importer in index.importers_of(&source) {
for importer in index.importers_of(source) {
let edges = index.imports[&importer]
.iter()
.filter(|e| e.target.as_deref() == Some(source.as_path()));
.filter(|e| e.target.as_deref() == Some(source));
for edge in edges {
let new_text = relative_specifier(&importer, &target);
let new_text = relative_specifier(&importer, target);
if new_text == edge.record.specifier {
continue; // no-op rewrite, never reaches the plan
}
@ -92,11 +111,7 @@ pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<Mov
}
}
rewrites.sort_by_key(|r| (r.file.clone(), r.span.start));
Ok(MovePlan {
source,
target,
rewrites,
})
rewrites
}
#[cfg(test)]
@ -104,23 +119,9 @@ 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 crate::core::plan::tests_support::edge;
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 {

View file

@ -0,0 +1,20 @@
//! Shared `#[cfg(test)]` graph builders for the plan submodules.
use std::ops::Range;
use std::path::PathBuf;
use crate::core::index::ResolvedImport;
use crate::parser::ImportRecord;
/// Hand-wired resolved edge: plan tests never touch the parser.
pub(crate) fn edge(spec: &str, span: Range<usize>, target: &str) -> ResolvedImport {
let record = ImportRecord {
specifier: spec.into(),
span,
is_dynamic: false,
};
ResolvedImport {
record,
target: Some(PathBuf::from(target)),
}
}