jmove/src/parser/mod.rs
loki5512344 7c8498bc2a
feat(ts): ts/unused-import rule — whole-statement delete of dead TS/JS imports
- binds names via tree-sitter (default, namespace, named + alias, type-only),
  keeps a statement when ANY bound name occurs outside it (word scan over
  comments/strings included): under-delete is the safe direction, mirroring
  the Java rule's asymmetry
- ESM caveat encoded in the policy: mixed live/dead statements are not
  touched — removing a specifier would still drop the module's side effects
- side-effect imports and 'export .. from' re-exports are never candidates
- parser/ts became a folder (mod/extract/unused_imports) to keep the
  4-entries-per-module rule; word_occurs shared via crate::parser
- java/rules/unused_imports now reuses the shared word scanner (DRY)
2026-09-15 17:07:46 +02:00

238 lines
8.6 KiB
Rust

//! Language frontends: import extraction and module specifier resolution.
//!
//! ## Contract (stable across submodules — implementers must not change it)
//!
//! - A [`Language`] parses one source file into [`ImportRecord`]s: every
//! *static-ish* module reference (TS `import`/`export from`/`require`/
//! 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.
//! - Fix rules (Phase 1.6) implement [`Fix`] and propose [`FixCandidate`]s:
//! byte [`Edit`](crate::core::Edit)s for one file, run through the same
//! dry-run/atomic engine as `mv`. Deterministic by contract — ambiguity
//! is reported as `auto_fixable: false` for an agent to resolve, never
//! guessed at.
use std::ops::Range;
use std::path::Path;
use crate::core::Edit;
use crate::core::index::Index;
pub mod java;
pub mod resolve;
pub mod ts;
/// 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.
TypeScript,
/// `.tsx` / `.jsx` sources.
Tsx,
/// Plain `.js` / `.mjs` / `.cjs` sources.
JavaScript,
/// `.java` sources.
Java,
}
impl SourceLanguage {
/// Map a file extension (lowercase, no dot) to a language, if supported.
#[must_use]
pub fn from_extension(ext: &str) -> Option<Self> {
match ext {
"ts" | "mts" | "cts" => Some(Self::TypeScript),
"tsx" | "jsx" => Some(Self::Tsx),
"js" | "mjs" | "cjs" => Some(Self::JavaScript),
"java" => Some(Self::Java),
_ => None,
}
}
/// Detect the language from a file name. `None` means "not a source file
/// we index" (skip it).
#[must_use]
pub fn for_path(path: &Path) -> Option<Self> {
Self::from_extension(
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()?,
)
}
}
/// 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"` (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: 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.
fn language(&self) -> SourceLanguage;
/// 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> {
match lang {
SourceLanguage::Java => Box::new(java::TreeSitterJava::new()),
SourceLanguage::TypeScript | SourceLanguage::Tsx | SourceLanguage::JavaScript => {
Box::new(ts::TreeSitterTs::new(lang))
}
}
}
/// Urgency of a [`FixCandidate`] for the reader.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
/// The code does not compile (or resolves) without the fix.
Error,
/// The code works but is dirty: unused or misordered imports.
Warning,
/// Style note only.
Info,
}
impl Severity {
/// Stable lowercase name used in `--json` output.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warning => "warning",
Self::Info => "info",
}
}
/// Overlap-resolution priority: on a byte conflict the more urgent
/// rule wins and the other candidate is deferred to the next run.
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Error => 0,
Self::Warning => 1,
Self::Info => 2,
}
}
}
/// One proposed fix for a single file: what is wrong and — when
/// `auto_fixable` — the exact edits that repair it.
#[derive(Debug, Clone)]
pub struct FixCandidate {
/// Stable rule id; also the value accepted by `jmove fix --rule`.
pub rule: &'static str,
/// One-line explanation for humans and agents.
pub message: String,
/// How urgent the fix is.
pub severity: Severity,
/// `false` marks an ambiguous finding an agent or human must resolve;
/// the engine never applies such candidates automatically.
pub auto_fixable: bool,
/// Byte range of the offending construct (issue location for reports,
/// even when `edits` is empty).
pub span: Range<usize>,
/// Edits on this file, ascending by span.
pub edits: Vec<Edit>,
/// Fully-qualified options for an ambiguous finding (`auto_fixable:
/// false`); empty for every auto-fixable or non-lookup candidate.
pub candidates: Vec<String>,
}
/// A deterministic single-file fix rule (Phase 1.6).
pub trait Fix: Send + Sync {
/// Stable rule id reported in candidates and accepted by `--rule`.
fn rule(&self) -> &'static str;
/// Candidates for `path` (project-relative) with the given contents.
/// A rule may only return `auto_fixable: true` edits it can prove
/// safe; anything ambiguous goes out as a non-auto candidate.
fn fixes(&self, path: &Path, source: &str, index: &Index) -> Vec<FixCandidate>;
}
/// `word` as a standalone identifier token outside `skip`. Shared by the
/// deletion-safety scans of every `unused-import` rule. Bytes >= 0x80
/// count as identifier parts: treating a possibly-mojibake neighbour as
/// "part of a bigger word" can only keep an import, never drop one.
pub(crate) fn word_occurs(source: &[u8], word: &[u8], skip: &Range<usize>) -> bool {
if word.is_empty() {
return false;
}
source.windows(word.len()).enumerate().any(|(at, found)| {
let end = at + word.len();
if at < skip.end && end > skip.start {
return false;
}
let before = at == 0 || !is_ident_byte(source[at - 1]);
let after = end == source.len() || !is_ident_byte(source[end]);
*found == *word && before && after
})
}
fn is_ident_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') || byte >= 0x80
}
/// Default rule set for `lang` (empty for languages without rules yet).
#[must_use]
pub fn fixers_for(lang: SourceLanguage) -> Vec<Box<dyn Fix>> {
match lang {
SourceLanguage::Java => vec![
Box::new(java::rules::unused_imports::JavaUnusedImports::new()),
Box::new(java::rules::missing_imports::JavaMissingImports::new()),
Box::new(java::rules::import_order::JavaImportOrder::new()),
],
SourceLanguage::TypeScript | SourceLanguage::Tsx | SourceLanguage::JavaScript => {
vec![Box::new(ts::unused_imports::TsUnusedImports::new())]
}
}
}
/// Every rule id `jmove fix` currently knows about.
#[must_use]
pub fn rule_ids() -> &'static [&'static str] {
&[
java::rules::unused_imports::RULE,
java::rules::missing_imports::RULE,
java::rules::import_order::RULE,
ts::unused_imports::RULE,
]
}