"""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