From b1af375763d900add139a16b0c91ebebb94250f3 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Tue, 15 Sep 2026 19:17:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20tsconfig=20compilerOptions.paths=20?= =?UTF-8?q?=E2=80=94=20aliased=20bare=20specifiers=20join=20the=20graph=20?= =?UTF-8?q?and=20follow=20moves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index/tsconfig: JSONC sanitize (comments, trailing commas), baseUrl + star/exact path keys, first candidate only, longest-prefix wins; invalid or missing tsconfig silently means no aliases - resolution pass: '.'-prefixed specifiers resolve relatively as before, bare ones go through the alias table and join the importer graph - planner: remap keeps the alias shape while the moved file stays inside the mapped tree (morphological, since destinations do not exist yet), exact keys keep meaning only while still resolving to the target; otherwise the previous relative rewrite applies unchanged - resolve_base extracted from resolve_module (shared extension/index guessing); MODULE_EXTS is the exact inverse for remapping - e2e: @utils/str.ts -> @utils/text.ts inside alias, -> ./core/text.ts when leaving it, @cfg falls back to ./settings; check passes on aliases --- docs/SKILL.md | 7 + src/core/index/mod.rs | 15 +- src/core/index/tsconfig/mod.rs | 212 ++++++++++++++++++++++ src/core/index/tsconfig/tests.rs | 80 ++++++++ src/core/plan/mod.rs | 8 +- src/parser/resolve.rs | 17 +- tests/cli_alias.rs | 37 ++++ tests/typescript/aliased/package.json | 1 + tests/typescript/aliased/src/app.ts | 7 + tests/typescript/aliased/src/config.ts | 1 + tests/typescript/aliased/src/utils/log.ts | 3 + tests/typescript/aliased/src/utils/str.ts | 3 + tests/typescript/aliased/tsconfig.json | 10 + todo.md | 4 +- 14 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 src/core/index/tsconfig/mod.rs create mode 100644 src/core/index/tsconfig/tests.rs create mode 100644 tests/cli_alias.rs create mode 100644 tests/typescript/aliased/package.json create mode 100644 tests/typescript/aliased/src/app.ts create mode 100644 tests/typescript/aliased/src/config.ts create mode 100644 tests/typescript/aliased/src/utils/log.ts create mode 100644 tests/typescript/aliased/src/utils/str.ts create mode 100644 tests/typescript/aliased/tsconfig.json 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 для неиндексируемых