diff --git a/Cargo.lock b/Cargo.lock index 1e3aa6c..b4d2bb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 9f2f340..07bcf7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index f98883d..d330d8f 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -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"` diff --git a/docs/SKILL.md b/docs/SKILL.md index 48d12a1..a3916e7 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -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 ` 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 --dry-run --json` — preview. diff --git a/src/core/index.rs b/src/core/index.rs deleted file mode 100644 index 67addd4..0000000 --- a/src/core/index.rs +++ /dev/null @@ -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, -} - -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 { - let mut v: Vec = 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, -} - -/// 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>, -} - -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 { - 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 = 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)> = 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 { - let mut importers: Vec = 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(()) - } -} diff --git a/src/core/index/files.rs b/src/core/index/files.rs new file mode 100644 index 0000000..72af54d --- /dev/null +++ b/src/core/index/files.rs @@ -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, +} + +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 { + let mut v: Vec = self.paths.iter().cloned().collect(); + v.sort(); + v + } +} diff --git a/src/core/index/mod.rs b/src/core/index/mod.rs new file mode 100644 index 0000000..49dce0d --- /dev/null +++ b/src/core/index/mod.rs @@ -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, +} + +/// 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>, + /// Declared `package` of each Java file that has one. + pub packages: HashMap, +} + +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 { + 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 = 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)> = 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 { + let mut importers: Vec = self + .imports + .iter() + .filter(|(_, imports)| imports.iter().any(|r| r.target.as_deref() == Some(target))) + .map(|(file, _)| file.clone()) + .collect(); + importers.sort(); + importers + } +} diff --git a/src/core/index/tests.rs b/src/core/index/tests.rs new file mode 100644 index 0000000..57019b6 --- /dev/null +++ b/src/core/index/tests.rs @@ -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(()) +} diff --git a/src/core/plan/java/mod.rs b/src/core/plan/java/mod.rs new file mode 100644 index 0000000..8d2e76c --- /dev/null +++ b/src/core/plan/java/mod.rs @@ -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 ` 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> { + 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::>() + .join(".") +} + +fn file_stem(path: &Path) -> JmoveResult { + 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; diff --git a/src/core/plan/java/tests.rs b/src/core/plan/java/tests.rs new file mode 100644 index 0000000..037d0a7 --- /dev/null +++ b/src/core/plan/java/tests.rs @@ -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 = 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![ + ( + "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() + ); +} diff --git a/src/core/plan/mod.rs b/src/core/plan/mod.rs index a84993c..44126e5 100644 --- a/src/core/plan/mod.rs +++ b/src/core/plan/mod.rs @@ -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 { 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 Vec { 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, 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)]) -> Index { let mut ix = Index::default(); for f in files { diff --git a/src/core/plan/tests_support.rs b/src/core/plan/tests_support.rs new file mode 100644 index 0000000..ac157ef --- /dev/null +++ b/src/core/plan/tests_support.rs @@ -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, target: &str) -> ResolvedImport { + let record = ImportRecord { + specifier: spec.into(), + span, + is_dynamic: false, + }; + ResolvedImport { + record, + target: Some(PathBuf::from(target)), + } +} diff --git a/src/lib.rs b/src/lib.rs index bff461c..dc2b79c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 diff --git a/src/parser/java/mod.rs b/src/parser/java/mod.rs new file mode 100644 index 0000000..277d48f --- /dev/null +++ b/src/parser/java/mod.rs @@ -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-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 { + 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 { + 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> { + 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, +} + +impl JavaClassIndex { + /// Build the index from the scanned files and their package declarations. + #[must_use] + pub fn new(files: &FileSet, packages: &HashMap) -> Self { + let mut classes: HashMap = 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 { + 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); + } +} diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 02717c6..69cd8e6 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -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, + pub span: Range, /// `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, +} + /// 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; + + /// 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 { + None + } } /// Build the default frontend for `lang`. #[must_use] pub fn frontend_for(lang: SourceLanguage) -> Box { - 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)) + } + } } diff --git a/src/parser/ts_extract.rs b/src/parser/ts_extract.rs index 6378bba..a5f4eb4 100644 --- a/src/parser/ts_extract.rs +++ b/src/parser/ts_extract.rs @@ -60,13 +60,13 @@ fn visit(node: Node, source: &str, out: &mut Vec) { } /// 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(), } } diff --git a/tests/cli.rs b/tests/cli.rs index ce2e853..188f208 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -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 ` 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/` 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 ` 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 = 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\"")); diff --git a/tests/cli_java.rs b/tests/cli_java.rs new file mode 100644 index 0000000..04534d8 --- /dev/null +++ b/tests/cli_java.rs @@ -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()); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..1e50ed5 --- /dev/null +++ b/tests/common/mod.rs @@ -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 ` 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//` 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 ` 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) +} diff --git a/tests/java/README.md b/tests/java/README.md deleted file mode 100644 index 9b3e19b..0000000 --- a/tests/java/README.md +++ /dev/null @@ -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`. diff --git a/tests/java/basic/src/main/java/Main.java b/tests/java/basic/src/main/java/Main.java new file mode 100644 index 0000000..1a0ad40 --- /dev/null +++ b/tests/java/basic/src/main/java/Main.java @@ -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"); + } +} diff --git a/tests/java/basic/src/main/java/com/example/app/App.java b/tests/java/basic/src/main/java/com/example/app/App.java new file mode 100644 index 0000000..d0cfa15 --- /dev/null +++ b/tests/java/basic/src/main/java/com/example/app/App.java @@ -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 items = List.of(shout("hi"), new Text().render()); +} diff --git a/tests/java/basic/src/main/java/com/example/model/User.java b/tests/java/basic/src/main/java/com/example/model/User.java new file mode 100644 index 0000000..05d9d7a --- /dev/null +++ b/tests/java/basic/src/main/java/com/example/model/User.java @@ -0,0 +1,9 @@ +package com.example.model; + +import com.example.util.Text; + +public class User { + String loudName() { + return Text.shout("user"); + } +} diff --git a/tests/java/basic/src/main/java/com/example/util/Text.java b/tests/java/basic/src/main/java/com/example/util/Text.java new file mode 100644 index 0000000..8240373 --- /dev/null +++ b/tests/java/basic/src/main/java/com/example/util/Text.java @@ -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"; + } +} diff --git a/todo.md b/todo.md index a83b260..b0fb82f 100644 --- a/todo.md +++ b/todo.md @@ -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