diff --git a/docs/SKILL.md b/docs/SKILL.md index 2486cb6..e16325f 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -40,6 +40,13 @@ working tree unstaged either way — stage or commit them yourself. `--json` reports the choice as `moved_via` (`"git"`/`"fs"`) and, on a dry-run, `would_move_via`. +tsconfig `paths`: `compilerOptions.paths` prefixes (`@/*`, `@cfg` exact +keys, `baseUrl`-relative) map bare specifiers into the project graph, so +aliased imports participate in `mv` — the rewrite keeps the alias shape +while the new file stays inside the mapped tree and falls back to a +relative specifier otherwise. `extends` chains and 2nd+ candidate lists +are not followed (v1); an invalid tsconfig silently means "no aliases". + ### check — find broken imports and Java layout errors ``` diff --git a/src/core/index/mod.rs b/src/core/index/mod.rs index 83aa37e..92b3419 100644 --- a/src/core/index/mod.rs +++ b/src/core/index/mod.rs @@ -9,6 +9,9 @@ mod files; #[cfg(test)] mod tests; +mod tsconfig; + +pub use tsconfig::PathAliases; use std::collections::{BTreeMap, HashMap}; use std::fs; @@ -48,6 +51,8 @@ pub struct Index { /// FQN → file map for every indexed Java class; the fix rules use it /// for candidate lookup (empty when the project has no Java sources). pub java_classes: JavaClassIndex, + /// tsconfig `compilerOptions.paths` alias table (empty without one). + pub aliases: PathAliases, } impl Index { @@ -64,12 +69,14 @@ impl Index { /// collision-free resolution (and therefore `mv`/`fix` rewrites) exact. pub fn build_scoped(root: &Path, source_root: Option<&Path>) -> JmoveResult { let root = root.canonicalize()?; + let aliases = PathAliases::load(&root); let mut index = Self { root, files: FileSet::default(), imports: HashMap::new(), packages: HashMap::new(), java_classes: JavaClassIndex::default(), + aliases, }; index.scan(source_root)?; // Resolution needs the complete file set (extension/index guessing) @@ -82,8 +89,14 @@ impl Index { java_classes .resolve(&resolved.record.specifier) .map(PathBuf::from) - } else { + } else if resolved.record.specifier.starts_with('.') { resolve_module(importer, &resolved.record.specifier, &index.files) + } else { + // Bare specifier: only tsconfig `paths` can map it into + // the project; anything else stays external. + index + .aliases + .resolve(&resolved.record.specifier, &index.files) }; } } diff --git a/src/core/index/tsconfig/mod.rs b/src/core/index/tsconfig/mod.rs new file mode 100644 index 0000000..a024931 --- /dev/null +++ b/src/core/index/tsconfig/mod.rs @@ -0,0 +1,212 @@ +//! tsconfig `compilerOptions.paths`: the alias table for bare specifiers. +//! +//! Most real TS projects import through aliases (`@/utils/x`, `@cfg`). +//! Without the mapping those imports are invisible to the graph, so `mv` +//! would leave them pointing at the old location. Loading is best-effort by +//! design: no tsconfig, invalid JSON or an unreadable file simply mean "no +//! aliases", never an error. `extends` chains are not followed (v1). +//! +//! tsconfig is JSONC: comments and trailing commas are legal, so the text +//! is sanitized before `serde_json` sees it. + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::core::index::FileSet; +use crate::core::{normalize_rel_path, rel_str}; +use crate::parser::resolve::{MODULE_EXTS, resolve_base}; + +/// One `paths` entry. Star entries match by prefix, plain keys by equality. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Entry { + /// Text the specifier must start with (star stripped), e.g. `"@utils/"`. + prefix: String, + /// `false` for an exact key (`"@cfg"`). + starred: bool, + /// Project-relative directory (star) or module base (exact) the alias maps into. + dir: PathBuf, +} + +/// Alias table; empty is a perfectly normal state. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct PathAliases { + entries: Vec, +} + +impl PathAliases { + /// Load `root/tsconfig.json`; anything unreadable yields no aliases. + #[must_use] + pub fn load(root: &Path) -> Self { + let Ok(raw) = fs::read_to_string(root.join("tsconfig.json")) else { + return Self::default(); + }; + Self::parse(&sanitize_jsonc(&raw)) + } + + fn parse(json: &str) -> Self { + let Ok(value) = serde_json::from_str::(json) else { + return Self::default(); + }; + let options = value.get("compilerOptions"); + let base = options + .and_then(|o| o.get("baseUrl")) + .and_then(Value::as_str) + .and_then(|b| normalize_rel_path(Path::new(b))) + .unwrap_or_default(); + let Some(paths) = options + .and_then(|o| o.get("paths")) + .and_then(Value::as_object) + else { + return Self::default(); + }; + let mut entries = Vec::new(); + for (key, targets) in paths { + // Only the first candidate of a list is honoured (KISS). + let Some(first) = targets + .as_array() + .and_then(|a| a.first()) + .and_then(Value::as_str) + else { + continue; + }; + let starred = key.ends_with('*'); + let prefix = if starred { + key.trim_end_matches('*').to_owned() + } else { + key.clone() + }; + let stripped = first.trim_start_matches("./"); + let dir_src = if first.ends_with("/*") { + stripped.trim_end_matches("/*") + } else { + stripped + }; + let dir = normalize_rel_path(&base.join(dir_src)); + let Some(dir) = dir.filter(|d| !d.as_os_str().is_empty()) else { + continue; + }; + entries.push(Entry { + prefix, + starred, + dir, + }); + } + // Longest alias prefix wins when several match (`@a/b/*` over `@a/*`). + entries.sort_by(|a, b| b.prefix.cmp(&a.prefix)); + Self { entries } + } + + /// Resolve a bare (non-`.`-starting) specifier to an indexed file. + #[must_use] + pub fn resolve(&self, specifier: &str, files: &FileSet) -> Option { + let entry = self.find(specifier)?; + let base = if entry.starred { + entry.dir.join(&specifier[entry.prefix.len()..]) + } else { + entry.dir.clone() + }; + resolve_base(&normalize_rel_path(&base)?, files) + } + + /// Re-express `target` through the same alias `specifier` used before. + /// Star entries map morphologically (the plan runs before the file + /// exists at its destination, so no file-set check is possible): the + /// target must sit inside the alias directory and shed its module + /// extension the same way `resolve` would re-add it. `None` means + /// "fall back to a relative specifier". + #[must_use] + pub fn remap(&self, specifier: &str, target: &Path, files: &FileSet) -> Option { + let entry = self.find(specifier)?; + if !entry.starred { + // Exact key: it only keeps meaning while it maps to this file — + // after the move the old mapping points elsewhere, so this is + // normally `None` and the caller rewrites relatively. + return (self.resolve(specifier, files)? == target).then(|| specifier.to_owned()); + } + let rel = target.strip_prefix(&entry.dir).ok()?; + let text = rel_str(rel); + let stem = MODULE_EXTS + .iter() + .find(|ext| text.ends_with(**ext)) + .and_then(|ext| Some(text.strip_suffix(*ext)?.to_owned())) + .unwrap_or(text); + Some(format!("{}{}", entry.prefix, stem)) + } + + fn find(&self, specifier: &str) -> Option<&Entry> { + self.entries.iter().find(|e| { + if e.starred { + specifier.starts_with(&e.prefix) && specifier.len() > e.prefix.len() + } else { + specifier == e.prefix + } + }) + } +} + +/// Strip `//` + `/* */` comments and trailing commas from JSONC text. +/// String awareness keeps `"http://x"` and escaped quotes safe. +#[must_use] +pub(crate) fn sanitize_jsonc(src: &str) -> String { + let bytes = src.as_bytes(); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + let mut in_string = false; + while i < bytes.len() { + let b = bytes[i]; + if in_string { + out.push(b as char); + if b == b'\\' && i + 1 < bytes.len() { + out.push(bytes[i + 1] as char); + i += 2; + continue; + } + if b == b'"' { + in_string = false; + } + i += 1; + continue; + } + match b { + b'"' => { + in_string = true; + out.push('"'); + i += 1; + } + b'/' if bytes.get(i + 1) == Some(&b'/') => { + i = bytes[i..] + .iter() + .position(|c| *c == b'\n') + .map_or(bytes.len(), |p| i + p); + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + let end = bytes[i + 2..] + .windows(2) + .position(|w| w == b"*/") + .map_or(bytes.len() - 2, |p| i + 2 + p + 2); + i = end; + } + b',' => { + // Drop only if the next non-space token closes a container. + let rest = &bytes[i + 1..]; + match rest.iter().find(|c| !c.is_ascii_whitespace()) { + Some(b'}') | Some(b']') => i += 1, + _ => { + out.push(','); + i += 1; + } + } + } + _ => { + out.push(b as char); + i += 1; + } + } + } + out +} + +#[cfg(test)] +mod tests; diff --git a/src/core/index/tsconfig/tests.rs b/src/core/index/tsconfig/tests.rs new file mode 100644 index 0000000..ef55f89 --- /dev/null +++ b/src/core/index/tsconfig/tests.rs @@ -0,0 +1,80 @@ +use super::{PathAliases, sanitize_jsonc}; +use crate::core::index::FileSet; +use std::path::{Path, PathBuf}; + +fn files(paths: &[&str]) -> FileSet { + let mut set = FileSet::default(); + for p in paths { + set.add(PathBuf::from(p)); + } + set +} + +fn aliases(json: &str) -> PathAliases { + PathAliases::parse(&sanitize_jsonc(json)) +} + +const TSCONFIG: &str = r#"{ + // comment with a brace } + "compilerOptions": { +"baseUrl": "./", +"paths": { + "@/*": ["src/*"], + "@utils/*": ["src/shared/utils/*"], + "@cfg": ["src/config.ts"], +}, + }, + "include": ["src/**/*"], +}"#; + +#[test] +fn jsonc_comments_and_trailing_commas_parse() { + let a = aliases(TSCONFIG); + assert_eq!(a.entries.len(), 3); +} + +#[test] +fn resolution_prefers_the_longest_matching_prefix() { + let set = files(&[ + "src/shared/utils/str.ts", + "src/utils/str.ts", + "src/config.ts", + ]); + let a = aliases(TSCONFIG); + assert_eq!( + a.resolve("@utils/str", &set).as_deref(), + Some(Path::new("src/shared/utils/str.ts")) + ); + assert_eq!( + a.resolve("@/utils/str", &set).as_deref(), + Some(Path::new("src/utils/str.ts")) + ); + assert_eq!( + a.resolve("@cfg", &set).as_deref(), + Some(Path::new("src/config.ts")) + ); + assert_eq!(a.resolve("react", &set), None); +} + +#[test] +fn remap_keeps_alias_shape_inside_and_falls_back_outside() { + let set = files(&["src/utils/str.ts", "src/deep/str.ts", "src/config.ts"]); + let a = aliases(r#"{"compilerOptions": {"paths": {"@u/*": ["src/utils/*"]}}}"#); + // Destination need not exist yet: the plan runs before the move. + assert_eq!( + a.remap("@u/str", Path::new("src/utils/other.ts"), &set) + .as_deref(), + Some("@u/other") + ); + // Target outside the alias tree: caller must fall back to relative. + assert!(a.remap("@u/str", Path::new("lib/other.ts"), &set).is_none()); +} + +#[test] +fn string_contents_survive_sanitizing() { + let json = r#"{"url": "http://x.com//y", "a": [1, 2,], /* c */ "b": "/*n*/"}"#; + let cleaned = sanitize_jsonc(json); + let value: serde_json::Value = serde_json::from_str(&cleaned).unwrap(); + assert_eq!(value["url"], "http://x.com//y"); + assert_eq!(value["b"], "/*n*/"); +} diff --git a/src/core/plan/mod.rs b/src/core/plan/mod.rs index 8fae7b8..8c03e79 100644 --- a/src/core/plan/mod.rs +++ b/src/core/plan/mod.rs @@ -147,7 +147,13 @@ fn ts_rewrites(index: &Index, source: &Path, target: &Path) -> Vec { .iter() .filter(|e| e.target.as_deref() == Some(source)); for edge in edges { - let new_text = relative_specifier(&importer, target); + // Aliased imports keep their alias shape when the new location + // still round-trips through the same mapping; everything else + // gets the relative rewrite. + let new_text = index + .aliases + .remap(&edge.record.specifier, target, &index.files) + .unwrap_or_else(|| relative_specifier(&importer, target)); if new_text == edge.record.specifier { continue; // no-op rewrite, never reaches the plan } diff --git a/src/parser/resolve.rs b/src/parser/resolve.rs index b636895..3ac5986 100644 --- a/src/parser/resolve.rs +++ b/src/parser/resolve.rs @@ -14,6 +14,10 @@ use crate::core::normalize_rel_path; /// Supported extensions, in Node/TS resolution priority order. const EXTENSIONS: [&str; 6] = ["ts", "tsx", "js", "jsx", "mjs", "cjs"]; +/// Module extension *suffixes*, longest-first: `resolve_base` re-adds them +/// to a specifier, alias remapping sheds them again (the exact inverse). +pub const MODULE_EXTS: &[&str] = &[".d.ts", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; + /// Ambient declaration files: only consulted after every real module /// candidate missed (last resort). const DECLARATION_EXT: &str = "d.ts"; @@ -45,9 +49,18 @@ pub fn resolve_module(importer: &Path, specifier: &str, files: &FileSet) -> Opti // `importer` is project-relative, so `..` segments that walk past the // root collapse to `None` here instead of escaping the index. let base = normalize_rel_path(&importer.parent()?.join(specifier))?; - if files.contains(&base) { - return Some(base); + resolve_base(&base, files) +} + +/// Resolve a project-relative module base path against the file set: +/// exact file, extension guessing, then `index.*` in the directory. +/// Shared by relative specifiers and tsconfig alias mapping. +#[must_use] +pub fn resolve_base(base: &Path, files: &FileSet) -> Option { + if files.contains(base) { + return Some(base.to_path_buf()); } + let base = base.to_path_buf(); for ext in EXTENSIONS { let candidate = with_ext(&base, ext); if files.contains(&candidate) { diff --git a/tests/cli_alias.rs b/tests/cli_alias.rs new file mode 100644 index 0000000..f5f0948 --- /dev/null +++ b/tests/cli_alias.rs @@ -0,0 +1,37 @@ +//! End-to-end tests for tsconfig `paths` aliases: aliased imports take +//! part in `mv` and keep their alias shape inside the mapped tree. + +mod common; + +use common::{copy_fixture, in_root, jmove, read}; +use predicates::prelude::*; + +const APP: &str = "src/app.ts"; + +#[test] +fn aliased_imports_follow_the_move_and_keep_the_alias_shape() { + let tmp = copy_fixture("typescript", "aliased"); + jmove(&tmp, &["check"]).success(); // "@utils/str" resolves: not "broken" + jmove(&tmp, &["mv", "src/utils/str.ts", "src/utils/text.ts"]) + .success() + .stdout(predicate::str::contains("updated 1 import")); + let app = read(&in_root(tmp.path(), APP)); + assert!(app.contains("from \"@utils/text\""), "{app}"); + assert!(!app.contains("./utils/text"), "{app}"); + // Moving out of the alias tree falls back to a relative specifier. + jmove(&tmp, &["mv", "src/utils/text.ts", "src/core/text.ts"]).success(); + let app = read(&in_root(tmp.path(), APP)); + assert!(app.contains("from \"./core/text\""), "{app}"); + jmove(&tmp, &["check"]).success(); +} + +#[test] +fn exact_alias_key_falls_back_when_it_no_longer_matches() { + let tmp = copy_fixture("typescript", "aliased"); + jmove(&tmp, &["mv", "src/config.ts", "src/settings.ts"]).success(); + let app = read(&in_root(tmp.path(), APP)); + // "@cfg" would now resolve to nothing: the rewrite must be relative. + assert!(app.contains("from \"./settings\""), "{app}"); + assert!(!app.contains("@cfg"), "{app}"); + jmove(&tmp, &["check"]).success(); +} diff --git a/tests/typescript/aliased/package.json b/tests/typescript/aliased/package.json new file mode 100644 index 0000000..3f4a4c7 --- /dev/null +++ b/tests/typescript/aliased/package.json @@ -0,0 +1 @@ +{ "name": "aliased-fixture" } diff --git a/tests/typescript/aliased/src/app.ts b/tests/typescript/aliased/src/app.ts new file mode 100644 index 0000000..611b2ea --- /dev/null +++ b/tests/typescript/aliased/src/app.ts @@ -0,0 +1,7 @@ +import { shout } from "@utils/str"; +import cfg from "@cfg"; +import { log } from "./utils/log"; + +export function go(): string { + return shout(cfg) + log(); +} diff --git a/tests/typescript/aliased/src/config.ts b/tests/typescript/aliased/src/config.ts new file mode 100644 index 0000000..614457e --- /dev/null +++ b/tests/typescript/aliased/src/config.ts @@ -0,0 +1 @@ +export default "cfg"; diff --git a/tests/typescript/aliased/src/utils/log.ts b/tests/typescript/aliased/src/utils/log.ts new file mode 100644 index 0000000..2cf3ecd --- /dev/null +++ b/tests/typescript/aliased/src/utils/log.ts @@ -0,0 +1,3 @@ +export function log(): string { + return ""; +} diff --git a/tests/typescript/aliased/src/utils/str.ts b/tests/typescript/aliased/src/utils/str.ts new file mode 100644 index 0000000..2b5e1b1 --- /dev/null +++ b/tests/typescript/aliased/src/utils/str.ts @@ -0,0 +1,3 @@ +export function shout(s: string): string { + return s.toUpperCase(); +} diff --git a/tests/typescript/aliased/tsconfig.json b/tests/typescript/aliased/tsconfig.json new file mode 100644 index 0000000..26e34f3 --- /dev/null +++ b/tests/typescript/aliased/tsconfig.json @@ -0,0 +1,10 @@ +{ + // JSONC on purpose: comments and trailing commas must not break loading + "compilerOptions": { + "baseUrl": "./src", + "paths": { + "@utils/*": ["utils/*"], + "@cfg": ["config.ts"], + }, + }, +} diff --git a/todo.md b/todo.md index e7bbd10..580817c 100644 --- a/todo.md +++ b/todo.md @@ -90,7 +90,9 @@ AI оставляем СНАРУЖИ: при неоднозначности jmov ## Phase 2 - [ ] Кэш индекса на диске (bincode/rkyv) → .jmove/index - [ ] Инкрементальная переиндексация (только изменённые файлы) -- [ ] Поддержка tsconfig paths / алиасов (@/...) +- [x] Поддержка tsconfig paths / алиасов (@/...): JSONC-парсер (комментарии/хвостовые запятые), + baseUrl + star/exact keys, longest-prefix wins; при mv алиас сохраняется, если файл остался + в дереве алиаса, иначе fallback на относительный; extends/2+ кандидаты — осознанно не делаем (v1) - [ ] Параллельная индексация через rayon - [x] --git интеграция (git mv для stage/истории): auto для tracked файлов, --no-git флаг, moved_via/would_move_via в --json - [x] Перенос директорий целиком (mv папки): зеркальный batch-move всех индексируемых файлов, merged rewrites, prune пустых исходных каталогов, left_behind для неиндексируемых