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

11
Cargo.lock generated
View file

@ -324,6 +324,7 @@ dependencies = [
"tempfile",
"thiserror",
"tree-sitter",
"tree-sitter-java",
"tree-sitter-typescript",
]
@ -617,6 +618,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-java"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.8"

View file

@ -12,6 +12,7 @@ serde_json = "1.0.151"
similar = "3.2.0"
thiserror = "2.0.20"
tree-sitter = "0.27.0"
tree-sitter-java = "0.23.5"
tree-sitter-typescript = "0.23.2"
[dev-dependencies]

View file

@ -76,6 +76,33 @@ jmove never overwrites: free the destination (or pick another name) and
retry. A missing source reports `SOURCE_NOT_FOUND` the same way, and a
file nobody imports simply moves with zero rewrites.
### Java: package + imports + move in one step
Java couples the `package` declaration to the directory layout, so a move
is three coordinated edits — jmove makes all of them:
```console
$ jmove mv src/main/java/com/example/util/Text.java src/main/java/com/example/core/Text.java --dry-run
--- src/main/java/com/example/app/App.java
+++ src/main/java/com/example/app/App.java
@@ -1,7 +1,7 @@
package com.example.app;
-import com.example.util.Text;
-import static com.example.util.Text.shout;
+import com.example.core.Text;
+import static com.example.core.Text.shout;
...
move src/main/java/com/example/util/Text.java -> src/main/java/com/example/core/Text.java
$ jmove mv src/main/java/com/example/util/Text.java src/main/java/com/example/core/Text.java
moved src/main/java/com/example/util/Text.java -> src/main/java/com/example/core/Text.java, updated 4 imports in 3 files
```
The `3 files` include the moved file itself: its `package` line is the
fourth rewrite. Targets outside the source root, non-`.java` targets and
cross-directory moves of default-package classes are rejected with
`PLAN_REJECTED` (exit 1) and change nothing.
## AI-agent usage (`--json`)
Every `--json` response is a flat envelope: `status` (`"ok"` | `"dry_run"`

View file

@ -4,7 +4,8 @@
Moves or renames source files inside a project and updates every import
statement referencing them. Never breaks imports. Supported: TypeScript,
JavaScript; Java/Python/Go on the roadmap. Single binary, no LSP needed.
JavaScript, Java (package declaration + all importers + the file move are
kept in sync); Python/Go on the roadmap. Single binary, no LSP needed.
## When to use
@ -32,6 +33,20 @@ jmove check [--root DIR] [--json]
Run after any move (or any edit) to validate project consistency.
## Java specifics
- Moving a `.java` file rewrites three coordinated edits: its own
`package` declaration, every `import <fqn>` naming the class (static
member imports keep their member suffix) and the physical move.
- The target must be a `.java` path under the same source root
(`src/main/java`, `src`, …) and its directory maps to the new package.
Anything else exits `1` with code `PLAN_REJECTED`.
- A class in the default package (no `package` declaration) can only be
renamed inside its directory.
- `check` never reports unresolved Java imports (jdk, third-party,
`pkg.*`) as broken — they are external by design, like TS bare
specifiers.
## Recommended agent workflow
1. `jmove mv <src> <target> --dry-run --json` — preview.

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

View file

@ -1,4 +1,4 @@
//! `jmove` — a project-aware file mover for TypeScript/JavaScript.
//! `jmove` — a project-aware file mover for TypeScript/JavaScript and Java.
//!
//! Moving a file inside a project invalidates every relative import that
//! points at it. `jmove` indexes the project's import graph, computes the

246
src/parser/java/mod.rs Normal file
View file

@ -0,0 +1,246 @@
//! Tree-sitter frontend for Java: `package` declaration, `import`
//! extraction and fully-qualified-name (FQN) resolution.
//!
//! CONTRACT: see [`crate::parser`]. Java specifiers are absolute FQNs
//! (`com.example.utils.Parser`), resolved through a class index built from
//! the declared packages of all indexed files — never through path
//! arithmetic. On-demand `pkg.*` imports are not extracted: a single-type
//! move never invalidates them, so rewriting them would be wrong.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tree_sitter::{Node, Parser, Tree};
use super::{ImportRecord, Language, PackageDecl, SourceLanguage};
use crate::core::index::FileSet;
/// Frontend backed by the tree-sitter Java grammar.
pub struct TreeSitterJava;
impl TreeSitterJava {
/// Create the Java frontend.
#[must_use]
pub fn new() -> Self {
Self
}
fn parse(source: &str) -> Option<Tree> {
// tree-sitter's Parser holds raw pointers; build one per call
// instead of storing it in the (Sync) frontend struct.
let mut parser = Parser::new();
if parser
.set_language(&tree_sitter_java::LANGUAGE.into())
.is_err()
{
return None;
}
parser.parse(source, None)
}
}
impl Default for TreeSitterJava {
fn default() -> Self {
Self::new()
}
}
impl Language for TreeSitterJava {
fn language(&self) -> SourceLanguage {
SourceLanguage::Java
}
fn extract_imports(&self, source: &str) -> Vec<ImportRecord> {
let Some(tree) = Self::parse(source) else {
return Vec::new();
};
let mut out = Vec::new();
let mut cursor = tree.root_node().walk();
for child in tree.root_node().children(&mut cursor) {
if child.kind() != "import_declaration" {
continue;
}
// `import pkg.*;` — the dotted path never names one file.
if has_child_kind(child, "asterisk") {
continue;
}
// The grammar exposes no field names on import/package nodes;
// the dotted path is the single `scoped_identifier` child.
if let Some(path) = find_child_kind(child, "scoped_identifier") {
out.push(record(path, source));
}
}
out
}
fn extract_package(&self, source: &str) -> Option<PackageDecl> {
let tree = Self::parse(source)?;
let mut cursor = tree.root_node().walk();
tree.root_node()
.children(&mut cursor)
.find(|child| child.kind() == "package_declaration")
.and_then(|decl| find_child_kind(decl, "scoped_identifier"))
.map(|name| PackageDecl {
name: text(name, source).to_owned(),
span: name.start_byte()..name.end_byte(),
})
}
}
fn has_child_kind(node: Node, kind: &str) -> bool {
find_child_kind(node, kind).is_some()
}
fn find_child_kind<'t>(node: Node<'t>, kind: &str) -> Option<Node<'t>> {
let mut cursor = node.walk();
node.children(&mut cursor).find(|c| c.kind() == kind)
}
// Specifier text of a `scoped_identifier` node (no quotes in Java).
fn record(node: Node, source: &str) -> ImportRecord {
ImportRecord {
specifier: text(node, source).to_owned(),
span: node.start_byte()..node.end_byte(),
is_dynamic: false,
}
}
fn text<'a>(node: Node, source: &'a str) -> &'a str {
source
.get(node.start_byte()..node.end_byte())
.unwrap_or_default()
}
/// Map from every indexed Java class's declared FQN to its file. A file
/// without a `package` declaration lives in the default package, which is
/// un-importable, so it gets no entry. Collisions (two files declaring the
/// same FQN — a broken project) keep the sorted-first path deterministically.
#[derive(Debug, Default)]
pub struct JavaClassIndex {
classes: HashMap<String, PathBuf>,
}
impl JavaClassIndex {
/// Build the index from the scanned files and their package declarations.
#[must_use]
pub fn new(files: &FileSet, packages: &HashMap<PathBuf, PackageDecl>) -> Self {
let mut classes: HashMap<String, PathBuf> = HashMap::new();
for file in files.sorted() {
let Some(decl) = packages.get(&file) else {
continue;
};
let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else {
continue;
};
classes
.entry(format!("{}.{stem}", decl.name))
.or_insert(file);
}
Self { classes }
}
/// Resolve a Java import `specifier` to an indexed file. Exact FQN
/// first; failing that, drop the last segment once so `import static
/// com.example.Parser.parse` (a member import) lands on
/// `com.example.Parser`. Anything left unresolved is an external
/// (jdk/third-party) import.
#[must_use]
pub fn resolve(&self, specifier: &str) -> Option<&Path> {
if let Some(file) = self.classes.get(specifier) {
return Some(file.as_path());
}
let owner = specifier.rsplit_once('.')?.0;
self.classes.get(owner).map(PathBuf::as_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn imports(source: &str) -> Vec<ImportRecord> {
TreeSitterJava::new().extract_imports(source)
}
#[test]
fn extracts_package_with_exact_span() {
let src = "package com.example.utils;\n\npublic class P {}\n";
let decl = TreeSitterJava::new().extract_package(src).expect("package");
assert_eq!(decl.name, "com.example.utils");
assert_eq!(&src[decl.span.clone()], "com.example.utils");
}
#[test]
fn package_in_comment_is_not_captured() {
let src = "// package com.example;\npublic class P {}\n";
assert!(TreeSitterJava::new().extract_package(src).is_none());
}
#[test]
fn extracts_single_type_and_static_imports() {
let src = "package p;\nimport a.b.User;\nimport static a.b.User.create;\n";
let recs = imports(src);
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].specifier, "a.b.User");
assert_eq!(&src[recs[0].span.clone()], "a.b.User");
assert_eq!(recs[1].specifier, "a.b.User.create");
assert_eq!(&src[recs[1].span.clone()], "a.b.User.create");
assert!(recs.iter().all(|r| !r.is_dynamic));
}
#[test]
fn on_demand_imports_are_not_extracted() {
let src = "package p;\nimport java.util.*;\nimport a.b.User;\n";
let recs = imports(src);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].specifier, "a.b.User");
}
#[test]
fn imports_inside_nested_types_are_still_top_level() {
let src = "package p;\nclass A { }\nimport q.B;\n";
assert_eq!(imports(src).len(), 1);
}
#[test]
fn class_index_maps_package_and_stem() {
let mut files = FileSet::default();
files.add(PathBuf::from("src/main/java/com/example/utils/Parser.java"));
files.add(PathBuf::from("src/Main.java"));
let mut packages = HashMap::new();
packages.insert(
PathBuf::from("src/main/java/com/example/utils/Parser.java"),
PackageDecl {
name: "com.example.utils".into(),
span: 0..0,
},
);
let classes = JavaClassIndex::new(&files, &packages);
assert_eq!(
classes.resolve("com.example.utils.Parser"),
Some(Path::new("src/main/java/com/example/utils/Parser.java"))
);
// default package: un-importable, no entry
assert_eq!(classes.classes.len(), 1);
}
#[test]
fn resolve_handles_exact_and_member_imports() {
let mut classes = HashMap::new();
classes.insert(
"com.example.Parser".to_owned(),
PathBuf::from("src/com/example/Parser.java"),
);
let index = JavaClassIndex { classes };
assert_eq!(
index.resolve("com.example.Parser"),
Some(Path::new("src/com/example/Parser.java"))
);
assert_eq!(
index.resolve("com.example.Parser.parse"),
Some(Path::new("src/com/example/Parser.java"))
);
assert_eq!(index.resolve("java.util.List"), None);
assert_eq!(index.resolve("Parser"), None);
}
}

View file

@ -4,20 +4,25 @@
//!
//! - A [`Language`] parses one source file into [`ImportRecord`]s: every
//! *static-ish* module reference (TS `import`/`export from`/`require`/
//! dynamic `import()`).
//! - [`crate::core::parser_support::resolve`] turns a specifier into a
//! project-relative file path using a resolver aware of the indexed file
//! set. Non-project (package/bare) specifiers resolve to `None`.
//! dynamic `import()`; Java single-type and static-member `import`s —
//! on-demand `pkg.*` imports are deliberately not extracted).
//! - Java files additionally expose their [`PackageDecl`] via
//! [`Language::extract_package`]; resolution of Java specifiers goes
//! through [`java::JavaClassIndex`] instead of [`resolve`] (TS relative
//! specifiers). External Java imports (jdk, third-party) simply never
//! appear in the class index.
//! - Rewrites must touch **only the specifier string**, never the rest of
//! the statement (KISS + no formatter dependency): that is why
//! [`ImportRecord::span`] is a byte range into the original source.
use std::ops::Range;
use std::path::Path;
pub mod java;
pub mod resolve;
pub mod ts;
/// Source languages `jmove` understands (Phase 1: TypeScript/JavaScript).
/// Source languages `jmove` understands (TS/JS in Phase 1, Java in Phase 1.5).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SourceLanguage {
/// `.ts` (non-TSX) sources.
@ -26,6 +31,8 @@ pub enum SourceLanguage {
Tsx,
/// Plain `.js` / `.mjs` / `.cjs` sources.
JavaScript,
/// `.java` sources.
Java,
}
impl SourceLanguage {
@ -36,6 +43,7 @@ impl SourceLanguage {
"ts" | "mts" | "cts" => Some(Self::TypeScript),
"tsx" | "jsx" => Some(Self::Tsx),
"js" | "mjs" | "cjs" => Some(Self::JavaScript),
"java" => Some(Self::Java),
_ => None,
}
}
@ -56,16 +64,27 @@ impl SourceLanguage {
/// One module reference found in a source file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportRecord {
/// Raw specifier text as written, e.g. `"../utils/format"`.
/// Raw specifier text as written, e.g. `"../utils/format"` (TS) or
/// `"com.example.utils.Parser"` (Java).
pub specifier: String,
/// Byte range of the *specifier string contents* (inside the quotes,
/// without the quote characters) in the parsed file. The rewriter
/// replaces exactly this span and nothing else.
pub span: std::ops::Range<usize>,
pub span: Range<usize>,
/// `true` for dynamic `import("...")` / `require("...")` occurrences.
pub is_dynamic: bool,
}
/// A Java `package` declaration: dotted name plus the byte span of the name
/// (quotes have no meaning here — the span covers `com.example.utils`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageDecl {
/// Declared package name, e.g. `"com.example.utils"`.
pub name: String,
/// Byte range of the package name in the parsed file.
pub span: Range<usize>,
}
/// A language frontend that extracts imports from source text.
pub trait Language: Send + Sync {
/// The language this frontend handles.
@ -74,10 +93,21 @@ pub trait Language: Send + Sync {
/// Extract all import records from `source` in byte-offset order.
/// Parse errors must not be fatal: return what was understood.
fn extract_imports(&self, source: &str) -> Vec<ImportRecord>;
/// Extract the `package` declaration, if this language has one and the
/// file declares it. Default: no package concept (TS/JS).
fn extract_package(&self, _source: &str) -> Option<PackageDecl> {
None
}
}
/// Build the default frontend for `lang`.
#[must_use]
pub fn frontend_for(lang: SourceLanguage) -> Box<dyn Language> {
Box::new(ts::TreeSitterTs::new(lang))
match lang {
SourceLanguage::Java => Box::new(java::TreeSitterJava::new()),
SourceLanguage::TypeScript | SourceLanguage::Tsx | SourceLanguage::JavaScript => {
Box::new(ts::TreeSitterTs::new(lang))
}
}
}

View file

@ -60,13 +60,13 @@ fn visit(node: Node, source: &str, out: &mut Vec<ImportRecord>) {
}
/// Grammar for a language variant. The TypeScript grammar also parses
/// plain JavaScript; only JSX needs the TSX variant.
/// plain JavaScript; only JSX needs the TSX variant. Java never reaches
/// this frontend ([`crate::parser::frontend_for`] routes it to
/// [`crate::parser::java`]), so everything else maps to plain TypeScript.
fn grammar(lang: SourceLanguage) -> Language {
match lang {
SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
SourceLanguage::TypeScript | SourceLanguage::JavaScript => {
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
}
_ => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
}
}

View file

@ -1,71 +1,19 @@
//! End-to-end tests for the `jmove` CLI against the TypeScript fixtures.
//!
//! Each test copies a fixture tree into a fresh tempdir, runs the real
//! binary with `--root <tmp>` and asserts on exit codes, stdout/stderr and
//! the resulting files on disk.
use std::fs;
use std::path::{Path, PathBuf};
mod common;
use assert_cmd::Command;
use assert_cmd::assert::Assert;
use common::{copy_fixture, fixture_dir, in_root, jmove, read};
use predicates::prelude::*;
use tempfile::TempDir;
/// Recursively copy `tests/typescript/<name>` into a tempdir and return it.
///
/// An empty `.git` marker is created in the copy: the `ignore` crate only
/// applies `.gitignore` rules inside a git repository by default, and the
/// `normal` fixture relies on its `node_modules/` rule being effective.
fn copy_fixture(name: &str) -> TempDir {
let tmp = TempDir::new().expect("tempdir");
let from = fixture_dir(name);
copy_dir(&from, tmp.path());
fs::create_dir(tmp.path().join(".git")).expect("git marker");
tmp
}
/// Source path of a TypeScript fixture tree inside the repository.
fn fixture_dir(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/typescript")
.join(name)
}
/// Recursive file/dir copy; plain `std::fs` only, no symlinks in fixtures.
fn copy_dir(from: &Path, to: &Path) {
fs::create_dir_all(to).expect("create dir");
for entry in fs::read_dir(from).expect("read dir") {
let entry = entry.expect("entry");
let target = to.join(entry.file_name());
if entry.file_type().expect("file type").is_dir() {
copy_dir(&entry.path(), &target);
} else {
fs::copy(entry.path(), target).expect("copy file");
}
}
}
/// Run `jmove --root <dir> <args…>` and return an assertable outcome.
fn jmove(root: &TempDir, args: &[&str]) -> Assert {
let mut cmd = Command::cargo_bin("jmove").expect("jmove binary");
cmd.arg("--root").arg(root.path()).args(args);
cmd.assert()
}
/// Read `path` as a string, panicking with the path on failure.
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
}
/// Build a path inside the tempdir root from a `/`-separated relative path.
fn in_root(root: &Path, rel: &str) -> PathBuf {
root.join(rel)
/// Convenience wrapper: a TypeScript fixture by name.
fn fixture(name: &str) -> TempDir {
copy_fixture("typescript", name)
}
#[test]
fn mv_dry_run_prints_diff_and_leaves_disk_untouched() {
let tmp = copy_fixture("basic");
let tmp = fixture("basic");
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts", "--dry-run"])
.success()
.stdout(predicate::str::contains("./lib/sum"))
@ -80,14 +28,14 @@ fn mv_dry_run_prints_diff_and_leaves_disk_untouched() {
);
assert_eq!(
read(&in_root(tmp.path(), "app.ts")),
read(&fixture_dir("basic").join("app.ts")),
read(&fixture_dir("typescript", "basic").join("app.ts")),
"importer untouched"
);
}
#[test]
fn mv_rewrites_importer_and_moves_the_file() {
let tmp = copy_fixture("basic");
let tmp = fixture("basic");
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts"])
.success()
.stdout(predicate::str::contains("moved lib/sum.ts -> utils/sum.ts"))
@ -109,7 +57,7 @@ fn mv_rewrites_importer_and_moves_the_file() {
#[test]
fn mv_updates_barrel_and_never_touches_node_modules() {
let tmp = copy_fixture("normal");
let tmp = fixture("normal");
jmove(&tmp, &["mv", "src/impl/core.ts", "src/impl/calc.ts"]).success();
assert!(
read(&in_root(tmp.path(), "src/impl/index.ts")).contains("./calc"),
@ -128,7 +76,7 @@ fn mv_updates_barrel_and_never_touches_node_modules() {
#[test]
fn mv_rewrites_only_the_specifier_line_in_multi_line_imports() {
let tmp = copy_fixture("complex");
let tmp = fixture("complex");
let view = in_root(tmp.path(), "src/ui/deep/nested/view.ts");
let before: Vec<String> = read(&view).lines().map(str::to_owned).collect();
@ -154,12 +102,12 @@ fn mv_rewrites_only_the_specifier_line_in_multi_line_imports() {
#[test]
fn check_passes_on_clean_and_fails_on_broken_fixture() {
let clean = copy_fixture("basic");
let clean = fixture("basic");
jmove(&clean, &["check"])
.success()
.stdout(predicate::str::contains("no broken imports"));
let messy = copy_fixture("complex");
let messy = fixture("complex");
jmove(&messy, &["check"])
.code(2)
.stdout(predicate::str::contains(
@ -169,7 +117,7 @@ fn check_passes_on_clean_and_fails_on_broken_fixture() {
#[test]
fn check_json_reports_broken_import_payload() {
let tmp = copy_fixture("complex");
let tmp = fixture("complex");
jmove(&tmp, &["check", "--json"]).code(2).stdout(
predicate::str::contains("\"status\": \"ok\"")
.and(predicate::str::contains("\"operation\": \"check\""))
@ -183,7 +131,7 @@ fn check_json_reports_broken_import_payload() {
#[test]
fn mv_json_happy_path_reports_changed_files() {
let tmp = copy_fixture("basic");
let tmp = fixture("basic");
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts", "--json"])
.success()
.stdout(
@ -203,7 +151,7 @@ fn mv_json_happy_path_reports_changed_files() {
#[test]
fn mv_json_dry_run_reports_preview_payload() {
let tmp = copy_fixture("basic");
let tmp = fixture("basic");
jmove(
&tmp,
&["mv", "lib/sum.ts", "utils/sum.ts", "--dry-run", "--json"],
@ -224,7 +172,7 @@ fn mv_json_dry_run_reports_preview_payload() {
#[test]
fn mv_json_reports_target_exists_error_shape() {
let tmp = copy_fixture("basic");
let tmp = fixture("basic");
jmove(&tmp, &["mv", "app.ts", "lib/sum.ts", "--json"])
.code(1)
.stdout(
@ -241,7 +189,7 @@ fn mv_json_reports_target_exists_error_shape() {
#[test]
fn mv_reports_source_not_found() {
let tmp = copy_fixture("basic");
let tmp = fixture("basic");
jmove(&tmp, &["mv", "lib/nope.ts", "utils/nope.ts", "--json"])
.code(1)
.stdout(predicate::str::contains("\"code\": \"SOURCE_NOT_FOUND\""));

151
tests/cli_java.rs Normal file
View file

@ -0,0 +1,151 @@
//! End-to-end tests for the `jmove` CLI against the Java fixtures.
mod common;
use common::{copy_fixture, in_root, jmove, read};
use predicates::prelude::*;
fn fixture(name: &str) -> tempfile::TempDir {
copy_fixture("java", name)
}
const TEXT: &str = "src/main/java/com/example/util/Text.java";
const MOVED: &str = "src/main/java/com/example/core/Text.java";
#[test]
fn mv_between_packages_updates_declaration_and_every_importer() {
let tmp = fixture("basic");
jmove(&tmp, &["mv", TEXT, MOVED])
.success()
.stdout(predicate::str::contains("4 imports in 3 files"));
let moved = read(&in_root(tmp.path(), MOVED));
assert!(moved.contains("package com.example.core;"), "{moved}");
assert!(!moved.contains("com.example.util"), "{moved}");
let app = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/App.java",
));
assert!(app.contains("import com.example.core.Text;"), "{app}");
assert!(
app.contains("import static com.example.core.Text.shout;"),
"{app}"
);
// Specifiers that never named the moved class are byte-identical.
assert!(app.contains("import com.example.unknown.*;"), "{app}");
assert!(app.contains("import java.util.List;"), "{app}");
let user = read(&in_root(
tmp.path(),
"src/main/java/com/example/model/User.java",
));
assert!(user.contains("import com.example.core.Text;"), "{user}");
assert!(!in_root(tmp.path(), TEXT).exists());
// Unresolvable Java imports (jdk, wildcard) are not "broken".
jmove(&tmp, &["check"]).success();
}
#[test]
fn mv_dry_run_previews_all_three_edits_without_writing() {
let tmp = fixture("basic");
jmove(&tmp, &["mv", TEXT, MOVED, "--dry-run"])
.success()
.stdout(predicate::str::contains("-package com.example.util;"))
.stdout(predicate::str::contains("+package com.example.core;"))
.stdout(predicate::str::contains("-import com.example.util.Text;"))
.stdout(predicate::str::contains("+import com.example.core.Text;"))
.stdout(predicate::str::contains(format!("move {TEXT} -> {MOVED}")));
assert!(in_root(tmp.path(), TEXT).is_file());
assert!(!in_root(tmp.path(), MOVED).exists());
}
#[test]
fn mv_json_reports_package_and_importer_changes() {
let tmp = fixture("basic");
jmove(&tmp, &["mv", TEXT, MOVED, "--json"])
.success()
.stdout(
predicate::str::contains("\"old\": \"com.example.util\"")
.and(predicate::str::contains("\"new\": \"com.example.core\""))
.and(predicate::str::contains(
"\"old\": \"com.example.util.Text.shout\"",
))
.and(predicate::str::contains(
"\"new\": \"com.example.core.Text.shout\"",
))
.and(predicate::str::contains("\"updated_imports\": 4")),
);
let changed = read(&in_root(tmp.path(), MOVED));
assert!(changed.contains("package com.example.core"));
}
#[test]
fn rename_inside_same_package_keeps_package_declaration() {
let tmp = fixture("basic");
jmove(
&tmp,
&["mv", TEXT, "src/main/java/com/example/util/Paragraph.java"],
)
.success();
let renamed = read(&in_root(
tmp.path(),
"src/main/java/com/example/util/Paragraph.java",
));
assert!(renamed.contains("package com.example.util;"), "{renamed}");
let app = read(&in_root(
tmp.path(),
"src/main/java/com/example/app/App.java",
));
assert!(app.contains("import com.example.util.Paragraph;"), "{app}");
assert!(
app.contains("import static com.example.util.Paragraph.shout;"),
"{app}"
);
}
#[test]
fn target_outside_the_source_root_is_rejected() {
let tmp = fixture("basic");
jmove(&tmp, &["mv", TEXT, "webapp/core/Text.java", "--json"])
.code(1)
.stdout(
predicate::str::contains("\"code\": \"PLAN_REJECTED\"")
.and(predicate::str::contains("source root")),
);
assert!(in_root(tmp.path(), TEXT).is_file());
}
#[test]
fn java_file_must_keep_the_java_extension() {
let tmp = fixture("basic");
jmove(
&tmp,
&["mv", TEXT, "src/main/java/com/example/core/Text.txt"],
)
.code(1)
.stderr(predicate::str::contains(".java extension"));
}
#[test]
fn default_package_file_cannot_change_directory() {
let tmp = fixture("basic");
jmove(
&tmp,
&[
"mv",
"src/main/java/Main.java",
"src/main/java/app/Main.java",
],
)
.code(1)
.stderr(predicate::str::contains("default package"));
// ...but an in-place rename is fine and needs no rewrites.
jmove(
&tmp,
&["mv", "src/main/java/Main.java", "src/main/java/Run.java"],
)
.success();
assert!(in_root(tmp.path(), "src/main/java/Run.java").is_file());
}

64
tests/common/mod.rs Normal file
View file

@ -0,0 +1,64 @@
//! Shared helpers for the CLI integration tests.
//!
//! Each test copies a fixture tree into a fresh tempdir, runs the real
//! binary with `--root <tmp>` and asserts on exit codes, stdout/stderr and
//! the resulting files on disk.
use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use assert_cmd::assert::Assert;
use tempfile::TempDir;
/// Recursively copy `tests/<language>/<name>` into a tempdir and return it.
///
/// An empty `.git` marker is created in the copy: the `ignore` crate only
/// applies `.gitignore` rules inside a git repository by default, and the
/// typescript `normal` fixture relies on its `node_modules/` rule being
/// effective.
pub fn copy_fixture(language: &str, name: &str) -> TempDir {
let tmp = TempDir::new().expect("tempdir");
copy_dir(&fixture_dir(language, name), tmp.path());
fs::create_dir(tmp.path().join(".git")).expect("git marker");
tmp
}
/// Source path of a fixture tree inside the repository.
pub fn fixture_dir(language: &str, name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join(language)
.join(name)
}
/// Recursive file/dir copy; plain `std::fs` only, no symlinks in fixtures.
fn copy_dir(from: &Path, to: &Path) {
fs::create_dir_all(to).expect("create dir");
for entry in fs::read_dir(from).expect("read dir") {
let entry = entry.expect("entry");
let target = to.join(entry.file_name());
if entry.file_type().expect("file type").is_dir() {
copy_dir(&entry.path(), &target);
} else {
fs::copy(entry.path(), target).expect("copy file");
}
}
}
/// Run `jmove --root <dir> <args…>` and return an assertable outcome.
pub fn jmove(root: &TempDir, args: &[&str]) -> Assert {
let mut cmd = Command::cargo_bin("jmove").expect("jmove binary");
cmd.arg("--root").arg(root.path()).args(args);
cmd.assert()
}
/// Read `path` as a string, panicking with the path on failure.
pub fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
}
/// Build a path inside the tempdir root from a `/`-separated relative path.
pub fn in_root(root: &Path, rel: &str) -> PathBuf {
root.join(rel)
}

View file

@ -1 +0,0 @@
> Planned Phase 1.5 fixture tree. `jmove` does not support Java yet; this folder intentionally holds only this note so nothing is dead code. See `docs/PLAN.md`.

View file

@ -0,0 +1,6 @@
// No package declaration: the default package, un-importable.
public class Main {
public static void main(String[] args) {
System.out.println("hi");
}
}

View file

@ -0,0 +1,10 @@
package com.example.app;
import com.example.util.Text;
import static com.example.util.Text.shout;
import com.example.unknown.*;
import java.util.List;
public class App {
private final List<String> items = List.of(shout("hi"), new Text().render());
}

View file

@ -0,0 +1,9 @@
package com.example.model;
import com.example.util.Text;
public class User {
String loudName() {
return Text.shout("user");
}
}

View file

@ -0,0 +1,11 @@
package com.example.util;
public class Text {
public static String shout(String s) {
return s.toUpperCase();
}
public String render() {
return "text";
}
}

View file

@ -32,10 +32,10 @@
- [x] docs/EXAMPLES.md — примеры для людей и агентов
## Phase 1.5 — Java (наша ниша, аналогов в CLI нет)
- [ ] tree-sitter Java грамматика: package + import extraction
- [ ] Детект source root (src/main/java, src/) и соответствие package ⇄ директория
- [ ] mv = три синхронных правки: package, все import в проекту, физический перенос
- [ ] e2e фикстуры tests/java/
- [x] tree-sitter Java грамматика: package + import extraction
- [x] Детект source root (src/main/java, src/) и соответствие package ⇄ директория
- [x] mv = три синхронных правки: package, все import в проекту, физический перенос
- [x] e2e фикстуры tests/java/
## Phase 2
- [ ] Кэш индекса на диске (bincode/rkyv) → .jmove/index