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

1from __future__ import annotations 

2 

3import os 

4 

5from circular_deps.resolvers.base import PathResolver 

6from circular_deps.resolvers.tsconfig import TsconfigParser 

7 

8 

9class TypeScriptPathResolver(PathResolver): 

10 _INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx"] 

11 

12 def __init__(self, module_resolution=None, filepath=None, root_dir=None): 

13 """Initialize TypeScript path resolver with module resolution mode. 

14 

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" 

20 

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() 

37 

38 def _normalize_module_resolution(self, mode: str) -> str: 

39 if mode in ("nodenext", "node16"): 

40 return "node16" 

41 return mode 

42 

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" 

47 

48 config = TsconfigParser.parse_tsconfig(tsconfig_path) 

49 if not config: 

50 return "node16" 

51 

52 mode = TsconfigParser.get_module_resolution(config) 

53 return mode or "node16" 

54 

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" 

59 

60 def resolve(self, module, current_file, root_dir): 

61 if "node_modules" in module: 

62 return None 

63 

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) 

68 

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

72 

73 return self._resolve_module_path(module, target_path, root_dir) 

74 

75 def _resolve_absolute(self, module, current_file, root_dir): 

76 module_path = module.replace("/", os.sep) 

77 

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 

83 

84 target_path = os.path.join(root_dir, module_path) 

85 return self._resolve_module_path(module, target_path, root_dir) 

86 

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) 

91 

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 ) 

115 

116 return None 

117 

118 def _has_extension(self, module_path: str) -> bool: 

119 return os.path.splitext(module_path)[1] != "" 

120 

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 

126 

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 

135 

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) 

141 

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) 

147 

148 return None 

149 

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) 

159 

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) 

164 

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) 

169 

170 return None 

171 

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] 

176 

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) 

181 

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) 

187 

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) 

193 

194 return None 

195 

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] 

200 

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) 

205 

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) 

211 

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) 

217 

218 return None 

219 

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) 

229 

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) 

234 

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) 

239 

240 return None 

241 

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