258 lines
8.8 KiB
Python
258 lines
8.8 KiB
Python
"""TypeScript tsconfig.json path alias resolver.
|
|
|
|
Resolves TypeScript path aliases (e.g., ``@/ -> src/``) declared in
|
|
``compilerOptions.paths`` so that ``IMPORTS_FROM`` edges can point to
|
|
real file paths instead of raw alias strings.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Extensions probed when resolving an alias target
|
|
_PROBE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".vue"]
|
|
|
|
# Tsconfig filenames to look for when walking up the directory tree
|
|
_TSCONFIG_NAMES = ["tsconfig.json", "tsconfig.app.json"]
|
|
|
|
|
|
class TsconfigResolver:
|
|
"""Resolves TypeScript path aliases (e.g., @/ -> src/) using tsconfig.json."""
|
|
|
|
def __init__(self) -> None:
|
|
self._cache: dict[str, Optional[dict]] = {}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public API
|
|
# ------------------------------------------------------------------
|
|
|
|
def resolve_alias(self, import_str: str, file_path: str) -> Optional[str]:
|
|
"""Resolve a TS path alias to an absolute file path, or None."""
|
|
try:
|
|
config = self._load_tsconfig_for_file(file_path)
|
|
if config is None:
|
|
return None
|
|
|
|
base_url: Optional[str] = config.get("baseUrl")
|
|
paths: dict[str, list[str]] = config.get("paths", {})
|
|
tsconfig_dir: str = config.get("_tsconfig_dir", "")
|
|
|
|
if not paths:
|
|
return None
|
|
|
|
if base_url:
|
|
base_dir = (Path(tsconfig_dir) / base_url).resolve()
|
|
else:
|
|
base_dir = Path(tsconfig_dir).resolve()
|
|
|
|
return self._match_and_probe(import_str, paths, base_dir)
|
|
except (OSError, ValueError, TypeError):
|
|
logger.debug(
|
|
"TsconfigResolver: unexpected error for %s", file_path, exc_info=True,
|
|
)
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _load_tsconfig_for_file(self, file_path: str) -> Optional[dict]:
|
|
"""Find and load tsconfig.json for the given file."""
|
|
start_dir = Path(file_path).parent.resolve()
|
|
current = start_dir
|
|
visited: list[str] = []
|
|
|
|
while True:
|
|
dir_str = str(current)
|
|
if dir_str in self._cache:
|
|
result = self._cache[dir_str]
|
|
for visited_dir in visited:
|
|
self._cache[visited_dir] = result
|
|
return result
|
|
|
|
visited.append(dir_str)
|
|
|
|
for name in _TSCONFIG_NAMES:
|
|
candidate = current / name
|
|
if candidate.is_file():
|
|
config = self._parse_tsconfig(candidate)
|
|
config["_tsconfig_dir"] = dir_str
|
|
for visited_dir in visited:
|
|
self._cache[visited_dir] = config
|
|
return config
|
|
|
|
parent = current.parent
|
|
if parent == current:
|
|
for visited_dir in visited:
|
|
self._cache[visited_dir] = None
|
|
return None
|
|
current = parent
|
|
|
|
def _parse_tsconfig(self, tsconfig_path: Path) -> dict:
|
|
"""Parse a tsconfig.json file (supports JSONC comments)."""
|
|
seen: set[str] = set()
|
|
return self._resolve_extends(tsconfig_path, seen)
|
|
|
|
def _resolve_extends(self, tsconfig_path: Path, seen: set[str]) -> dict:
|
|
"""Recursively resolve the tsconfig extends chain."""
|
|
canonical = str(tsconfig_path.resolve())
|
|
if canonical in seen:
|
|
logger.debug("TsconfigResolver: cycle detected at %s", canonical)
|
|
return {}
|
|
seen = seen | {canonical}
|
|
|
|
try:
|
|
raw = tsconfig_path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
logger.debug("TsconfigResolver: cannot read %s", tsconfig_path)
|
|
return {}
|
|
|
|
stripped = self._strip_jsonc_comments(raw)
|
|
try:
|
|
data: dict = json.loads(stripped)
|
|
except json.JSONDecodeError:
|
|
logger.debug("TsconfigResolver: invalid JSON in %s", tsconfig_path)
|
|
return {}
|
|
|
|
result: dict = {}
|
|
|
|
extends: Optional[str] = data.get("extends")
|
|
if extends and isinstance(extends, str) and extends.startswith("."):
|
|
parent_path = (tsconfig_path.parent / extends).resolve()
|
|
if not parent_path.suffix:
|
|
parent_path = parent_path.with_suffix(".json")
|
|
if parent_path.is_file():
|
|
parent_config = self._resolve_extends(parent_path, seen)
|
|
parent_opts = parent_config.get("compilerOptions", {})
|
|
result.setdefault("compilerOptions", {}).update(parent_opts)
|
|
|
|
child_opts: dict = data.get("compilerOptions", {})
|
|
result.setdefault("compilerOptions", {}).update(child_opts)
|
|
|
|
compiler_options = result.get("compilerOptions", {})
|
|
if "baseUrl" in compiler_options:
|
|
result["baseUrl"] = compiler_options["baseUrl"]
|
|
if "paths" in compiler_options:
|
|
result["paths"] = compiler_options["paths"]
|
|
|
|
return result
|
|
|
|
def _strip_jsonc_comments(self, text: str) -> str:
|
|
"""Remove // and /* */ comments and trailing commas from JSONC."""
|
|
result: list[str] = []
|
|
i = 0
|
|
n = len(text)
|
|
|
|
while i < n:
|
|
ch = text[i]
|
|
|
|
if ch == '"':
|
|
result.append(ch)
|
|
i += 1
|
|
while i < n:
|
|
c = text[i]
|
|
result.append(c)
|
|
if c == "\\" and i + 1 < n:
|
|
i += 1
|
|
result.append(text[i])
|
|
elif c == '"':
|
|
break
|
|
i += 1
|
|
i += 1
|
|
continue
|
|
|
|
if ch == "/" and i + 1 < n and text[i + 1] == "*":
|
|
i += 2
|
|
while i < n - 1:
|
|
if text[i] == "*" and text[i + 1] == "/":
|
|
i += 2
|
|
break
|
|
i += 1
|
|
else:
|
|
i = n
|
|
continue
|
|
|
|
if ch == "/" and i + 1 < n and text[i + 1] == "/":
|
|
i += 2
|
|
while i < n and text[i] != "\n":
|
|
i += 1
|
|
continue
|
|
|
|
result.append(ch)
|
|
i += 1
|
|
|
|
stripped = "".join(result)
|
|
stripped = re.sub(r",\s*([\]}])", r"\1", stripped)
|
|
return stripped
|
|
|
|
def _match_and_probe(
|
|
self,
|
|
import_str: str,
|
|
paths: dict[str, list[str]],
|
|
base_dir: Path,
|
|
) -> Optional[str]:
|
|
"""Match import_str against alias patterns and probe the filesystem."""
|
|
def _pattern_specificity(item: tuple[str, list[str]]) -> int:
|
|
pat = item[0]
|
|
return len(pat.partition("*")[0])
|
|
|
|
sorted_paths = sorted(paths.items(), key=_pattern_specificity, reverse=True)
|
|
|
|
for pattern, replacements in sorted_paths:
|
|
suffix = _match_pattern(pattern, import_str)
|
|
if suffix is None:
|
|
continue
|
|
|
|
for replacement in replacements:
|
|
if "*" in replacement:
|
|
mapped = replacement.replace("*", suffix, 1)
|
|
else:
|
|
mapped = replacement
|
|
|
|
candidate_base = (base_dir / mapped).resolve()
|
|
found = _probe_path(candidate_base)
|
|
if found:
|
|
return str(found)
|
|
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _match_pattern(pattern: str, import_str: str) -> Optional[str]:
|
|
"""Return the wildcard-matched suffix if pattern matches import_str."""
|
|
if "*" not in pattern:
|
|
return "" if import_str == pattern else None
|
|
|
|
prefix, _, suffix_pat = pattern.partition("*")
|
|
if not (import_str.startswith(prefix) and import_str.endswith(suffix_pat)):
|
|
return None
|
|
|
|
end = len(import_str) - len(suffix_pat) if suffix_pat else len(import_str)
|
|
return import_str[len(prefix):end]
|
|
|
|
|
|
def _probe_path(base: Path) -> Optional[Path]:
|
|
"""Probe base and base + extensions for an existing file."""
|
|
if base.is_file():
|
|
return base
|
|
for ext in _PROBE_EXTENSIONS:
|
|
candidate = base.with_suffix(ext) if not base.suffix else Path(str(base) + ext)
|
|
if candidate.is_file():
|
|
return candidate
|
|
if base.is_dir():
|
|
for ext in _PROBE_EXTENSIONS:
|
|
candidate = base / f"index{ext}"
|
|
if candidate.is_file():
|
|
return candidate
|
|
return None
|