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
|
|
@ -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
|
||||
//! it without touching the filesystem. Specifier arithmetic lives in
|
||||
//! [`specifier`].
|
||||
//! [`specifier`]; the Java package/directory flavour in [`java`].
|
||||
|
||||
mod java;
|
||||
mod specifier;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests_support;
|
||||
|
||||
pub use specifier::relative_specifier;
|
||||
|
||||
|
|
@ -13,6 +16,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use crate::core::index::Index;
|
||||
use crate::core::{JmoveError, JmoveResult, normalize_rel_path};
|
||||
use crate::parser::SourceLanguage;
|
||||
|
||||
/// One in-file replacement of an import specifier. Only the specifier text
|
||||
/// between the quotes is touched — the statement layout is never reformatted.
|
||||
|
|
@ -41,10 +45,11 @@ pub struct MovePlan {
|
|||
|
||||
/// Compute the rewrite plan for `source -> target`.
|
||||
///
|
||||
/// Every indexed import whose resolved target equals `source` gets a new
|
||||
/// relative specifier from the importer's directory to `target` (see
|
||||
/// [`relative_specifier`]). Rewrites whose result equals the old specifier
|
||||
/// are dropped; the result is sorted by (file, span).
|
||||
/// TS/JS: every indexed import whose resolved target equals `source` gets a
|
||||
/// new relative specifier from the importer's directory to `target` (see
|
||||
/// [`relative_specifier`]). Java moves additionally rewrite the moved file's
|
||||
/// `package` declaration (see [`java`]). Rewrites whose result equals the
|
||||
/// old specifier are dropped; the result is sorted by (file, span).
|
||||
pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<MovePlan> {
|
||||
let rel = |label: &str, p: &Path| match normalize_rel_path(p) {
|
||||
Some(r) => Ok(r),
|
||||
|
|
@ -72,13 +77,27 @@ pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<Mov
|
|||
)));
|
||||
}
|
||||
|
||||
let rewrites = if SourceLanguage::for_path(&source) == Some(SourceLanguage::Java) {
|
||||
java::java_rewrites(index, &source, &target)?
|
||||
} else {
|
||||
ts_rewrites(index, &source, &target)
|
||||
};
|
||||
Ok(MovePlan {
|
||||
source,
|
||||
target,
|
||||
rewrites,
|
||||
})
|
||||
}
|
||||
|
||||
// Relative-specifier rewrites for the TS/JS flavour of the graph.
|
||||
fn ts_rewrites(index: &Index, source: &Path, target: &Path) -> Vec<Rewrite> {
|
||||
let mut rewrites = Vec::new();
|
||||
for importer in index.importers_of(&source) {
|
||||
for importer in index.importers_of(source) {
|
||||
let edges = index.imports[&importer]
|
||||
.iter()
|
||||
.filter(|e| e.target.as_deref() == Some(source.as_path()));
|
||||
.filter(|e| e.target.as_deref() == Some(source));
|
||||
for edge in edges {
|
||||
let new_text = relative_specifier(&importer, &target);
|
||||
let new_text = relative_specifier(&importer, target);
|
||||
if new_text == edge.record.specifier {
|
||||
continue; // no-op rewrite, never reaches the plan
|
||||
}
|
||||
|
|
@ -92,11 +111,7 @@ pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<Mov
|
|||
}
|
||||
}
|
||||
rewrites.sort_by_key(|r| (r.file.clone(), r.span.start));
|
||||
Ok(MovePlan {
|
||||
source,
|
||||
target,
|
||||
rewrites,
|
||||
})
|
||||
rewrites
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -104,23 +119,9 @@ mod tests {
|
|||
use super::{Rewrite, plan_move};
|
||||
use crate::core::JmoveError;
|
||||
use crate::core::index::{Index, ResolvedImport};
|
||||
use crate::parser::ImportRecord;
|
||||
use std::ops::Range;
|
||||
use crate::core::plan::tests_support::edge;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// Hand-wired edges: planner tests never touch the parser.
|
||||
fn edge(spec: &str, span: Range<usize>, target: &str) -> ResolvedImport {
|
||||
let record = ImportRecord {
|
||||
specifier: spec.into(),
|
||||
span,
|
||||
is_dynamic: false,
|
||||
};
|
||||
ResolvedImport {
|
||||
record,
|
||||
target: Some(target.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn index_with(files: &[&str], imports: &[(&str, Vec<ResolvedImport>)]) -> Index {
|
||||
let mut ix = Index::default();
|
||||
for f in files {
|
||||
|
|
|
|||
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
|
||||
//! 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
|
||||
//! *static-ish* module reference (TS `import`/`export from`/`require`/
|
||||
//! dynamic `import()`).
|
||||
//! - [`crate::core::parser_support::resolve`] turns a specifier into a
|
||||
//! project-relative file path using a resolver aware of the indexed file
|
||||
//! set. Non-project (package/bare) specifiers resolve to `None`.
|
||||
//! dynamic `import()`; Java single-type and static-member `import`s —
|
||||
//! on-demand `pkg.*` imports are deliberately not extracted).
|
||||
//! - Java files additionally expose their [`PackageDecl`] via
|
||||
//! [`Language::extract_package`]; resolution of Java specifiers goes
|
||||
//! through [`java::JavaClassIndex`] instead of [`resolve`] (TS relative
|
||||
//! specifiers). External Java imports (jdk, third-party) simply never
|
||||
//! appear in the class index.
|
||||
//! - Rewrites must touch **only the specifier string**, never the rest of
|
||||
//! the statement (KISS + no formatter dependency): that is why
|
||||
//! [`ImportRecord::span`] is a byte range into the original source.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
|
||||
pub mod java;
|
||||
pub mod resolve;
|
||||
pub mod ts;
|
||||
|
||||
/// Source languages `jmove` understands (Phase 1: TypeScript/JavaScript).
|
||||
/// Source languages `jmove` understands (TS/JS in Phase 1, Java in Phase 1.5).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SourceLanguage {
|
||||
/// `.ts` (non-TSX) sources.
|
||||
|
|
@ -26,6 +31,8 @@ pub enum SourceLanguage {
|
|||
Tsx,
|
||||
/// Plain `.js` / `.mjs` / `.cjs` sources.
|
||||
JavaScript,
|
||||
/// `.java` sources.
|
||||
Java,
|
||||
}
|
||||
|
||||
impl SourceLanguage {
|
||||
|
|
@ -36,6 +43,7 @@ impl SourceLanguage {
|
|||
"ts" | "mts" | "cts" => Some(Self::TypeScript),
|
||||
"tsx" | "jsx" => Some(Self::Tsx),
|
||||
"js" | "mjs" | "cjs" => Some(Self::JavaScript),
|
||||
"java" => Some(Self::Java),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -56,16 +64,27 @@ impl SourceLanguage {
|
|||
/// One module reference found in a source file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportRecord {
|
||||
/// Raw specifier text as written, e.g. `"../utils/format"`.
|
||||
/// Raw specifier text as written, e.g. `"../utils/format"` (TS) or
|
||||
/// `"com.example.utils.Parser"` (Java).
|
||||
pub specifier: String,
|
||||
/// Byte range of the *specifier string contents* (inside the quotes,
|
||||
/// without the quote characters) in the parsed file. The rewriter
|
||||
/// replaces exactly this span and nothing else.
|
||||
pub span: std::ops::Range<usize>,
|
||||
pub span: Range<usize>,
|
||||
/// `true` for dynamic `import("...")` / `require("...")` occurrences.
|
||||
pub is_dynamic: bool,
|
||||
}
|
||||
|
||||
/// A Java `package` declaration: dotted name plus the byte span of the name
|
||||
/// (quotes have no meaning here — the span covers `com.example.utils`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackageDecl {
|
||||
/// Declared package name, e.g. `"com.example.utils"`.
|
||||
pub name: String,
|
||||
/// Byte range of the package name in the parsed file.
|
||||
pub span: Range<usize>,
|
||||
}
|
||||
|
||||
/// A language frontend that extracts imports from source text.
|
||||
pub trait Language: Send + Sync {
|
||||
/// The language this frontend handles.
|
||||
|
|
@ -74,10 +93,21 @@ pub trait Language: Send + Sync {
|
|||
/// Extract all import records from `source` in byte-offset order.
|
||||
/// Parse errors must not be fatal: return what was understood.
|
||||
fn extract_imports(&self, source: &str) -> Vec<ImportRecord>;
|
||||
|
||||
/// Extract the `package` declaration, if this language has one and the
|
||||
/// file declares it. Default: no package concept (TS/JS).
|
||||
fn extract_package(&self, _source: &str) -> Option<PackageDecl> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the default frontend for `lang`.
|
||||
#[must_use]
|
||||
pub fn frontend_for(lang: SourceLanguage) -> Box<dyn Language> {
|
||||
Box::new(ts::TreeSitterTs::new(lang))
|
||||
match lang {
|
||||
SourceLanguage::Java => Box::new(java::TreeSitterJava::new()),
|
||||
SourceLanguage::TypeScript | SourceLanguage::Tsx | SourceLanguage::JavaScript => {
|
||||
Box::new(ts::TreeSitterTs::new(lang))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,13 +60,13 @@ fn visit(node: Node, source: &str, out: &mut Vec<ImportRecord>) {
|
|||
}
|
||||
|
||||
/// Grammar for a language variant. The TypeScript grammar also parses
|
||||
/// plain JavaScript; only JSX needs the TSX variant.
|
||||
/// plain JavaScript; only JSX needs the TSX variant. Java never reaches
|
||||
/// this frontend ([`crate::parser::frontend_for`] routes it to
|
||||
/// [`crate::parser::java`]), so everything else maps to plain TypeScript.
|
||||
fn grammar(lang: SourceLanguage) -> Language {
|
||||
match lang {
|
||||
SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
|
||||
SourceLanguage::TypeScript | SourceLanguage::JavaScript => {
|
||||
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
|
||||
}
|
||||
_ => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue