repomap-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- repomap/__init__.py +320 -0
- repomap/ai.py +1108 -0
- repomap/check.py +1212 -0
- repomap/cli/__init__.py +3 -0
- repomap/cli/__main__.py +12 -0
- repomap/cli/cli.py +2475 -0
- repomap/core.py +730 -0
- repomap/lsp.py +753 -0
- repomap/parser.py +1697 -0
- repomap/ranking.py +639 -0
- repomap/resolver.py +906 -0
- repomap/toolkit.py +850 -0
- repomap/topic.py +600 -0
- repomap_cli-1.0.0.dist-info/METADATA +284 -0
- repomap_cli-1.0.0.dist-info/RECORD +18 -0
- repomap_cli-1.0.0.dist-info/WHEEL +4 -0
- repomap_cli-1.0.0.dist-info/entry_points.txt +2 -0
- repomap_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
repomap/resolver.py
ADDED
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Repo Map Resolver — Import and Alias Resolution Layer
|
|
4
|
+
=======================================================
|
|
5
|
+
负责 import 路径解析、alias 映射、re-export 追踪。
|
|
6
|
+
|
|
7
|
+
支持:
|
|
8
|
+
- tsconfig.json / jsconfig.json paths 和 baseUrl
|
|
9
|
+
- package.json exports 字段
|
|
10
|
+
- Vite / Webpack 等 bundler 的 alias 配置
|
|
11
|
+
- CommonJS require/module.exports
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .parser import EXT_TO_LANG
|
|
25
|
+
from . import JSImportBinding, PathAliasRule, ProjectImportConfig, RepoGraph
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("repomap")
|
|
28
|
+
|
|
29
|
+
MAX_EXPORT_RESOLVE_DEPTH = 3
|
|
30
|
+
CALLABLE_KINDS = {"function", "method", "anonymous_function"}
|
|
31
|
+
|
|
32
|
+
# Bundler config file names
|
|
33
|
+
BUNDLER_CONFIGS = {
|
|
34
|
+
"vite": ["vite.config.js", "vite.config.ts", "vite.config.mjs"],
|
|
35
|
+
"webpack": ["webpack.config.js", "webpack.config.ts"],
|
|
36
|
+
"rollup": ["rollup.config.js", "rollup.config.mjs"],
|
|
37
|
+
"esbuild": [], # esbuild usually configured in package.json or build scripts
|
|
38
|
+
"turbopack": [], # Next.js 13+, uses next.config.js
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
SKIP_DIR_NAMES = {
|
|
42
|
+
".cache",
|
|
43
|
+
".git",
|
|
44
|
+
".hg",
|
|
45
|
+
".idea",
|
|
46
|
+
".mypy_cache",
|
|
47
|
+
".next",
|
|
48
|
+
".nox",
|
|
49
|
+
".nuxt",
|
|
50
|
+
".parcel-cache",
|
|
51
|
+
".pnpm-store",
|
|
52
|
+
".pytest_cache",
|
|
53
|
+
".ruff_cache",
|
|
54
|
+
".svelte-kit",
|
|
55
|
+
".tox",
|
|
56
|
+
".turbo",
|
|
57
|
+
".venv",
|
|
58
|
+
".vscode",
|
|
59
|
+
".yarn",
|
|
60
|
+
"__pypackages__",
|
|
61
|
+
"__pycache__",
|
|
62
|
+
"build",
|
|
63
|
+
"coverage",
|
|
64
|
+
"dist",
|
|
65
|
+
"env",
|
|
66
|
+
"ENV",
|
|
67
|
+
"node_modules",
|
|
68
|
+
"site-packages",
|
|
69
|
+
"target",
|
|
70
|
+
"venv",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PackageJsonExports:
|
|
75
|
+
"""解析 package.json exports 字段,支持条件导出和子路径模式。"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, exports: Any) -> None:
|
|
78
|
+
self.raw = exports
|
|
79
|
+
self.mappings: dict[str, str] = {} # subpath -> resolved path
|
|
80
|
+
self._parse_exports(exports)
|
|
81
|
+
|
|
82
|
+
def _parse_exports(self, exports: Any, base_path: str = ".", is_nested_condition: bool = False) -> None:
|
|
83
|
+
"""解析 exports 字段的各种形式。
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
exports: package.json 中的 exports 字段值
|
|
87
|
+
base_path: 当前子路径(用于子路径映射)
|
|
88
|
+
is_nested_condition: 是否处于嵌套条件导出中
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(exports, str):
|
|
91
|
+
# 简写形式: "exports": "./dist/index.js"
|
|
92
|
+
self.mappings[base_path] = exports
|
|
93
|
+
elif isinstance(exports, dict):
|
|
94
|
+
# 检查是否有条件导出 (import/require/default/types 等)
|
|
95
|
+
condition_keys = {"import", "require", "default", "types", "node", "browser", "deno"}
|
|
96
|
+
has_conditions = bool(set(exports.keys()) & condition_keys)
|
|
97
|
+
|
|
98
|
+
if has_conditions:
|
|
99
|
+
# 选择优先的条件: import > default > require > types
|
|
100
|
+
selected_target = None
|
|
101
|
+
for key in ("import", "default", "require", "types"):
|
|
102
|
+
if key in exports:
|
|
103
|
+
selected_target = exports[key]
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
if selected_target is None:
|
|
107
|
+
# 使用第一个非子路径的可用条件
|
|
108
|
+
for key, target in exports.items():
|
|
109
|
+
if not key.startswith("."):
|
|
110
|
+
selected_target = target
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
# 处理选中的目标
|
|
114
|
+
if isinstance(selected_target, str):
|
|
115
|
+
self.mappings[base_path] = selected_target
|
|
116
|
+
elif isinstance(selected_target, dict):
|
|
117
|
+
# 嵌套条件导出,base_path 保持不变
|
|
118
|
+
self._parse_exports(selected_target, base_path, is_nested_condition=True)
|
|
119
|
+
else:
|
|
120
|
+
# 子路径映射形式
|
|
121
|
+
for key, target in exports.items():
|
|
122
|
+
subpath = f"{base_path}/{key}" if base_path != "." else key
|
|
123
|
+
if isinstance(target, str):
|
|
124
|
+
self.mappings[subpath] = target
|
|
125
|
+
elif isinstance(target, dict):
|
|
126
|
+
# 子路径映射中的值可能是条件导出字典
|
|
127
|
+
self._parse_exports(target, subpath, is_nested_condition=False)
|
|
128
|
+
|
|
129
|
+
def resolve(self, import_path: str) -> str | None:
|
|
130
|
+
"""
|
|
131
|
+
解析 import 路径到实际文件路径。
|
|
132
|
+
import_path 应该是包内路径,如 "#utils" 或 "./helpers"
|
|
133
|
+
"""
|
|
134
|
+
# 直接匹配
|
|
135
|
+
if import_path in self.mappings:
|
|
136
|
+
return self.mappings[import_path]
|
|
137
|
+
|
|
138
|
+
# 处理子路径通配符模式,如 "./features/*": "./dist/features/*.js"
|
|
139
|
+
for pattern, target in self.mappings.items():
|
|
140
|
+
if "*" in pattern:
|
|
141
|
+
prefix = pattern.split("*")[0]
|
|
142
|
+
if import_path.startswith(prefix):
|
|
143
|
+
suffix = import_path[len(prefix):]
|
|
144
|
+
if "*" in target:
|
|
145
|
+
return target.replace("*", suffix, 1)
|
|
146
|
+
# 目标没有通配符,追加到目录
|
|
147
|
+
if target.endswith("/"):
|
|
148
|
+
return f"{target}{suffix}"
|
|
149
|
+
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class BundlerAliasConfig:
|
|
154
|
+
"""解析各种 bundler 的 alias 配置。
|
|
155
|
+
|
|
156
|
+
NOTE: 当前实现仅作为占位符,实际 bundler alias 解析依赖于:
|
|
157
|
+
1. tsconfig.json / jsconfig.json 的 paths 配置(由 ImportResolver 主逻辑处理)
|
|
158
|
+
2. 项目特定的构建配置通常应由用户通过其他方式提供
|
|
159
|
+
|
|
160
|
+
正则解析 JS 配置文件极易出错,已移除。如需支持特定 bundler,
|
|
161
|
+
建议通过显式的配置文件映射而非解析代码实现。
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, project_root: Path) -> None:
|
|
165
|
+
self.project_root = project_root
|
|
166
|
+
self.aliases: dict[str, str] = {} # alias -> resolved path
|
|
167
|
+
|
|
168
|
+
def resolve(self, import_path: str) -> str | None:
|
|
169
|
+
"""解析 bundler alias 到实际路径。"""
|
|
170
|
+
for alias, target in self.aliases.items():
|
|
171
|
+
if import_path == alias:
|
|
172
|
+
return target
|
|
173
|
+
if import_path.startswith(f"{alias}/"):
|
|
174
|
+
suffix = import_path[len(alias) + 1:]
|
|
175
|
+
return f"{target}/{suffix}" if not target.endswith("/") else f"{target}{suffix}"
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ImportResolver:
|
|
180
|
+
"""
|
|
181
|
+
Import 解析器:处理各种 import 路径解析场景。
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, project_root: Path, graph: RepoGraph) -> None:
|
|
185
|
+
self.project_root = project_root
|
|
186
|
+
self.graph = graph
|
|
187
|
+
self.import_configs: list[ProjectImportConfig] = []
|
|
188
|
+
self.bundler_aliases = BundlerAliasConfig(project_root)
|
|
189
|
+
self.package_exports: dict[str, PackageJsonExports] = {} # package name -> exports
|
|
190
|
+
self.package_export_roots: dict[str, PurePosixPath] = {} # package name -> project-relative package root
|
|
191
|
+
self._package_imports: dict[str, str] = {} # #pattern -> resolved target
|
|
192
|
+
|
|
193
|
+
# 构建文件索引: stem -> [files]
|
|
194
|
+
self._file_map: dict[str, list[str]] = defaultdict(list)
|
|
195
|
+
# name -> [sym_id]
|
|
196
|
+
self._name_idx: dict[str, list[str]] = defaultdict(list)
|
|
197
|
+
# sym_id -> file
|
|
198
|
+
self._sym_file: dict[str, str] = {}
|
|
199
|
+
|
|
200
|
+
# 导入解析缓存:(source_file, imp) -> target_files,避免同一模块被反复解析
|
|
201
|
+
self._resolve_cache: dict[tuple[str, str], list[str]] = {}
|
|
202
|
+
|
|
203
|
+
self._load_import_configs()
|
|
204
|
+
self._load_package_json_exports()
|
|
205
|
+
self._detect_vite_alias()
|
|
206
|
+
|
|
207
|
+
def _load_import_configs(self) -> None:
|
|
208
|
+
"""加载所有 tsconfig.json / jsconfig.json 配置。"""
|
|
209
|
+
configs: list[ProjectImportConfig] = []
|
|
210
|
+
for config_path in self._discover_import_config_paths():
|
|
211
|
+
data = self._load_jsonc_with_extends(config_path, set())
|
|
212
|
+
compiler_options = data.get("compilerOptions", {}) if isinstance(data, dict) else {}
|
|
213
|
+
base_url = self._resolve_config_relative_path(config_path, compiler_options.get("baseUrl"))
|
|
214
|
+
alias_rules: list[PathAliasRule] = []
|
|
215
|
+
raw_paths = compiler_options.get("paths", {})
|
|
216
|
+
if isinstance(raw_paths, dict):
|
|
217
|
+
for alias_pattern, targets in raw_paths.items():
|
|
218
|
+
if not isinstance(alias_pattern, str) or not isinstance(targets, list):
|
|
219
|
+
continue
|
|
220
|
+
resolved_targets = tuple(
|
|
221
|
+
target
|
|
222
|
+
for raw_target in targets
|
|
223
|
+
if isinstance(raw_target, str)
|
|
224
|
+
for target in [self._resolve_config_relative_path(config_path, raw_target, base_url)]
|
|
225
|
+
if target is not None
|
|
226
|
+
)
|
|
227
|
+
if resolved_targets:
|
|
228
|
+
alias_rules.append(
|
|
229
|
+
PathAliasRule(alias_pattern=alias_pattern, target_patterns=resolved_targets)
|
|
230
|
+
)
|
|
231
|
+
try:
|
|
232
|
+
config_dir = config_path.parent.relative_to(self.project_root).as_posix()
|
|
233
|
+
except ValueError:
|
|
234
|
+
continue
|
|
235
|
+
configs.append(
|
|
236
|
+
ProjectImportConfig(
|
|
237
|
+
config_path=str(config_path.relative_to(self.project_root)),
|
|
238
|
+
config_dir=config_dir or ".",
|
|
239
|
+
base_url=base_url,
|
|
240
|
+
alias_rules=alias_rules,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
configs.sort(
|
|
244
|
+
key=lambda config: (
|
|
245
|
+
-self._path_depth(config.config_dir),
|
|
246
|
+
config.config_path or "",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
self.import_configs = configs
|
|
250
|
+
|
|
251
|
+
def _load_package_json_exports(self) -> None:
|
|
252
|
+
"""扫描 package.json 并解析 exports 字段。"""
|
|
253
|
+
package_json_path = self.project_root / "package.json"
|
|
254
|
+
if package_json_path.exists():
|
|
255
|
+
try:
|
|
256
|
+
data = json.loads(package_json_path.read_text(encoding="utf-8"))
|
|
257
|
+
if isinstance(data, dict) and "exports" in data:
|
|
258
|
+
self._register_package_exports(".", data["exports"], PurePosixPath("."))
|
|
259
|
+
package_name = data.get("name")
|
|
260
|
+
if isinstance(package_name, str) and package_name:
|
|
261
|
+
self._register_package_exports(package_name, data["exports"], PurePosixPath("."))
|
|
262
|
+
# 解析 imports 字段(Node.js # 私有导入)
|
|
263
|
+
if isinstance(data, dict) and "imports" in data:
|
|
264
|
+
self._parse_package_imports(data["imports"])
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.debug(f"Failed to parse package.json: {e}")
|
|
267
|
+
|
|
268
|
+
# 解析子包的 package.json(monorepo 场景),跳过依赖和构建目录,避免读取海量无关 package。
|
|
269
|
+
for root, dir_names, file_names in os.walk(self.project_root):
|
|
270
|
+
dir_names[:] = [name for name in dir_names if name not in SKIP_DIR_NAMES]
|
|
271
|
+
if "package.json" not in file_names:
|
|
272
|
+
continue
|
|
273
|
+
sub_package_path = Path(root) / "package.json"
|
|
274
|
+
if sub_package_path == package_json_path:
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
sub_data = json.loads(sub_package_path.read_text(encoding="utf-8"))
|
|
278
|
+
if not isinstance(sub_data, dict) or "exports" not in sub_data:
|
|
279
|
+
continue
|
|
280
|
+
rel_path = sub_package_path.parent.relative_to(self.project_root)
|
|
281
|
+
package_root = PurePosixPath(rel_path.as_posix())
|
|
282
|
+
path_package_name = f"./{rel_path.as_posix()}"
|
|
283
|
+
self._register_package_exports(path_package_name, sub_data["exports"], package_root)
|
|
284
|
+
package_name = sub_data.get("name")
|
|
285
|
+
if isinstance(package_name, str) and package_name:
|
|
286
|
+
self._register_package_exports(package_name, sub_data["exports"], package_root)
|
|
287
|
+
except ValueError:
|
|
288
|
+
continue
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.debug(f"Failed to parse {sub_package_path}: {e}")
|
|
291
|
+
|
|
292
|
+
def _detect_vite_alias(self) -> None:
|
|
293
|
+
"""检测 Vite 默认 alias: ~/ → src/。"""
|
|
294
|
+
for cfg_name in ("vite.config.ts", "vite.config.js", "vite.config.mjs"):
|
|
295
|
+
if (self.project_root / cfg_name).exists():
|
|
296
|
+
if "~" not in self.bundler_aliases.aliases:
|
|
297
|
+
self.bundler_aliases.aliases["~"] = "src"
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
def _parse_package_imports(self, imports: Any) -> None:
|
|
301
|
+
"""解析 package.json imports 字段(# 私有导入映射)。"""
|
|
302
|
+
if not isinstance(imports, dict):
|
|
303
|
+
return
|
|
304
|
+
for pattern, target in imports.items():
|
|
305
|
+
resolved = None
|
|
306
|
+
if isinstance(target, str):
|
|
307
|
+
resolved = target
|
|
308
|
+
elif isinstance(target, dict):
|
|
309
|
+
for key in ("import", "default", "require", "node", "browser"):
|
|
310
|
+
if key in target and isinstance(target[key], str):
|
|
311
|
+
resolved = target[key]
|
|
312
|
+
break
|
|
313
|
+
if resolved and isinstance(pattern, str):
|
|
314
|
+
self._package_imports[pattern] = resolved
|
|
315
|
+
|
|
316
|
+
def _register_package_exports(self, package_name: str, exports: Any, package_root: PurePosixPath) -> None:
|
|
317
|
+
self.package_exports[package_name] = PackageJsonExports(exports)
|
|
318
|
+
self.package_export_roots[package_name] = package_root
|
|
319
|
+
|
|
320
|
+
def build_indices(self) -> None:
|
|
321
|
+
"""构建文件和符号索引,用于快速解析。"""
|
|
322
|
+
self._file_map.clear()
|
|
323
|
+
self._name_idx.clear()
|
|
324
|
+
self._sym_file.clear()
|
|
325
|
+
|
|
326
|
+
for file in sorted(self.graph.file_symbols):
|
|
327
|
+
self._file_map[Path(file).stem].append(file)
|
|
328
|
+
|
|
329
|
+
for sid, sym in self.graph.symbols.items():
|
|
330
|
+
self._name_idx[sym.name].append(sid)
|
|
331
|
+
self._sym_file[sid] = sym.file
|
|
332
|
+
|
|
333
|
+
for symbol_ids in self._name_idx.values():
|
|
334
|
+
symbol_ids.sort(key=lambda symbol_id: (
|
|
335
|
+
self._sym_file[symbol_id],
|
|
336
|
+
self.graph.symbols[symbol_id].line,
|
|
337
|
+
self.graph.symbols[symbol_id].name,
|
|
338
|
+
))
|
|
339
|
+
|
|
340
|
+
def resolve_import_targets(self, source_file: str, imp: str) -> list[str]:
|
|
341
|
+
"""解析 import 路径到目标文件列表。"""
|
|
342
|
+
# 非相对路径走缓存,避免同一模块被不同文件重复解析
|
|
343
|
+
cache_key = (source_file, imp)
|
|
344
|
+
if cached := self._resolve_cache.get(cache_key):
|
|
345
|
+
return cached
|
|
346
|
+
|
|
347
|
+
result: list[str]
|
|
348
|
+
# 处理 Node.js # 私有导入
|
|
349
|
+
if imp.startswith("#") and self._package_imports:
|
|
350
|
+
target = self._package_imports.get(imp)
|
|
351
|
+
if target:
|
|
352
|
+
result = self._resolve_package_export_target(".", target)
|
|
353
|
+
if result:
|
|
354
|
+
self._resolve_cache[cache_key] = result
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
if imp.startswith("."):
|
|
358
|
+
result = self._resolve_relative(source_file, imp)
|
|
359
|
+
else:
|
|
360
|
+
# 尝试 Java 点号导入 (com.example.Foo → com/example/Foo.java)
|
|
361
|
+
source_ext = Path(source_file).suffix.lower()
|
|
362
|
+
if source_ext == ".java" and "." in imp and not imp.startswith("."):
|
|
363
|
+
java_modules = [part for part in imp.split(".") if part]
|
|
364
|
+
if java_modules:
|
|
365
|
+
java_path = PurePosixPath(*java_modules)
|
|
366
|
+
java_matches = self._candidate_files_for_base_path(java_path)
|
|
367
|
+
if java_matches:
|
|
368
|
+
self._resolve_cache[cache_key] = java_matches
|
|
369
|
+
return java_matches
|
|
370
|
+
|
|
371
|
+
# 尝试 bundler alias 解析
|
|
372
|
+
bundler_match = self.bundler_aliases.resolve(imp)
|
|
373
|
+
if bundler_match:
|
|
374
|
+
result = self._resolve_relative(source_file, bundler_match)
|
|
375
|
+
else:
|
|
376
|
+
# 尝试 tsconfig/jsconfig alias/baseUrl 解析
|
|
377
|
+
alias_matches = self._resolve_alias_or_baseurl_targets(source_file, imp)
|
|
378
|
+
if alias_matches:
|
|
379
|
+
result = alias_matches
|
|
380
|
+
else:
|
|
381
|
+
# 尝试 package.json exports 解析(用于自引用或子包)
|
|
382
|
+
pkg_result = None
|
|
383
|
+
for package_name, exports in self.package_exports.items():
|
|
384
|
+
export_subpath = self._package_export_subpath(package_name, imp)
|
|
385
|
+
if export_subpath is None:
|
|
386
|
+
continue
|
|
387
|
+
resolved = exports.resolve(export_subpath)
|
|
388
|
+
if resolved:
|
|
389
|
+
pkg_result = self._resolve_package_export_target(package_name, resolved)
|
|
390
|
+
break
|
|
391
|
+
if pkg_result is not None:
|
|
392
|
+
result = pkg_result
|
|
393
|
+
else:
|
|
394
|
+
python_matches = self._resolve_python_dotted_import(source_file, imp)
|
|
395
|
+
if python_matches:
|
|
396
|
+
result = python_matches
|
|
397
|
+
else:
|
|
398
|
+
# 最后尝试模块名匹配(优先同语言)
|
|
399
|
+
module_key = Path(imp).stem or imp.split(".")[-1]
|
|
400
|
+
matches = list(self._file_map.get(module_key, []))
|
|
401
|
+
if not matches:
|
|
402
|
+
result = []
|
|
403
|
+
else:
|
|
404
|
+
# 优先匹配同扩展名的文件(语言隔离)
|
|
405
|
+
source_ext = Path(source_file).suffix.lower()
|
|
406
|
+
same_lang_matches = [f for f in matches if f.lower().endswith(source_ext)]
|
|
407
|
+
if same_lang_matches:
|
|
408
|
+
result = same_lang_matches
|
|
409
|
+
else:
|
|
410
|
+
# 次优:匹配相同语言组的文件
|
|
411
|
+
source_lang = EXT_TO_LANG.get(source_ext)
|
|
412
|
+
if source_lang:
|
|
413
|
+
same_group_matches = [f for f in matches if EXT_TO_LANG.get(Path(f).suffix.lower()) == source_lang]
|
|
414
|
+
if same_group_matches:
|
|
415
|
+
result = same_group_matches
|
|
416
|
+
else:
|
|
417
|
+
# 兜底:返回所有匹配(但限制数量避免爆炸)
|
|
418
|
+
result = matches[:3]
|
|
419
|
+
else:
|
|
420
|
+
result = matches[:3]
|
|
421
|
+
|
|
422
|
+
# 缓存非相对路径的解析结果(相对路径取决于源文件位置,不宜缓存)
|
|
423
|
+
if not imp.startswith("."):
|
|
424
|
+
self._resolve_cache[cache_key] = result
|
|
425
|
+
return result
|
|
426
|
+
|
|
427
|
+
def _package_export_subpath(self, package_name: str, imp: str) -> str | None:
|
|
428
|
+
if package_name == ".":
|
|
429
|
+
return imp if imp.startswith("#") else None
|
|
430
|
+
if imp == package_name:
|
|
431
|
+
return "."
|
|
432
|
+
prefix = package_name + "/"
|
|
433
|
+
if imp.startswith(prefix):
|
|
434
|
+
return "./" + imp[len(prefix):]
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
def _resolve_package_export_target(self, package_name: str, target: str) -> list[str]:
|
|
438
|
+
package_root = self.package_export_roots.get(package_name, PurePosixPath("."))
|
|
439
|
+
target_path = PurePosixPath(target)
|
|
440
|
+
if target_path.is_absolute():
|
|
441
|
+
return []
|
|
442
|
+
normalized = self._normalize_posix_path(package_root / target_path)
|
|
443
|
+
if normalized is None:
|
|
444
|
+
return []
|
|
445
|
+
return self._candidate_files_for_base_path(normalized)
|
|
446
|
+
|
|
447
|
+
def _resolve_relative(self, source_file: str, imp: str) -> list[str]:
|
|
448
|
+
resolved = self._resolve_relative_base(source_file, imp)
|
|
449
|
+
if resolved is None:
|
|
450
|
+
return []
|
|
451
|
+
return self._candidate_files_for_base_path(resolved)
|
|
452
|
+
|
|
453
|
+
def _candidate_files_for_base_path(self, resolved: PurePosixPath) -> list[str]:
|
|
454
|
+
matches = []
|
|
455
|
+
resolved_str = str(resolved)
|
|
456
|
+
if resolved_str in self.graph.file_symbols:
|
|
457
|
+
matches.append(resolved_str)
|
|
458
|
+
if resolved.suffix.lower() in EXT_TO_LANG:
|
|
459
|
+
return sorted(set(matches))
|
|
460
|
+
for ext in EXT_TO_LANG:
|
|
461
|
+
direct = resolved_str + ext
|
|
462
|
+
index_file = str(resolved / f"index{ext}")
|
|
463
|
+
if direct in self.graph.file_symbols:
|
|
464
|
+
matches.append(direct)
|
|
465
|
+
if index_file in self.graph.file_symbols:
|
|
466
|
+
matches.append(index_file)
|
|
467
|
+
init_file = str(resolved / "__init__.py")
|
|
468
|
+
if init_file in self.graph.file_symbols:
|
|
469
|
+
matches.append(init_file)
|
|
470
|
+
# Python namespace package: 无 __init__.py 时也尝试匹配目录下 .py 文件
|
|
471
|
+
elif resolved_str not in self.graph.file_symbols:
|
|
472
|
+
ns_prefix = resolved_str + "/"
|
|
473
|
+
ns_matches = [
|
|
474
|
+
f for f in self.graph.file_symbols
|
|
475
|
+
if f.startswith(ns_prefix) and f.endswith(".py")
|
|
476
|
+
]
|
|
477
|
+
matches.extend(ns_matches[:5])
|
|
478
|
+
return sorted(set(matches))
|
|
479
|
+
|
|
480
|
+
def _resolve_python_dotted_import(self, source_file: str, imp: str) -> list[str]:
|
|
481
|
+
if EXT_TO_LANG.get(Path(source_file).suffix.lower()) != "python" or "." not in imp:
|
|
482
|
+
return []
|
|
483
|
+
module_path = PurePosixPath(*[part for part in imp.split(".") if part])
|
|
484
|
+
if str(module_path) in ("", "."):
|
|
485
|
+
return []
|
|
486
|
+
return self._candidate_files_for_base_path(module_path)
|
|
487
|
+
|
|
488
|
+
def _resolve_alias_or_baseurl_targets(self, source_file: str, imp: str) -> list[str]:
|
|
489
|
+
for config in self._candidate_import_configs_for_file(source_file):
|
|
490
|
+
alias_matches: list[str] = []
|
|
491
|
+
for rule in config.alias_rules:
|
|
492
|
+
wildcard_value = self._match_alias_pattern(rule.alias_pattern, imp)
|
|
493
|
+
if wildcard_value is None:
|
|
494
|
+
continue
|
|
495
|
+
for target_pattern in rule.target_patterns:
|
|
496
|
+
target_base = self._apply_alias_target(target_pattern, wildcard_value)
|
|
497
|
+
if target_base is None:
|
|
498
|
+
continue
|
|
499
|
+
alias_matches.extend(self._candidate_files_for_base_path(PurePosixPath(target_base)))
|
|
500
|
+
if alias_matches:
|
|
501
|
+
return sorted(set(alias_matches))
|
|
502
|
+
if config.base_url:
|
|
503
|
+
base_path = self._normalize_posix_path(PurePosixPath(config.base_url, imp))
|
|
504
|
+
base_matches = self._candidate_files_for_base_path(base_path)
|
|
505
|
+
if base_matches:
|
|
506
|
+
return base_matches
|
|
507
|
+
return []
|
|
508
|
+
|
|
509
|
+
def _candidate_import_configs_for_file(self, source_file: str) -> list[ProjectImportConfig]:
|
|
510
|
+
source_parent = self._normalize_posix_path(PurePosixPath(source_file).parent)
|
|
511
|
+
if source_parent is None:
|
|
512
|
+
return []
|
|
513
|
+
ranked: list[tuple[int, ProjectImportConfig]] = []
|
|
514
|
+
for config in self.import_configs:
|
|
515
|
+
config_dir = self._normalize_posix_path(PurePosixPath(config.config_dir or "."))
|
|
516
|
+
if config_dir is not None and self._is_subpath(source_parent, config_dir):
|
|
517
|
+
ranked.append((self._path_depth(config.config_dir), config))
|
|
518
|
+
ranked.sort(key=lambda item: (-item[0], item[1].config_path or ""))
|
|
519
|
+
return [config for _, config in ranked]
|
|
520
|
+
|
|
521
|
+
@staticmethod
|
|
522
|
+
def _match_alias_pattern(alias_pattern: str, import_path: str) -> str | None:
|
|
523
|
+
if "*" not in alias_pattern:
|
|
524
|
+
return "" if alias_pattern == import_path else None
|
|
525
|
+
prefix, suffix = alias_pattern.split("*", 1)
|
|
526
|
+
if not import_path.startswith(prefix) or not import_path.endswith(suffix):
|
|
527
|
+
return None
|
|
528
|
+
return import_path[len(prefix):len(import_path) - len(suffix) if suffix else None]
|
|
529
|
+
|
|
530
|
+
@staticmethod
|
|
531
|
+
def _apply_alias_target(target_pattern: str, wildcard_value: str) -> str | None:
|
|
532
|
+
if "*" in target_pattern:
|
|
533
|
+
return target_pattern.replace("*", wildcard_value, 1)
|
|
534
|
+
if wildcard_value:
|
|
535
|
+
return None
|
|
536
|
+
return target_pattern
|
|
537
|
+
|
|
538
|
+
def _resolve_relative_base(self, source_file: str, imp: str) -> PurePosixPath | None:
|
|
539
|
+
source_parent = PurePosixPath(source_file).parent
|
|
540
|
+
if "/" in imp:
|
|
541
|
+
return self._normalize_posix_path(PurePosixPath(source_parent, imp))
|
|
542
|
+
|
|
543
|
+
leading_dots = len(imp) - len(imp.lstrip("."))
|
|
544
|
+
remainder = imp.lstrip(".").replace(".", "/")
|
|
545
|
+
base = source_parent
|
|
546
|
+
for _ in range(max(leading_dots - 1, 0)):
|
|
547
|
+
base = base.parent
|
|
548
|
+
if remainder:
|
|
549
|
+
base = PurePosixPath(base, remainder)
|
|
550
|
+
return self._normalize_posix_path(base)
|
|
551
|
+
|
|
552
|
+
@staticmethod
|
|
553
|
+
def _normalize_posix_path(path: PurePosixPath) -> PurePosixPath | None:
|
|
554
|
+
normalized_parts: list[str] = []
|
|
555
|
+
for part in path.parts:
|
|
556
|
+
if part in ("", "."):
|
|
557
|
+
continue
|
|
558
|
+
if part == "..":
|
|
559
|
+
if not normalized_parts:
|
|
560
|
+
return None
|
|
561
|
+
normalized_parts.pop()
|
|
562
|
+
continue
|
|
563
|
+
normalized_parts.append(part)
|
|
564
|
+
if not normalized_parts:
|
|
565
|
+
return PurePosixPath(".")
|
|
566
|
+
return PurePosixPath(*normalized_parts)
|
|
567
|
+
|
|
568
|
+
@staticmethod
|
|
569
|
+
def _path_depth(path_value: str | None) -> int:
|
|
570
|
+
if not path_value or path_value == ".":
|
|
571
|
+
return 0
|
|
572
|
+
return len([part for part in PurePosixPath(path_value).parts if part not in ("", ".")])
|
|
573
|
+
|
|
574
|
+
@staticmethod
|
|
575
|
+
def _is_subpath(path: PurePosixPath, maybe_parent: PurePosixPath) -> bool:
|
|
576
|
+
parent_parts = tuple(part for part in maybe_parent.parts if part not in ("", "."))
|
|
577
|
+
path_parts = tuple(part for part in path.parts if part not in ("", "."))
|
|
578
|
+
if not parent_parts:
|
|
579
|
+
return True
|
|
580
|
+
if len(parent_parts) > len(path_parts):
|
|
581
|
+
return False
|
|
582
|
+
return path_parts[: len(parent_parts)] == parent_parts
|
|
583
|
+
|
|
584
|
+
def resolve_calling_symbol(self, file: str, call_line: int) -> str | None:
|
|
585
|
+
"""确定指定行所在的符号(调用者)。"""
|
|
586
|
+
symbol_ids = self.graph.file_symbols.get(file, [])
|
|
587
|
+
containing = [
|
|
588
|
+
self.graph.symbols[symbol_id]
|
|
589
|
+
for symbol_id in symbol_ids
|
|
590
|
+
if symbol_id in self.graph.symbols
|
|
591
|
+
and self.graph.symbols[symbol_id].line <= call_line <= max(
|
|
592
|
+
self.graph.symbols[symbol_id].end_line,
|
|
593
|
+
self.graph.symbols[symbol_id].line,
|
|
594
|
+
)
|
|
595
|
+
]
|
|
596
|
+
if not containing:
|
|
597
|
+
return None
|
|
598
|
+
containing.sort(
|
|
599
|
+
key=lambda symbol: (
|
|
600
|
+
max(symbol.end_line, symbol.line) - symbol.line,
|
|
601
|
+
-symbol.line,
|
|
602
|
+
symbol.col,
|
|
603
|
+
symbol.name,
|
|
604
|
+
)
|
|
605
|
+
)
|
|
606
|
+
return containing[0].id
|
|
607
|
+
|
|
608
|
+
def resolve_import_binding_targets(
|
|
609
|
+
self,
|
|
610
|
+
file: str,
|
|
611
|
+
binding: JSImportBinding,
|
|
612
|
+
) -> set[str]:
|
|
613
|
+
"""解析 import binding 到目标符号。"""
|
|
614
|
+
if binding.imported_name == "*":
|
|
615
|
+
return set()
|
|
616
|
+
target_files = self.resolve_import_targets(file, binding.module)
|
|
617
|
+
if not target_files:
|
|
618
|
+
return set()
|
|
619
|
+
|
|
620
|
+
resolved_ids: set[str] = set()
|
|
621
|
+
for target_file in target_files:
|
|
622
|
+
resolved_ids.update(
|
|
623
|
+
self._resolve_exported_symbols(
|
|
624
|
+
file=target_file,
|
|
625
|
+
export_name=binding.imported_name,
|
|
626
|
+
depth=0,
|
|
627
|
+
visited=set(),
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
if resolved_ids:
|
|
632
|
+
return resolved_ids
|
|
633
|
+
|
|
634
|
+
if binding.imported_name != "default":
|
|
635
|
+
direct_candidates = [
|
|
636
|
+
symbol_id
|
|
637
|
+
for symbol_id in self._name_idx.get(binding.imported_name, [])
|
|
638
|
+
if self._sym_file[symbol_id] in target_files
|
|
639
|
+
]
|
|
640
|
+
if direct_candidates:
|
|
641
|
+
return set(direct_candidates)
|
|
642
|
+
return set()
|
|
643
|
+
|
|
644
|
+
def _resolve_exported_symbols(
|
|
645
|
+
self,
|
|
646
|
+
file: str,
|
|
647
|
+
export_name: str,
|
|
648
|
+
depth: int,
|
|
649
|
+
visited: set[tuple[str, str]],
|
|
650
|
+
) -> set[str]:
|
|
651
|
+
"""递归解析 re-export,追踪到实际定义的符号。"""
|
|
652
|
+
if depth >= MAX_EXPORT_RESOLVE_DEPTH:
|
|
653
|
+
return set()
|
|
654
|
+
visit_key = (file, export_name)
|
|
655
|
+
if visit_key in visited:
|
|
656
|
+
return set()
|
|
657
|
+
next_visited = set(visited)
|
|
658
|
+
next_visited.add(visit_key)
|
|
659
|
+
|
|
660
|
+
bindings = self.graph.file_exports.get(file, [])
|
|
661
|
+
resolved_ids: set[str] = set()
|
|
662
|
+
|
|
663
|
+
for binding in bindings:
|
|
664
|
+
if binding.kind == "wildcard" and binding.module:
|
|
665
|
+
target_files = self.resolve_import_targets(file, binding.module)
|
|
666
|
+
for target_file in target_files:
|
|
667
|
+
resolved_ids.update(
|
|
668
|
+
self._resolve_exported_symbols(
|
|
669
|
+
file=target_file,
|
|
670
|
+
export_name=export_name,
|
|
671
|
+
depth=depth + 1,
|
|
672
|
+
visited=next_visited,
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
continue
|
|
676
|
+
|
|
677
|
+
if binding.exported_name != export_name:
|
|
678
|
+
continue
|
|
679
|
+
|
|
680
|
+
if binding.module is None and binding.source_name:
|
|
681
|
+
resolved_ids.update(
|
|
682
|
+
symbol_id
|
|
683
|
+
for symbol_id in self._name_idx.get(binding.source_name, [])
|
|
684
|
+
if self._sym_file[symbol_id] == file
|
|
685
|
+
)
|
|
686
|
+
continue
|
|
687
|
+
|
|
688
|
+
if binding.module and binding.source_name:
|
|
689
|
+
target_files = self.resolve_import_targets(file, binding.module)
|
|
690
|
+
for target_file in target_files:
|
|
691
|
+
resolved_ids.update(
|
|
692
|
+
self._resolve_exported_symbols(
|
|
693
|
+
file=target_file,
|
|
694
|
+
export_name=binding.source_name,
|
|
695
|
+
depth=depth + 1,
|
|
696
|
+
visited=next_visited,
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
if resolved_ids:
|
|
701
|
+
return resolved_ids
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
symbol_id
|
|
705
|
+
for symbol_id in self._name_idx.get(export_name, [])
|
|
706
|
+
if self._sym_file[symbol_id] == file and self.graph.symbols[symbol_id].visibility == "exported"
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
def resolve_call_target(
|
|
710
|
+
self,
|
|
711
|
+
file: str,
|
|
712
|
+
call_name: str,
|
|
713
|
+
call_line: int,
|
|
714
|
+
call_kind: str,
|
|
715
|
+
import_targets_by_file: dict[str, set[str]],
|
|
716
|
+
import_symbol_targets_by_file: dict[str, dict[str, set[str]]],
|
|
717
|
+
) -> str | None:
|
|
718
|
+
"""解析函数调用到目标符号。"""
|
|
719
|
+
candidates = [
|
|
720
|
+
symbol_id
|
|
721
|
+
for symbol_id in self._name_idx.get(call_name, [])
|
|
722
|
+
if self.graph.symbols[symbol_id].kind in CALLABLE_KINDS
|
|
723
|
+
]
|
|
724
|
+
if not candidates:
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
imported_targets = [
|
|
728
|
+
symbol_id
|
|
729
|
+
for symbol_id in import_symbol_targets_by_file.get(file, {}).get(call_name, set())
|
|
730
|
+
if self.graph.symbols[symbol_id].kind in CALLABLE_KINDS
|
|
731
|
+
]
|
|
732
|
+
if imported_targets:
|
|
733
|
+
if len(imported_targets) == 1:
|
|
734
|
+
return imported_targets[0]
|
|
735
|
+
return self._pick_best_target(imported_targets, file, call_line)
|
|
736
|
+
|
|
737
|
+
imported_files = import_targets_by_file.get(file, set())
|
|
738
|
+
imported = [symbol_id for symbol_id in candidates if self._sym_file[symbol_id] in imported_files]
|
|
739
|
+
if imported:
|
|
740
|
+
return self._pick_best_target(imported, file, call_line)
|
|
741
|
+
|
|
742
|
+
if call_kind == "member":
|
|
743
|
+
# 成员调用 (obj.method()):仅在同文件匹配 method 类型的符号,避免误绑到全局函数
|
|
744
|
+
same_file_methods = [
|
|
745
|
+
sid for sid in candidates
|
|
746
|
+
if self._sym_file[sid] == file
|
|
747
|
+
and self.graph.symbols[sid].kind == "method"
|
|
748
|
+
]
|
|
749
|
+
if same_file_methods:
|
|
750
|
+
return self._pick_best_target(same_file_methods, file, call_line)
|
|
751
|
+
return None
|
|
752
|
+
|
|
753
|
+
if any(binding.local_name == call_name for binding in self.graph.file_import_bindings.get(file, [])):
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
same_file = [symbol_id for symbol_id in candidates if self._sym_file[symbol_id] == file]
|
|
757
|
+
if same_file:
|
|
758
|
+
return self._pick_best_target(same_file, file, call_line)
|
|
759
|
+
|
|
760
|
+
if len(candidates) == 1:
|
|
761
|
+
return candidates[0]
|
|
762
|
+
|
|
763
|
+
exported = [
|
|
764
|
+
symbol_id
|
|
765
|
+
for symbol_id in candidates
|
|
766
|
+
if self.graph.symbols[symbol_id].visibility == "exported"
|
|
767
|
+
]
|
|
768
|
+
if len(exported) == 1:
|
|
769
|
+
return exported[0]
|
|
770
|
+
return None
|
|
771
|
+
|
|
772
|
+
def _pick_best_target(self, candidates: list[str], file: str, call_line: int) -> str:
|
|
773
|
+
ordered = sorted(
|
|
774
|
+
candidates,
|
|
775
|
+
key=lambda symbol_id: (
|
|
776
|
+
0 if self.graph.symbols[symbol_id].file == file else 1,
|
|
777
|
+
abs(self.graph.symbols[symbol_id].line - call_line),
|
|
778
|
+
0 if self.graph.symbols[symbol_id].visibility == "exported" else 1,
|
|
779
|
+
self.graph.symbols[symbol_id].file,
|
|
780
|
+
self.graph.symbols[symbol_id].line,
|
|
781
|
+
self.graph.symbols[symbol_id].name,
|
|
782
|
+
),
|
|
783
|
+
)
|
|
784
|
+
return ordered[0]
|
|
785
|
+
|
|
786
|
+
def _discover_import_config_paths(self) -> list[Path]:
|
|
787
|
+
found: list[Path] = []
|
|
788
|
+
for root, dir_names, file_names in os.walk(self.project_root):
|
|
789
|
+
dir_names[:] = [name for name in dir_names if name not in SKIP_DIR_NAMES]
|
|
790
|
+
for filename in ("tsconfig.json", "jsconfig.json"):
|
|
791
|
+
if filename in file_names:
|
|
792
|
+
found.append(Path(root) / filename)
|
|
793
|
+
return sorted(found)
|
|
794
|
+
|
|
795
|
+
def _load_jsonc_with_extends(self, config_path: Path, visited: set[Path]) -> dict[str, Any]:
|
|
796
|
+
if config_path in visited or not config_path.exists():
|
|
797
|
+
return {}
|
|
798
|
+
next_visited = set(visited)
|
|
799
|
+
next_visited.add(config_path)
|
|
800
|
+
try:
|
|
801
|
+
raw_data = json.loads(self._strip_jsonc(config_path.read_text(encoding="utf-8")))
|
|
802
|
+
except (OSError, json.JSONDecodeError):
|
|
803
|
+
return {}
|
|
804
|
+
if not isinstance(raw_data, dict):
|
|
805
|
+
return {}
|
|
806
|
+
|
|
807
|
+
extends_value = raw_data.get("extends")
|
|
808
|
+
if not isinstance(extends_value, str):
|
|
809
|
+
return raw_data
|
|
810
|
+
base_config_path = self._resolve_extends_path(config_path, extends_value)
|
|
811
|
+
if base_config_path is None:
|
|
812
|
+
return raw_data
|
|
813
|
+
base_data = self._load_jsonc_with_extends(base_config_path, next_visited)
|
|
814
|
+
return self._merge_dicts(base_data, raw_data)
|
|
815
|
+
|
|
816
|
+
def _resolve_extends_path(self, config_path: Path, extends_value: str) -> Path | None:
|
|
817
|
+
candidate = Path(extends_value)
|
|
818
|
+
if not candidate.suffix:
|
|
819
|
+
candidate = candidate.with_suffix(".json")
|
|
820
|
+
if candidate.is_absolute():
|
|
821
|
+
resolved = candidate
|
|
822
|
+
else:
|
|
823
|
+
if not extends_value.startswith("."):
|
|
824
|
+
return None
|
|
825
|
+
resolved = (config_path.parent / candidate).resolve()
|
|
826
|
+
try:
|
|
827
|
+
resolved.relative_to(self.project_root)
|
|
828
|
+
except ValueError:
|
|
829
|
+
return None
|
|
830
|
+
return resolved
|
|
831
|
+
|
|
832
|
+
def _resolve_config_relative_path(self, config_path: Path, value: Any, base_url: str | None = None) -> str | None:
|
|
833
|
+
if not isinstance(value, str) or not value.strip():
|
|
834
|
+
return None
|
|
835
|
+
value_path = Path(value)
|
|
836
|
+
if base_url and not value.startswith(".") and not value_path.is_absolute():
|
|
837
|
+
resolved = (self.project_root / base_url / value).resolve()
|
|
838
|
+
else:
|
|
839
|
+
resolved = (config_path.parent / value).resolve()
|
|
840
|
+
try:
|
|
841
|
+
return resolved.relative_to(self.project_root).as_posix()
|
|
842
|
+
except ValueError:
|
|
843
|
+
return None
|
|
844
|
+
|
|
845
|
+
@staticmethod
|
|
846
|
+
def _merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
847
|
+
merged = dict(base)
|
|
848
|
+
for key, value in override.items():
|
|
849
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
850
|
+
merged[key] = ImportResolver._merge_dicts(merged[key], value)
|
|
851
|
+
else:
|
|
852
|
+
merged[key] = value
|
|
853
|
+
return merged
|
|
854
|
+
|
|
855
|
+
@staticmethod
|
|
856
|
+
def _strip_jsonc(text: str) -> str:
|
|
857
|
+
result: list[str] = []
|
|
858
|
+
in_string = False
|
|
859
|
+
string_delimiter = ""
|
|
860
|
+
escape = False
|
|
861
|
+
in_line_comment = False
|
|
862
|
+
in_block_comment = False
|
|
863
|
+
i = 0
|
|
864
|
+
while i < len(text):
|
|
865
|
+
char = text[i]
|
|
866
|
+
next_char = text[i + 1] if i + 1 < len(text) else ""
|
|
867
|
+
if in_line_comment:
|
|
868
|
+
if char == "\n":
|
|
869
|
+
in_line_comment = False
|
|
870
|
+
result.append(char)
|
|
871
|
+
i += 1
|
|
872
|
+
continue
|
|
873
|
+
if in_block_comment:
|
|
874
|
+
if char == "*" and next_char == "/":
|
|
875
|
+
in_block_comment = False
|
|
876
|
+
i += 2
|
|
877
|
+
continue
|
|
878
|
+
i += 1
|
|
879
|
+
continue
|
|
880
|
+
if in_string:
|
|
881
|
+
result.append(char)
|
|
882
|
+
if escape:
|
|
883
|
+
escape = False
|
|
884
|
+
elif char == "\\":
|
|
885
|
+
escape = True
|
|
886
|
+
elif char == string_delimiter:
|
|
887
|
+
in_string = False
|
|
888
|
+
i += 1
|
|
889
|
+
continue
|
|
890
|
+
if char == "/" and next_char == "/":
|
|
891
|
+
in_line_comment = True
|
|
892
|
+
i += 2
|
|
893
|
+
continue
|
|
894
|
+
if char == "/" and next_char == "*":
|
|
895
|
+
in_block_comment = True
|
|
896
|
+
i += 2
|
|
897
|
+
continue
|
|
898
|
+
if char in {'"', "'"}:
|
|
899
|
+
in_string = True
|
|
900
|
+
string_delimiter = char
|
|
901
|
+
result.append(char)
|
|
902
|
+
i += 1
|
|
903
|
+
continue
|
|
904
|
+
result.append(char)
|
|
905
|
+
i += 1
|
|
906
|
+
return re.sub(r",(\s*[}\]])", r"\1", "".join(result))
|