feat: tsconfig compilerOptions.paths — aliased bare specifiers join the graph and follow moves

- 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
This commit is contained in:
loki5512344 2026-09-15 19:17:06 +02:00
parent f8004e669b
commit b1af375763
Signed by: boba
GPG key ID: 253067914055423B
14 changed files with 400 additions and 5 deletions

View file

@ -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<Self> {
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)
};
}
}

View file

@ -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<Entry>,
}
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::<Value>(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<PathBuf> {
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<String> {
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;

View file

@ -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*/");
}

View file

@ -147,7 +147,13 @@ fn ts_rewrites(index: &Index, source: &Path, target: &Path) -> Vec<Rewrite> {
.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
}

View file

@ -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<PathBuf> {
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) {