chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user