feat(java): Phase 1.5 — tree-sitter Java frontend, package-aware mv, e2e fixtures
This commit is contained in:
parent
55670030c3
commit
603f916ee9
25 changed files with 1288 additions and 366 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -324,6 +324,7 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
|
"tree-sitter-java",
|
||||||
"tree-sitter-typescript",
|
"tree-sitter-typescript",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -617,6 +618,16 @@ dependencies = [
|
||||||
"tree-sitter-language",
|
"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]]
|
[[package]]
|
||||||
name = "tree-sitter-language"
|
name = "tree-sitter-language"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ serde_json = "1.0.151"
|
||||||
similar = "3.2.0"
|
similar = "3.2.0"
|
||||||
thiserror = "2.0.20"
|
thiserror = "2.0.20"
|
||||||
tree-sitter = "0.27.0"
|
tree-sitter = "0.27.0"
|
||||||
|
tree-sitter-java = "0.23.5"
|
||||||
tree-sitter-typescript = "0.23.2"
|
tree-sitter-typescript = "0.23.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -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
|
retry. A missing source reports `SOURCE_NOT_FOUND` the same way, and a
|
||||||
file nobody imports simply moves with zero rewrites.
|
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`)
|
## AI-agent usage (`--json`)
|
||||||
|
|
||||||
Every `--json` response is a flat envelope: `status` (`"ok"` | `"dry_run"`
|
Every `--json` response is a flat envelope: `status` (`"ok"` | `"dry_run"`
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
|
|
||||||
Moves or renames source files inside a project and updates every import
|
Moves or renames source files inside a project and updates every import
|
||||||
statement referencing them. Never breaks imports. Supported: TypeScript,
|
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
|
## When to use
|
||||||
|
|
||||||
|
|
@ -32,6 +33,20 @@ jmove check [--root DIR] [--json]
|
||||||
|
|
||||||
Run after any move (or any edit) to validate project consistency.
|
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
|
## Recommended agent workflow
|
||||||
|
|
||||||
1. `jmove mv <src> <target> --dry-run --json` — preview.
|
1. `jmove mv <src> <target> --dry-run --json` — preview.
|
||||||
|
|
|
||||||
|
|
@ -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
31
src/core/index/files.rs
Normal 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
147
src/core/index/mod.rs
Normal 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
121
src/core/index/tests.rs
Normal 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
141
src/core/plan/java/mod.rs
Normal 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
183
src/core/plan/java/tests.rs
Normal 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,12 @@
|
||||||
//!
|
//!
|
||||||
//! A plan is pure data (no disk writes), so dry-run and `--json` can render
|
//! A plan is pure data (no disk writes), so dry-run and `--json` can render
|
||||||
//! it without touching the filesystem. Specifier arithmetic lives in
|
//! it without touching the filesystem. Specifier arithmetic lives in
|
||||||
//! [`specifier`].
|
//! [`specifier`]; the Java package/directory flavour in [`java`].
|
||||||
|
|
||||||
|
mod java;
|
||||||
mod specifier;
|
mod specifier;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod tests_support;
|
||||||
|
|
||||||
pub use specifier::relative_specifier;
|
pub use specifier::relative_specifier;
|
||||||
|
|
||||||
|
|
@ -13,6 +16,7 @@ use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::core::index::Index;
|
use crate::core::index::Index;
|
||||||
use crate::core::{JmoveError, JmoveResult, normalize_rel_path};
|
use crate::core::{JmoveError, JmoveResult, normalize_rel_path};
|
||||||
|
use crate::parser::SourceLanguage;
|
||||||
|
|
||||||
/// One in-file replacement of an import specifier. Only the specifier text
|
/// One in-file replacement of an import specifier. Only the specifier text
|
||||||
/// between the quotes is touched — the statement layout is never reformatted.
|
/// 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`.
|
/// Compute the rewrite plan for `source -> target`.
|
||||||
///
|
///
|
||||||
/// Every indexed import whose resolved target equals `source` gets a new
|
/// TS/JS: every indexed import whose resolved target equals `source` gets a
|
||||||
/// relative specifier from the importer's directory to `target` (see
|
/// new relative specifier from the importer's directory to `target` (see
|
||||||
/// [`relative_specifier`]). Rewrites whose result equals the old specifier
|
/// [`relative_specifier`]). Java moves additionally rewrite the moved file's
|
||||||
/// are dropped; the result is sorted by (file, span).
|
/// `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> {
|
pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<MovePlan> {
|
||||||
let rel = |label: &str, p: &Path| match normalize_rel_path(p) {
|
let rel = |label: &str, p: &Path| match normalize_rel_path(p) {
|
||||||
Some(r) => Ok(r),
|
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();
|
let mut rewrites = Vec::new();
|
||||||
for importer in index.importers_of(&source) {
|
for importer in index.importers_of(source) {
|
||||||
let edges = index.imports[&importer]
|
let edges = index.imports[&importer]
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| e.target.as_deref() == Some(source.as_path()));
|
.filter(|e| e.target.as_deref() == Some(source));
|
||||||
for edge in edges {
|
for edge in edges {
|
||||||
let new_text = relative_specifier(&importer, &target);
|
let new_text = relative_specifier(&importer, target);
|
||||||
if new_text == edge.record.specifier {
|
if new_text == edge.record.specifier {
|
||||||
continue; // no-op rewrite, never reaches the plan
|
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));
|
rewrites.sort_by_key(|r| (r.file.clone(), r.span.start));
|
||||||
Ok(MovePlan {
|
rewrites
|
||||||
source,
|
|
||||||
target,
|
|
||||||
rewrites,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -104,23 +119,9 @@ mod tests {
|
||||||
use super::{Rewrite, plan_move};
|
use super::{Rewrite, plan_move};
|
||||||
use crate::core::JmoveError;
|
use crate::core::JmoveError;
|
||||||
use crate::core::index::{Index, ResolvedImport};
|
use crate::core::index::{Index, ResolvedImport};
|
||||||
use crate::parser::ImportRecord;
|
use crate::core::plan::tests_support::edge;
|
||||||
use std::ops::Range;
|
|
||||||
use std::path::{Path, PathBuf};
|
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 {
|
fn index_with(files: &[&str], imports: &[(&str, Vec<ResolvedImport>)]) -> Index {
|
||||||
let mut ix = Index::default();
|
let mut ix = Index::default();
|
||||||
for f in files {
|
for f in files {
|
||||||
|
|
|
||||||
20
src/core/plan/tests_support.rs
Normal file
20
src/core/plan/tests_support.rs
Normal 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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
//! Moving a file inside a project invalidates every relative import that
|
||||||
//! points at it. `jmove` indexes the project's import graph, computes the
|
//! points at it. `jmove` indexes the project's import graph, computes the
|
||||||
|
|
|
||||||
246
src/parser/java/mod.rs
Normal file
246
src/parser/java/mod.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,20 +4,25 @@
|
||||||
//!
|
//!
|
||||||
//! - A [`Language`] parses one source file into [`ImportRecord`]s: every
|
//! - A [`Language`] parses one source file into [`ImportRecord`]s: every
|
||||||
//! *static-ish* module reference (TS `import`/`export from`/`require`/
|
//! *static-ish* module reference (TS `import`/`export from`/`require`/
|
||||||
//! dynamic `import()`).
|
//! dynamic `import()`; Java single-type and static-member `import`s —
|
||||||
//! - [`crate::core::parser_support::resolve`] turns a specifier into a
|
//! on-demand `pkg.*` imports are deliberately not extracted).
|
||||||
//! project-relative file path using a resolver aware of the indexed file
|
//! - Java files additionally expose their [`PackageDecl`] via
|
||||||
//! set. Non-project (package/bare) specifiers resolve to `None`.
|
//! [`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
|
//! - Rewrites must touch **only the specifier string**, never the rest of
|
||||||
//! the statement (KISS + no formatter dependency): that is why
|
//! the statement (KISS + no formatter dependency): that is why
|
||||||
//! [`ImportRecord::span`] is a byte range into the original source.
|
//! [`ImportRecord::span`] is a byte range into the original source.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub mod java;
|
||||||
pub mod resolve;
|
pub mod resolve;
|
||||||
pub mod ts;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum SourceLanguage {
|
pub enum SourceLanguage {
|
||||||
/// `.ts` (non-TSX) sources.
|
/// `.ts` (non-TSX) sources.
|
||||||
|
|
@ -26,6 +31,8 @@ pub enum SourceLanguage {
|
||||||
Tsx,
|
Tsx,
|
||||||
/// Plain `.js` / `.mjs` / `.cjs` sources.
|
/// Plain `.js` / `.mjs` / `.cjs` sources.
|
||||||
JavaScript,
|
JavaScript,
|
||||||
|
/// `.java` sources.
|
||||||
|
Java,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SourceLanguage {
|
impl SourceLanguage {
|
||||||
|
|
@ -36,6 +43,7 @@ impl SourceLanguage {
|
||||||
"ts" | "mts" | "cts" => Some(Self::TypeScript),
|
"ts" | "mts" | "cts" => Some(Self::TypeScript),
|
||||||
"tsx" | "jsx" => Some(Self::Tsx),
|
"tsx" | "jsx" => Some(Self::Tsx),
|
||||||
"js" | "mjs" | "cjs" => Some(Self::JavaScript),
|
"js" | "mjs" | "cjs" => Some(Self::JavaScript),
|
||||||
|
"java" => Some(Self::Java),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,16 +64,27 @@ impl SourceLanguage {
|
||||||
/// One module reference found in a source file.
|
/// One module reference found in a source file.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ImportRecord {
|
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,
|
pub specifier: String,
|
||||||
/// Byte range of the *specifier string contents* (inside the quotes,
|
/// Byte range of the *specifier string contents* (inside the quotes,
|
||||||
/// without the quote characters) in the parsed file. The rewriter
|
/// without the quote characters) in the parsed file. The rewriter
|
||||||
/// replaces exactly this span and nothing else.
|
/// replaces exactly this span and nothing else.
|
||||||
pub span: std::ops::Range<usize>,
|
pub span: Range<usize>,
|
||||||
/// `true` for dynamic `import("...")` / `require("...")` occurrences.
|
/// `true` for dynamic `import("...")` / `require("...")` occurrences.
|
||||||
pub is_dynamic: bool,
|
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.
|
/// A language frontend that extracts imports from source text.
|
||||||
pub trait Language: Send + Sync {
|
pub trait Language: Send + Sync {
|
||||||
/// The language this frontend handles.
|
/// The language this frontend handles.
|
||||||
|
|
@ -74,10 +93,21 @@ pub trait Language: Send + Sync {
|
||||||
/// Extract all import records from `source` in byte-offset order.
|
/// Extract all import records from `source` in byte-offset order.
|
||||||
/// Parse errors must not be fatal: return what was understood.
|
/// Parse errors must not be fatal: return what was understood.
|
||||||
fn extract_imports(&self, source: &str) -> Vec<ImportRecord>;
|
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`.
|
/// Build the default frontend for `lang`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn frontend_for(lang: SourceLanguage) -> Box<dyn Language> {
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,13 @@ fn visit(node: Node, source: &str, out: &mut Vec<ImportRecord>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Grammar for a language variant. The TypeScript grammar also parses
|
/// 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 {
|
fn grammar(lang: SourceLanguage) -> Language {
|
||||||
match lang {
|
match lang {
|
||||||
SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
|
SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
|
||||||
SourceLanguage::TypeScript | SourceLanguage::JavaScript => {
|
_ => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
|
||||||
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
86
tests/cli.rs
86
tests/cli.rs
|
|
@ -1,71 +1,19 @@
|
||||||
//! End-to-end tests for the `jmove` CLI against the TypeScript fixtures.
|
//! 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;
|
mod common;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use assert_cmd::Command;
|
use common::{copy_fixture, fixture_dir, in_root, jmove, read};
|
||||||
use assert_cmd::assert::Assert;
|
|
||||||
use predicates::prelude::*;
|
use predicates::prelude::*;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
/// Recursively copy `tests/typescript/<name>` into a tempdir and return it.
|
/// Convenience wrapper: a TypeScript fixture by name.
|
||||||
///
|
fn fixture(name: &str) -> TempDir {
|
||||||
/// An empty `.git` marker is created in the copy: the `ignore` crate only
|
copy_fixture("typescript", name)
|
||||||
/// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mv_dry_run_prints_diff_and_leaves_disk_untouched() {
|
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"])
|
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts", "--dry-run"])
|
||||||
.success()
|
.success()
|
||||||
.stdout(predicate::str::contains("./lib/sum"))
|
.stdout(predicate::str::contains("./lib/sum"))
|
||||||
|
|
@ -80,14 +28,14 @@ fn mv_dry_run_prints_diff_and_leaves_disk_untouched() {
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
read(&in_root(tmp.path(), "app.ts")),
|
read(&in_root(tmp.path(), "app.ts")),
|
||||||
read(&fixture_dir("basic").join("app.ts")),
|
read(&fixture_dir("typescript", "basic").join("app.ts")),
|
||||||
"importer untouched"
|
"importer untouched"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mv_rewrites_importer_and_moves_the_file() {
|
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"])
|
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts"])
|
||||||
.success()
|
.success()
|
||||||
.stdout(predicate::str::contains("moved lib/sum.ts -> utils/sum.ts"))
|
.stdout(predicate::str::contains("moved lib/sum.ts -> utils/sum.ts"))
|
||||||
|
|
@ -109,7 +57,7 @@ fn mv_rewrites_importer_and_moves_the_file() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mv_updates_barrel_and_never_touches_node_modules() {
|
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();
|
jmove(&tmp, &["mv", "src/impl/core.ts", "src/impl/calc.ts"]).success();
|
||||||
assert!(
|
assert!(
|
||||||
read(&in_root(tmp.path(), "src/impl/index.ts")).contains("./calc"),
|
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]
|
#[test]
|
||||||
fn mv_rewrites_only_the_specifier_line_in_multi_line_imports() {
|
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 view = in_root(tmp.path(), "src/ui/deep/nested/view.ts");
|
||||||
let before: Vec<String> = read(&view).lines().map(str::to_owned).collect();
|
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]
|
#[test]
|
||||||
fn check_passes_on_clean_and_fails_on_broken_fixture() {
|
fn check_passes_on_clean_and_fails_on_broken_fixture() {
|
||||||
let clean = copy_fixture("basic");
|
let clean = fixture("basic");
|
||||||
jmove(&clean, &["check"])
|
jmove(&clean, &["check"])
|
||||||
.success()
|
.success()
|
||||||
.stdout(predicate::str::contains("no broken imports"));
|
.stdout(predicate::str::contains("no broken imports"));
|
||||||
|
|
||||||
let messy = copy_fixture("complex");
|
let messy = fixture("complex");
|
||||||
jmove(&messy, &["check"])
|
jmove(&messy, &["check"])
|
||||||
.code(2)
|
.code(2)
|
||||||
.stdout(predicate::str::contains(
|
.stdout(predicate::str::contains(
|
||||||
|
|
@ -169,7 +117,7 @@ fn check_passes_on_clean_and_fails_on_broken_fixture() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_json_reports_broken_import_payload() {
|
fn check_json_reports_broken_import_payload() {
|
||||||
let tmp = copy_fixture("complex");
|
let tmp = fixture("complex");
|
||||||
jmove(&tmp, &["check", "--json"]).code(2).stdout(
|
jmove(&tmp, &["check", "--json"]).code(2).stdout(
|
||||||
predicate::str::contains("\"status\": \"ok\"")
|
predicate::str::contains("\"status\": \"ok\"")
|
||||||
.and(predicate::str::contains("\"operation\": \"check\""))
|
.and(predicate::str::contains("\"operation\": \"check\""))
|
||||||
|
|
@ -183,7 +131,7 @@ fn check_json_reports_broken_import_payload() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mv_json_happy_path_reports_changed_files() {
|
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"])
|
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts", "--json"])
|
||||||
.success()
|
.success()
|
||||||
.stdout(
|
.stdout(
|
||||||
|
|
@ -203,7 +151,7 @@ fn mv_json_happy_path_reports_changed_files() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mv_json_dry_run_reports_preview_payload() {
|
fn mv_json_dry_run_reports_preview_payload() {
|
||||||
let tmp = copy_fixture("basic");
|
let tmp = fixture("basic");
|
||||||
jmove(
|
jmove(
|
||||||
&tmp,
|
&tmp,
|
||||||
&["mv", "lib/sum.ts", "utils/sum.ts", "--dry-run", "--json"],
|
&["mv", "lib/sum.ts", "utils/sum.ts", "--dry-run", "--json"],
|
||||||
|
|
@ -224,7 +172,7 @@ fn mv_json_dry_run_reports_preview_payload() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mv_json_reports_target_exists_error_shape() {
|
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"])
|
jmove(&tmp, &["mv", "app.ts", "lib/sum.ts", "--json"])
|
||||||
.code(1)
|
.code(1)
|
||||||
.stdout(
|
.stdout(
|
||||||
|
|
@ -241,7 +189,7 @@ fn mv_json_reports_target_exists_error_shape() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mv_reports_source_not_found() {
|
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"])
|
jmove(&tmp, &["mv", "lib/nope.ts", "utils/nope.ts", "--json"])
|
||||||
.code(1)
|
.code(1)
|
||||||
.stdout(predicate::str::contains("\"code\": \"SOURCE_NOT_FOUND\""));
|
.stdout(predicate::str::contains("\"code\": \"SOURCE_NOT_FOUND\""));
|
||||||
|
|
|
||||||
151
tests/cli_java.rs
Normal file
151
tests/cli_java.rs
Normal 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
64
tests/common/mod.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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`.
|
|
||||||
6
tests/java/basic/src/main/java/Main.java
Normal file
6
tests/java/basic/src/main/java/Main.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
10
tests/java/basic/src/main/java/com/example/app/App.java
Normal file
10
tests/java/basic/src/main/java/com/example/app/App.java
Normal 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());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.example.model;
|
||||||
|
|
||||||
|
import com.example.util.Text;
|
||||||
|
|
||||||
|
public class User {
|
||||||
|
String loudName() {
|
||||||
|
return Text.shout("user");
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tests/java/basic/src/main/java/com/example/util/Text.java
Normal file
11
tests/java/basic/src/main/java/com/example/util/Text.java
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
8
todo.md
8
todo.md
|
|
@ -32,10 +32,10 @@
|
||||||
- [x] docs/EXAMPLES.md — примеры для людей и агентов
|
- [x] docs/EXAMPLES.md — примеры для людей и агентов
|
||||||
|
|
||||||
## Phase 1.5 — Java (наша ниша, аналогов в CLI нет)
|
## Phase 1.5 — Java (наша ниша, аналогов в CLI нет)
|
||||||
- [ ] tree-sitter Java грамматика: package + import extraction
|
- [x] tree-sitter Java грамматика: package + import extraction
|
||||||
- [ ] Детект source root (src/main/java, src/) и соответствие package ⇄ директория
|
- [x] Детект source root (src/main/java, src/) и соответствие package ⇄ директория
|
||||||
- [ ] mv = три синхронных правки: package, все import в проекту, физический перенос
|
- [x] mv = три синхронных правки: package, все import в проекту, физический перенос
|
||||||
- [ ] e2e фикстуры tests/java/
|
- [x] e2e фикстуры tests/java/
|
||||||
|
|
||||||
## Phase 2
|
## Phase 2
|
||||||
- [ ] Кэш индекса на диске (bincode/rkyv) → .jmove/index
|
- [ ] Кэш индекса на диске (bincode/rkyv) → .jmove/index
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue