Coverage for circular_deps / resolvers / typescript.py: 73%
162 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 15:04 -0800
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 15:04 -0800
1from __future__ import annotations
3import os
5from circular_deps.resolvers.base import PathResolver
6from circular_deps.resolvers.tsconfig import TsconfigParser
9class TypeScriptPathResolver(PathResolver):
10 _INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx"]
12 def __init__(self, module_resolution=None, filepath=None, root_dir=None):
13 """Initialize TypeScript path resolver with module resolution mode.
15 Priority order for determining mode:
16 1. Explicit module_resolution parameter (CLI override)
17 2. tsconfig.json closest to filepath
18 3. tsconfig.json at root_dir
19 4. Default: "node16"
21 Args:
22 module_resolution: Explicit mode ("node", "node16", "nodenext", "bundler")
23 filepath: File path to find closest tsconfig.json
24 root_dir: Root directory to search for tsconfig.json
25 """
26 if module_resolution:
27 self.module_resolution = self._normalize_module_resolution(
28 module_resolution
29 )
30 elif filepath:
31 self.module_resolution = self._detect_from_tsconfig(filepath)
32 elif root_dir:
33 self.module_resolution = self._detect_from_tsconfig(root_dir)
34 else:
35 self.module_resolution = "node16"
36 self._validate_module_resolution()
38 def _normalize_module_resolution(self, mode: str) -> str:
39 if mode in ("nodenext", "node16"):
40 return "node16"
41 return mode
43 def _detect_from_tsconfig(self, path: str) -> str:
44 tsconfig_path = TsconfigParser.find_tsconfig_for_file(path)
45 if not tsconfig_path:
46 return "node16"
48 config = TsconfigParser.parse_tsconfig(tsconfig_path)
49 if not config:
50 return "node16"
52 mode = TsconfigParser.get_module_resolution(config)
53 return mode or "node16"
55 def _validate_module_resolution(self):
56 valid_modes = {"node", "node16", "bundler"}
57 if self.module_resolution not in valid_modes:
58 self.module_resolution = "node16"
60 def resolve(self, module, current_file, root_dir):
61 if "node_modules" in module:
62 return None
64 if module.startswith(".") or module.startswith(".."):
65 return self._resolve_relative(module, current_file, root_dir)
66 else:
67 return self._resolve_absolute(module, current_file, root_dir)
69 def _resolve_relative(self, module, current_file, root_dir):
70 current_dir = os.path.dirname(current_file)
71 target_path = os.path.abspath(os.path.join(current_dir, module))
73 return self._resolve_module_path(module, target_path, root_dir)
75 def _resolve_absolute(self, module, current_file, root_dir):
76 module_path = module.replace("/", os.sep)
78 current_dir = os.path.dirname(current_file)
79 target_path = os.path.join(current_dir, module_path)
80 resolved = self._resolve_module_path(module, target_path, root_dir)
81 if resolved:
82 return resolved
84 target_path = os.path.join(root_dir, module_path)
85 return self._resolve_module_path(module, target_path, root_dir)
87 def _resolve_module_path(
88 self, module: str, target_path: str, root_dir: str
89 ) -> str | None:
90 has_extension = self._has_extension(module)
92 if self.module_resolution == "node":
93 if has_extension:
94 return self._resolve_node_with_extension(target_path, root_dir)
95 else:
96 return self._resolve_node_without_extension(
97 module, target_path, root_dir
98 )
99 elif self.module_resolution == "node16":
100 if has_extension:
101 return self._resolve_node16_with_extension(
102 module, target_path, root_dir
103 )
104 else:
105 return None
106 elif self.module_resolution == "bundler":
107 if has_extension:
108 return self._resolve_bundler_with_extension(
109 module, target_path, root_dir
110 )
111 else:
112 return self._resolve_bundler_without_extension(
113 module, target_path, root_dir
114 )
116 return None
118 def _has_extension(self, module_path: str) -> bool:
119 return os.path.splitext(module_path)[1] != ""
121 def _strip_extension(self, module_path: str) -> str:
122 base, ext = os.path.splitext(module_path)
123 if ext.lower() in (".ts", ".tsx", ".js", ".jsx"):
124 return base
125 return module_path
127 def _resolve_node_with_extension(
128 self, target_path: str, root_dir: str
129 ) -> str | None:
130 if os.path.isfile(target_path):
131 ext = os.path.splitext(target_path)[1]
132 if ext in (".ts", ".tsx", ".js", ".jsx"):
133 return self._check_under_root(target_path, root_dir)
134 return None
136 if os.path.isdir(target_path):
137 for index_file in self._INDEX_FILES:
138 index_path = os.path.join(target_path, index_file)
139 if os.path.isfile(index_path):
140 return self._check_under_root(index_path, root_dir)
142 dirname = os.path.basename(target_path)
143 for ext in (".ts", ".tsx", ".js", ".jsx"):
144 file_path = os.path.join(target_path, dirname + ext)
145 if os.path.isfile(file_path):
146 return self._check_under_root(file_path, root_dir)
148 return None
150 def _resolve_node_without_extension(
151 self, module: str, target_path: str, root_dir: str
152 ) -> str | None:
153 if os.path.isdir(target_path) and not module.endswith("/"):
154 dirname = os.path.basename(target_path)
155 for ext in (".ts", ".tsx"):
156 file_path = os.path.join(target_path, dirname + ext)
157 if os.path.isfile(file_path):
158 return self._check_under_root(file_path, root_dir)
160 for index_file in self._INDEX_FILES:
161 index_path = os.path.join(target_path, index_file)
162 if os.path.isfile(index_path):
163 return self._check_under_root(index_path, root_dir)
165 for ext in (".d.ts", ".ts", ".tsx", ".js", ".jsx"):
166 file_path = target_path + ext
167 if os.path.isfile(file_path):
168 return self._check_under_root(file_path, root_dir)
170 return None
172 def _resolve_node16_with_extension(
173 self, module: str, target_path: str, root_dir: str
174 ) -> str | None:
175 ext = os.path.splitext(module)[1]
177 if os.path.isfile(target_path):
178 target_ext = os.path.splitext(target_path)[1]
179 if target_ext == ext:
180 return self._check_under_root(target_path, root_dir)
182 base = self._strip_extension(target_path)
183 for search_ext in (".ts", ".tsx"):
184 file_path = base + search_ext
185 if os.path.isfile(file_path):
186 return self._check_under_root(file_path, root_dir)
188 if os.path.isdir(target_path):
189 for index_file in self._INDEX_FILES:
190 index_path = os.path.join(target_path, index_file)
191 if os.path.isfile(index_path):
192 return self._check_under_root(index_path, root_dir)
194 return None
196 def _resolve_bundler_with_extension(
197 self, module: str, target_path: str, root_dir: str
198 ) -> str | None:
199 ext = os.path.splitext(module)[1]
201 if os.path.isfile(target_path):
202 target_ext = os.path.splitext(target_path)[1]
203 if target_ext == ext:
204 return self._check_under_root(target_path, root_dir)
206 base = self._strip_extension(target_path)
207 for search_ext in (".ts", ".tsx"):
208 file_path = base + search_ext
209 if os.path.isfile(file_path):
210 return self._check_under_root(file_path, root_dir)
212 if os.path.isdir(target_path):
213 for index_file in self._INDEX_FILES:
214 index_path = os.path.join(target_path, index_file)
215 if os.path.isfile(index_path):
216 return self._check_under_root(index_path, root_dir)
218 return None
220 def _resolve_bundler_without_extension(
221 self, module: str, target_path: str, root_dir: str
222 ) -> str | None:
223 if os.path.isdir(target_path) and not module.endswith("/"):
224 dirname = os.path.basename(target_path)
225 for ext in (".ts", ".tsx", ".js", ".jsx"):
226 file_path = os.path.join(target_path, dirname + ext)
227 if os.path.isfile(file_path):
228 return self._check_under_root(file_path, root_dir)
230 for index_file in self._INDEX_FILES:
231 index_path = target_path + "/" + index_file
232 if os.path.isfile(index_path):
233 return self._check_under_root(index_path, root_dir)
235 for ext in (".ts", ".tsx", ".js", ".jsx"):
236 file_path = target_path + ext
237 if os.path.isfile(file_path):
238 return self._check_under_root(file_path, root_dir)
240 return None
242 def _check_under_root(self, path, root_dir):
243 abs_path = os.path.abspath(path)
244 abs_root = os.path.abspath(root_dir)
245 if abs_path.startswith(abs_root):
246 return abs_path
247 return None