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

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)),
}
}