codegraph-nav 0.1.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.
- codegraph_nav/__init__.py +194 -0
- codegraph_nav/ast_grep_analyzer.py +448 -0
- codegraph_nav/cli.py +223 -0
- codegraph_nav/code_navigator.py +1328 -0
- codegraph_nav/code_search.py +1009 -0
- codegraph_nav/colors.py +209 -0
- codegraph_nav/completions.py +354 -0
- codegraph_nav/dart_analyzer.py +301 -0
- codegraph_nav/dependency_graph.py +814 -0
- codegraph_nav/domain/__init__.py +20 -0
- codegraph_nav/domain/routes.py +337 -0
- codegraph_nav/domain/schemas.py +229 -0
- codegraph_nav/domain/tags.py +87 -0
- codegraph_nav/exporters.py +563 -0
- codegraph_nav/go_analyzer.py +273 -0
- codegraph_nav/graph/__init__.py +72 -0
- codegraph_nav/graph/builder.py +409 -0
- codegraph_nav/graph/communities.py +402 -0
- codegraph_nav/graph/flows.py +311 -0
- codegraph_nav/graph/query.py +380 -0
- codegraph_nav/graph/schema.py +266 -0
- codegraph_nav/graph/search.py +257 -0
- codegraph_nav/graph/store.py +517 -0
- codegraph_nav/hints.py +195 -0
- codegraph_nav/import_resolver.py +891 -0
- codegraph_nav/js_ts_analyzer.py +564 -0
- codegraph_nav/line_reader.py +664 -0
- codegraph_nav/mcp/__init__.py +39 -0
- codegraph_nav/mcp/__main__.py +5 -0
- codegraph_nav/mcp/server.py +2228 -0
- codegraph_nav/py.typed +2 -0
- codegraph_nav/ruby_analyzer.py +259 -0
- codegraph_nav/rust_analyzer.py +379 -0
- codegraph_nav/token_efficient_renderer.py +743 -0
- codegraph_nav/watcher.py +382 -0
- codegraph_nav-0.1.0.dist-info/METADATA +487 -0
- codegraph_nav-0.1.0.dist-info/RECORD +41 -0
- codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
- codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
- codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
- codegraph_nav-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Import Path Resolver - Intelligent multi-language import resolution.
|
|
3
|
+
|
|
4
|
+
This module provides a unified approach to resolving import paths across
|
|
5
|
+
multiple programming languages, supporting:
|
|
6
|
+
- Relative imports (./foo, ../bar)
|
|
7
|
+
- Path aliases (@/, ~/, #components)
|
|
8
|
+
- Implicit index files (index.js, __init__.py, mod.rs)
|
|
9
|
+
- Package/module resolution (node_modules, Python packages)
|
|
10
|
+
|
|
11
|
+
Key improvement over static approaches: accepts dynamic alias configuration
|
|
12
|
+
that can be loaded from tsconfig.json, jsconfig.json, pyproject.toml, etc.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> resolver = ImportResolver('/my/project')
|
|
16
|
+
>>> resolver.load_aliases_from_tsconfig()
|
|
17
|
+
>>> result = resolver.resolve('src/api/routes.ts', '@/utils/helpers')
|
|
18
|
+
>>> print(result)
|
|
19
|
+
ResolveResult(path='src/utils/helpers.ts', strategy='alias')
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, cast
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResolveStrategy(Enum):
|
|
32
|
+
"""Enumeration of resolution strategies used."""
|
|
33
|
+
|
|
34
|
+
EXACT = "exact" # Direct path match
|
|
35
|
+
RELATIVE = "relative" # ./foo, ../bar
|
|
36
|
+
ALIAS = "alias" # @/foo, ~/bar, #components
|
|
37
|
+
INDEX = "index" # Implicit index file
|
|
38
|
+
SUFFIX = "suffix" # Partial path match
|
|
39
|
+
MODULE = "module" # Go/Python module prefix
|
|
40
|
+
PACKAGE = "package" # node_modules, Python package
|
|
41
|
+
NOT_FOUND = "not_found" # Resolution failed
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ResolveResult:
|
|
46
|
+
"""Result of an import resolution attempt.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
path: Resolved file path (relative to root), or None if not found.
|
|
50
|
+
strategy: Which strategy successfully resolved the import.
|
|
51
|
+
candidates: All candidate paths that were tried.
|
|
52
|
+
original_import: The original import string.
|
|
53
|
+
confidence: 0.0-1.0 indicating resolution confidence.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
path: str | None
|
|
57
|
+
strategy: ResolveStrategy
|
|
58
|
+
candidates: list[str] = field(default_factory=list)
|
|
59
|
+
original_import: str = ""
|
|
60
|
+
confidence: float = 1.0
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def found(self) -> bool:
|
|
64
|
+
"""Whether the import was successfully resolved."""
|
|
65
|
+
return self.path is not None and self.strategy != ResolveStrategy.NOT_FOUND
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class AliasConfig:
|
|
70
|
+
"""Configuration for a single path alias.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
pattern: The alias pattern (e.g., "@/*", "~/", "#components").
|
|
74
|
+
targets: List of replacement paths (e.g., ["src/*"]).
|
|
75
|
+
is_wildcard: Whether the pattern contains a wildcard.
|
|
76
|
+
prefix: Part before the wildcard.
|
|
77
|
+
suffix: Part after the wildcard.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
pattern: str
|
|
81
|
+
targets: list[str]
|
|
82
|
+
is_wildcard: bool = False
|
|
83
|
+
prefix: str = ""
|
|
84
|
+
suffix: str = ""
|
|
85
|
+
|
|
86
|
+
def __post_init__(self):
|
|
87
|
+
"""Parse pattern into prefix/suffix."""
|
|
88
|
+
if "*" in self.pattern:
|
|
89
|
+
self.is_wildcard = True
|
|
90
|
+
idx = self.pattern.index("*")
|
|
91
|
+
self.prefix = self.pattern[:idx]
|
|
92
|
+
self.suffix = self.pattern[idx + 1 :]
|
|
93
|
+
else:
|
|
94
|
+
self.prefix = self.pattern
|
|
95
|
+
|
|
96
|
+
def matches(self, import_path: str) -> str | None:
|
|
97
|
+
"""Check if import matches this alias, return captured wildcard portion.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
import_path: The import string to check.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The wildcard portion if matched, None otherwise.
|
|
104
|
+
"""
|
|
105
|
+
if not import_path.startswith(self.prefix):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
if not self.is_wildcard:
|
|
109
|
+
# Exact match required
|
|
110
|
+
return "" if import_path == self.pattern else None
|
|
111
|
+
|
|
112
|
+
# Check suffix
|
|
113
|
+
if self.suffix and not import_path.endswith(self.suffix):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
# Extract wildcard portion
|
|
117
|
+
wildcard_part = import_path[len(self.prefix) :]
|
|
118
|
+
if self.suffix:
|
|
119
|
+
wildcard_part = wildcard_part[: -len(self.suffix)]
|
|
120
|
+
|
|
121
|
+
return wildcard_part
|
|
122
|
+
|
|
123
|
+
def apply(self, wildcard_part: str) -> list[str]:
|
|
124
|
+
"""Apply the alias transformation.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
wildcard_part: The captured wildcard portion.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
List of resolved paths to try.
|
|
131
|
+
"""
|
|
132
|
+
results = []
|
|
133
|
+
for target in self.targets:
|
|
134
|
+
if "*" in target:
|
|
135
|
+
idx = target.index("*")
|
|
136
|
+
resolved = target[:idx] + wildcard_part + target[idx + 1 :]
|
|
137
|
+
else:
|
|
138
|
+
resolved = target + wildcard_part if wildcard_part else target
|
|
139
|
+
results.append(resolved)
|
|
140
|
+
return results
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ImportResolver:
|
|
144
|
+
"""Multi-language import path resolver with dynamic alias support.
|
|
145
|
+
|
|
146
|
+
This class resolves import statements to actual file paths using multiple
|
|
147
|
+
strategies in a prioritized order. Unlike static resolvers, it accepts
|
|
148
|
+
dynamic alias configuration that can be loaded from various config files.
|
|
149
|
+
|
|
150
|
+
Resolution Order:
|
|
151
|
+
1. Relative paths (./foo, ../bar)
|
|
152
|
+
2. Configured aliases (@/, ~/, etc.)
|
|
153
|
+
3. Module prefixes (Go modules, Python packages)
|
|
154
|
+
4. Exact path match
|
|
155
|
+
5. Implicit index files
|
|
156
|
+
6. Suffix/partial match
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
root: Absolute path to project root.
|
|
160
|
+
aliases: List of configured AliasConfig objects.
|
|
161
|
+
file_index: Cached file index for fast lookups.
|
|
162
|
+
module_name: Detected module/package name.
|
|
163
|
+
base_url: Base URL for relative alias resolution.
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> resolver = ImportResolver('/my/project')
|
|
167
|
+
>>>
|
|
168
|
+
>>> # Add aliases manually
|
|
169
|
+
>>> resolver.add_alias('@/*', ['src/*'])
|
|
170
|
+
>>> resolver.add_alias('~/', ['src/'])
|
|
171
|
+
>>>
|
|
172
|
+
>>> # Or load from config
|
|
173
|
+
>>> resolver.load_aliases_from_tsconfig()
|
|
174
|
+
>>>
|
|
175
|
+
>>> # Resolve imports
|
|
176
|
+
>>> result = resolver.resolve('src/app.ts', '@/utils/helpers')
|
|
177
|
+
>>> print(result.path) # 'src/utils/helpers.ts'
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
# Default extensions by language (in priority order)
|
|
181
|
+
EXTENSIONS = {
|
|
182
|
+
"default": ["", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".rb", ".java"],
|
|
183
|
+
"typescript": ["", ".ts", ".tsx", ".d.ts", ".js", ".jsx"],
|
|
184
|
+
"javascript": ["", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"],
|
|
185
|
+
"python": ["", ".py", ".pyi"],
|
|
186
|
+
"go": ["", ".go"],
|
|
187
|
+
"rust": ["", ".rs"],
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# Implicit index files by language
|
|
191
|
+
INDEX_FILES = {
|
|
192
|
+
"default": ["index.ts", "index.tsx", "index.js", "index.jsx", "__init__.py", "mod.rs"],
|
|
193
|
+
"typescript": ["index.ts", "index.tsx", "index.d.ts", "index.js"],
|
|
194
|
+
"javascript": ["index.js", "index.jsx", "index.mjs", "index.ts"],
|
|
195
|
+
"python": ["__init__.py", "__init__.pyi"],
|
|
196
|
+
"go": [], # Go doesn't have index files
|
|
197
|
+
"rust": ["mod.rs"],
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# Directories to skip
|
|
201
|
+
IGNORED_DIRS = {
|
|
202
|
+
"node_modules",
|
|
203
|
+
"__pycache__",
|
|
204
|
+
".git",
|
|
205
|
+
".svn",
|
|
206
|
+
"venv",
|
|
207
|
+
"env",
|
|
208
|
+
".venv",
|
|
209
|
+
"dist",
|
|
210
|
+
"build",
|
|
211
|
+
".next",
|
|
212
|
+
"coverage",
|
|
213
|
+
"vendor",
|
|
214
|
+
"target",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
def __init__(
|
|
218
|
+
self,
|
|
219
|
+
root: str,
|
|
220
|
+
aliases: dict[str, list[str]] | None = None,
|
|
221
|
+
base_url: str = "",
|
|
222
|
+
):
|
|
223
|
+
"""Initialize the import resolver.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
root: Path to project root directory.
|
|
227
|
+
aliases: Initial alias mappings {pattern: [targets]}.
|
|
228
|
+
base_url: Base URL for non-absolute alias targets.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
ValueError: If root path doesn't exist.
|
|
232
|
+
"""
|
|
233
|
+
self.root = Path(root).resolve()
|
|
234
|
+
if not self.root.exists():
|
|
235
|
+
raise ValueError(f"Root path does not exist: {self.root}")
|
|
236
|
+
|
|
237
|
+
self.base_url = base_url
|
|
238
|
+
self.aliases: list[AliasConfig] = []
|
|
239
|
+
self.file_index: dict[str, set[str] | dict[str, set[str]]] = {}
|
|
240
|
+
self.module_name = ""
|
|
241
|
+
self._index_built = False
|
|
242
|
+
|
|
243
|
+
# Add initial aliases
|
|
244
|
+
if aliases:
|
|
245
|
+
for pattern, targets in aliases.items():
|
|
246
|
+
self.add_alias(pattern, targets)
|
|
247
|
+
|
|
248
|
+
def add_alias(self, pattern: str, targets: str | list[str]) -> "ImportResolver":
|
|
249
|
+
"""Add a path alias configuration.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
pattern: Alias pattern (e.g., "@/*", "~/", "#components/*").
|
|
253
|
+
targets: Target path(s) to resolve to.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
self, for method chaining.
|
|
257
|
+
|
|
258
|
+
Example:
|
|
259
|
+
>>> resolver.add_alias("@/*", ["src/*"])
|
|
260
|
+
>>> resolver.add_alias("~/", "src/")
|
|
261
|
+
>>> resolver.add_alias("#components/*", ["src/components/*", "shared/components/*"])
|
|
262
|
+
"""
|
|
263
|
+
if isinstance(targets, str):
|
|
264
|
+
targets = [targets]
|
|
265
|
+
|
|
266
|
+
# Apply base_url to non-absolute targets
|
|
267
|
+
resolved_targets = []
|
|
268
|
+
for target in targets:
|
|
269
|
+
if self.base_url and not os.path.isabs(target) and not target.startswith("."):
|
|
270
|
+
# Only join if base_url is not "." (current dir)
|
|
271
|
+
if self.base_url != ".":
|
|
272
|
+
target = os.path.join(self.base_url, target)
|
|
273
|
+
# Normalize: remove leading "./" and use forward slashes
|
|
274
|
+
target = target.lstrip("./").replace("\\", "/")
|
|
275
|
+
resolved_targets.append(target)
|
|
276
|
+
|
|
277
|
+
self.aliases.append(AliasConfig(pattern=pattern, targets=resolved_targets))
|
|
278
|
+
return self
|
|
279
|
+
|
|
280
|
+
def clear_aliases(self) -> "ImportResolver":
|
|
281
|
+
"""Remove all configured aliases."""
|
|
282
|
+
self.aliases.clear()
|
|
283
|
+
return self
|
|
284
|
+
|
|
285
|
+
def load_aliases_from_tsconfig(self, config_path: str | None = None) -> "ImportResolver":
|
|
286
|
+
"""Load path aliases from tsconfig.json or jsconfig.json.
|
|
287
|
+
|
|
288
|
+
Automatically detects and loads configuration, including handling
|
|
289
|
+
the "extends" directive for inherited configs.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
config_path: Explicit path to config file (auto-detected if None).
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
self, for method chaining.
|
|
296
|
+
"""
|
|
297
|
+
if config_path:
|
|
298
|
+
config_paths = [Path(config_path)]
|
|
299
|
+
else:
|
|
300
|
+
config_paths = [
|
|
301
|
+
self.root / "tsconfig.json",
|
|
302
|
+
self.root / "jsconfig.json",
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
for path in config_paths:
|
|
306
|
+
if path.exists():
|
|
307
|
+
aliases, base_url = self._parse_tsconfig(path)
|
|
308
|
+
if base_url:
|
|
309
|
+
self.base_url = base_url
|
|
310
|
+
for pattern, targets in aliases.items():
|
|
311
|
+
self.add_alias(pattern, targets)
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
def _parse_tsconfig(
|
|
317
|
+
self, config_path: Path, seen: set[str] | None = None
|
|
318
|
+
) -> tuple[dict[str, list[str]], str]:
|
|
319
|
+
"""Parse tsconfig.json, following extends directive.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
config_path: Path to the config file.
|
|
323
|
+
seen: Set of already-parsed paths (prevents cycles).
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Tuple of (paths dict, baseUrl string).
|
|
327
|
+
"""
|
|
328
|
+
if seen is None:
|
|
329
|
+
seen = set()
|
|
330
|
+
|
|
331
|
+
config_str = str(config_path.resolve())
|
|
332
|
+
if config_str in seen:
|
|
333
|
+
return {}, ""
|
|
334
|
+
seen.add(config_str)
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
# Read and parse JSON (with comment stripping)
|
|
338
|
+
content = config_path.read_text(encoding="utf-8")
|
|
339
|
+
# Remove single-line comments
|
|
340
|
+
content = re.sub(r"//.*?$", "", content, flags=re.MULTILINE)
|
|
341
|
+
# Remove multi-line comments
|
|
342
|
+
content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
|
|
343
|
+
config = json.loads(content)
|
|
344
|
+
except (json.JSONDecodeError, OSError):
|
|
345
|
+
return {}, ""
|
|
346
|
+
|
|
347
|
+
compiler_options = config.get("compilerOptions", {})
|
|
348
|
+
paths = compiler_options.get("paths", {})
|
|
349
|
+
base_url = compiler_options.get("baseUrl", "")
|
|
350
|
+
|
|
351
|
+
# Handle extends
|
|
352
|
+
extends = config.get("extends")
|
|
353
|
+
if extends:
|
|
354
|
+
parent_path = Path(extends)
|
|
355
|
+
if not parent_path.is_absolute():
|
|
356
|
+
parent_path = config_path.parent / extends
|
|
357
|
+
if not parent_path.suffix:
|
|
358
|
+
parent_path = parent_path.with_suffix(".json")
|
|
359
|
+
|
|
360
|
+
parent_paths, parent_base = self._parse_tsconfig(parent_path, seen)
|
|
361
|
+
|
|
362
|
+
# Merge: child overrides parent
|
|
363
|
+
merged_paths = dict(parent_paths)
|
|
364
|
+
merged_paths.update(paths)
|
|
365
|
+
paths = merged_paths
|
|
366
|
+
|
|
367
|
+
if not base_url:
|
|
368
|
+
base_url = parent_base
|
|
369
|
+
|
|
370
|
+
return paths, base_url
|
|
371
|
+
|
|
372
|
+
def load_aliases_from_pyproject(self, config_path: str | None = None) -> "ImportResolver":
|
|
373
|
+
"""Load path aliases from pyproject.toml [tool.import_resolver] section.
|
|
374
|
+
|
|
375
|
+
Expected format in pyproject.toml:
|
|
376
|
+
[tool.import_resolver]
|
|
377
|
+
aliases = { "@" = ["src"], "~" = ["lib"] }
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
config_path: Explicit path (defaults to root/pyproject.toml).
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
self, for method chaining.
|
|
384
|
+
"""
|
|
385
|
+
path = Path(config_path) if config_path else self.root / "pyproject.toml"
|
|
386
|
+
|
|
387
|
+
if not path.exists():
|
|
388
|
+
return self
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
# Simple TOML parsing for the section we care about
|
|
392
|
+
content = path.read_text(encoding="utf-8")
|
|
393
|
+
|
|
394
|
+
# Try to import tomllib (Python 3.11+) or toml
|
|
395
|
+
try:
|
|
396
|
+
import tomllib
|
|
397
|
+
|
|
398
|
+
data = tomllib.loads(content)
|
|
399
|
+
except ImportError:
|
|
400
|
+
try:
|
|
401
|
+
import toml # type: ignore[import-untyped] # optional dep, no stubs
|
|
402
|
+
|
|
403
|
+
data = toml.loads(content)
|
|
404
|
+
except ImportError:
|
|
405
|
+
# Fallback: regex parsing for simple cases
|
|
406
|
+
data = self._simple_toml_parse(content)
|
|
407
|
+
|
|
408
|
+
resolver_config = data.get("tool", {}).get("import_resolver", {})
|
|
409
|
+
aliases = resolver_config.get("aliases", {})
|
|
410
|
+
|
|
411
|
+
for pattern, targets in aliases.items():
|
|
412
|
+
if isinstance(targets, str):
|
|
413
|
+
targets = [targets]
|
|
414
|
+
self.add_alias(pattern, targets)
|
|
415
|
+
|
|
416
|
+
self.base_url = resolver_config.get("base_url", self.base_url)
|
|
417
|
+
|
|
418
|
+
except Exception:
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
return self
|
|
422
|
+
|
|
423
|
+
def _simple_toml_parse(self, content: str) -> dict[str, Any]:
|
|
424
|
+
"""Minimal TOML parser for import_resolver config."""
|
|
425
|
+
# This is a very basic parser for the specific section we need
|
|
426
|
+
result: dict[str, Any] = {"tool": {"import_resolver": {"aliases": {}}}}
|
|
427
|
+
|
|
428
|
+
in_section = False
|
|
429
|
+
for line in content.split("\n"):
|
|
430
|
+
line = line.strip()
|
|
431
|
+
if line == "[tool.import_resolver]":
|
|
432
|
+
in_section = True
|
|
433
|
+
elif line.startswith("[") and in_section:
|
|
434
|
+
break
|
|
435
|
+
elif in_section and "=" in line:
|
|
436
|
+
# Parse key = value
|
|
437
|
+
key, value = line.split("=", 1)
|
|
438
|
+
key = key.strip().strip('"')
|
|
439
|
+
value = value.strip()
|
|
440
|
+
# Very basic value parsing
|
|
441
|
+
if value.startswith("["):
|
|
442
|
+
# Array
|
|
443
|
+
items = re.findall(r'"([^"]*)"', value)
|
|
444
|
+
result["tool"]["import_resolver"]["aliases"][key] = items
|
|
445
|
+
elif value.startswith('"'):
|
|
446
|
+
result["tool"]["import_resolver"]["aliases"][key] = [value.strip('"')]
|
|
447
|
+
|
|
448
|
+
return result
|
|
449
|
+
|
|
450
|
+
def build_index(self, languages: list[str] | None = None) -> "ImportResolver":
|
|
451
|
+
"""Build file index for fast lookups.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
languages: Languages to include (None = all).
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
self, for method chaining.
|
|
458
|
+
"""
|
|
459
|
+
exact: set[str] = set() # All file paths
|
|
460
|
+
no_ext_index: dict[str, set[str]] = {} # path without extension -> paths
|
|
461
|
+
suffix_index: dict[str, set[str]] = {} # path suffix -> paths
|
|
462
|
+
dir_index: dict[str, set[str]] = {} # directory -> files
|
|
463
|
+
basename_index: dict[str, set[str]] = {} # filename without dir -> paths
|
|
464
|
+
self.file_index = {
|
|
465
|
+
"exact": exact,
|
|
466
|
+
"no_ext": no_ext_index,
|
|
467
|
+
"suffix": suffix_index,
|
|
468
|
+
"dir": dir_index,
|
|
469
|
+
"basename": basename_index,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
# Determine extensions to look for
|
|
473
|
+
if languages:
|
|
474
|
+
extensions = set()
|
|
475
|
+
for lang in languages:
|
|
476
|
+
extensions.update(self.EXTENSIONS.get(lang, self.EXTENSIONS["default"]))
|
|
477
|
+
else:
|
|
478
|
+
extensions = set()
|
|
479
|
+
for exts in self.EXTENSIONS.values():
|
|
480
|
+
extensions.update(exts)
|
|
481
|
+
extensions.discard("")
|
|
482
|
+
|
|
483
|
+
# Detect module name
|
|
484
|
+
self.module_name = self._detect_module_name()
|
|
485
|
+
|
|
486
|
+
# Walk directory tree
|
|
487
|
+
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
488
|
+
# Filter ignored directories
|
|
489
|
+
dirnames[:] = [d for d in dirnames if d not in self.IGNORED_DIRS]
|
|
490
|
+
|
|
491
|
+
for filename in filenames:
|
|
492
|
+
ext = Path(filename).suffix
|
|
493
|
+
if ext not in extensions:
|
|
494
|
+
continue
|
|
495
|
+
|
|
496
|
+
full_path = Path(dirpath) / filename
|
|
497
|
+
# Normalize to forward slashes for cross-platform consistency
|
|
498
|
+
rel_path = str(full_path.relative_to(self.root)).replace("\\", "/")
|
|
499
|
+
|
|
500
|
+
# Index by exact path
|
|
501
|
+
exact.add(rel_path)
|
|
502
|
+
|
|
503
|
+
# Index by path without extension
|
|
504
|
+
# Normalize to forward slashes for cross-platform consistency
|
|
505
|
+
no_ext = str(Path(rel_path).with_suffix("")).replace("\\", "/")
|
|
506
|
+
if no_ext not in no_ext_index:
|
|
507
|
+
no_ext_index[no_ext] = set()
|
|
508
|
+
no_ext_index[no_ext].add(rel_path)
|
|
509
|
+
|
|
510
|
+
# Index by basename
|
|
511
|
+
basename = Path(rel_path).stem
|
|
512
|
+
if basename not in basename_index:
|
|
513
|
+
basename_index[basename] = set()
|
|
514
|
+
basename_index[basename].add(rel_path)
|
|
515
|
+
|
|
516
|
+
# Index by directory (normalize to forward slashes)
|
|
517
|
+
dir_path = str(Path(rel_path).parent).replace("\\", "/")
|
|
518
|
+
if dir_path not in dir_index:
|
|
519
|
+
dir_index[dir_path] = set()
|
|
520
|
+
dir_index[dir_path].add(rel_path)
|
|
521
|
+
|
|
522
|
+
# Index by all suffixes
|
|
523
|
+
parts = Path(rel_path).parts
|
|
524
|
+
for i in range(1, len(parts)):
|
|
525
|
+
# Normalize to forward slashes for cross-platform consistency
|
|
526
|
+
suffix = str(Path(*parts[i:])).replace("\\", "/")
|
|
527
|
+
if suffix not in suffix_index:
|
|
528
|
+
suffix_index[suffix] = set()
|
|
529
|
+
suffix_index[suffix].add(rel_path)
|
|
530
|
+
|
|
531
|
+
# Also without extension (normalize to forward slashes)
|
|
532
|
+
suffix_no_ext = str(Path(*parts[i:]).with_suffix("")).replace("\\", "/")
|
|
533
|
+
if suffix_no_ext not in suffix_index:
|
|
534
|
+
suffix_index[suffix_no_ext] = set()
|
|
535
|
+
suffix_index[suffix_no_ext].add(rel_path)
|
|
536
|
+
|
|
537
|
+
self._index_built = True
|
|
538
|
+
return self
|
|
539
|
+
|
|
540
|
+
def _detect_module_name(self) -> str:
|
|
541
|
+
"""Detect project module/package name."""
|
|
542
|
+
# Go module
|
|
543
|
+
go_mod = self.root / "go.mod"
|
|
544
|
+
if go_mod.exists():
|
|
545
|
+
try:
|
|
546
|
+
for line in go_mod.read_text().splitlines():
|
|
547
|
+
if line.startswith("module "):
|
|
548
|
+
return line.split()[1]
|
|
549
|
+
except Exception:
|
|
550
|
+
pass
|
|
551
|
+
|
|
552
|
+
# Python package
|
|
553
|
+
pyproject = self.root / "pyproject.toml"
|
|
554
|
+
if pyproject.exists():
|
|
555
|
+
try:
|
|
556
|
+
content = pyproject.read_text()
|
|
557
|
+
match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', content)
|
|
558
|
+
if match:
|
|
559
|
+
return match.group(1).replace("-", "_")
|
|
560
|
+
except Exception:
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
# Node package
|
|
564
|
+
package_json = self.root / "package.json"
|
|
565
|
+
if package_json.exists():
|
|
566
|
+
try:
|
|
567
|
+
data = json.loads(package_json.read_text())
|
|
568
|
+
return str(data.get("name", ""))
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
|
|
572
|
+
return self.root.name
|
|
573
|
+
|
|
574
|
+
def resolve(
|
|
575
|
+
self,
|
|
576
|
+
source_file: str,
|
|
577
|
+
import_string: str,
|
|
578
|
+
language: str | None = None,
|
|
579
|
+
) -> ResolveResult:
|
|
580
|
+
"""Resolve an import string to an actual file path.
|
|
581
|
+
|
|
582
|
+
This is the main entry point for import resolution. It tries multiple
|
|
583
|
+
strategies in order until one succeeds.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
source_file: Path to the file containing the import (relative to root).
|
|
587
|
+
import_string: The import string to resolve.
|
|
588
|
+
language: Source language (auto-detected if None).
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
ResolveResult with the resolved path and metadata.
|
|
592
|
+
|
|
593
|
+
Example:
|
|
594
|
+
>>> result = resolver.resolve('src/app.ts', '@/utils/helpers')
|
|
595
|
+
>>> if result.found:
|
|
596
|
+
... print(f"Resolved to: {result.path}")
|
|
597
|
+
... print(f"Strategy: {result.strategy.value}")
|
|
598
|
+
"""
|
|
599
|
+
if not self._index_built:
|
|
600
|
+
self.build_index()
|
|
601
|
+
|
|
602
|
+
# Detect language if not provided
|
|
603
|
+
if language is None:
|
|
604
|
+
language = self._detect_language(source_file)
|
|
605
|
+
|
|
606
|
+
# Normalize the import
|
|
607
|
+
normalized = self._normalize_import(import_string, language)
|
|
608
|
+
source_dir = str(Path(source_file).parent)
|
|
609
|
+
if source_dir == ".":
|
|
610
|
+
source_dir = ""
|
|
611
|
+
|
|
612
|
+
candidates = []
|
|
613
|
+
|
|
614
|
+
# Strategy 1: Relative imports (./foo, ../bar)
|
|
615
|
+
if import_string.startswith("."):
|
|
616
|
+
result = self._resolve_relative(import_string, source_dir, language)
|
|
617
|
+
if result.found:
|
|
618
|
+
return result
|
|
619
|
+
candidates.extend(result.candidates)
|
|
620
|
+
|
|
621
|
+
# Strategy 2: Configured aliases
|
|
622
|
+
for alias in self.aliases:
|
|
623
|
+
wildcard = alias.matches(import_string)
|
|
624
|
+
if wildcard is not None:
|
|
625
|
+
for target in alias.apply(wildcard):
|
|
626
|
+
result = self._try_resolve_path(target, language)
|
|
627
|
+
if result.found:
|
|
628
|
+
result.strategy = ResolveStrategy.ALIAS
|
|
629
|
+
result.original_import = import_string
|
|
630
|
+
return result
|
|
631
|
+
candidates.extend(result.candidates)
|
|
632
|
+
|
|
633
|
+
# Strategy 3: Module prefix (e.g., "mypackage/utils" for Go/Python)
|
|
634
|
+
if self.module_name and import_string.startswith(self.module_name):
|
|
635
|
+
rest = import_string[len(self.module_name) :].lstrip("/.")
|
|
636
|
+
result = self._try_resolve_path(rest, language)
|
|
637
|
+
if result.found:
|
|
638
|
+
result.strategy = ResolveStrategy.MODULE
|
|
639
|
+
result.original_import = import_string
|
|
640
|
+
return result
|
|
641
|
+
candidates.extend(result.candidates)
|
|
642
|
+
|
|
643
|
+
# Strategy 4: Exact match
|
|
644
|
+
result = self._try_resolve_path(normalized, language)
|
|
645
|
+
if result.found:
|
|
646
|
+
result.strategy = ResolveStrategy.EXACT
|
|
647
|
+
result.original_import = import_string
|
|
648
|
+
return result
|
|
649
|
+
candidates.extend(result.candidates)
|
|
650
|
+
|
|
651
|
+
# Strategy 5: Suffix match (for nested packages)
|
|
652
|
+
result = self._resolve_suffix(normalized, language)
|
|
653
|
+
if result.found:
|
|
654
|
+
result.original_import = import_string
|
|
655
|
+
return result
|
|
656
|
+
candidates.extend(result.candidates)
|
|
657
|
+
|
|
658
|
+
# Not found
|
|
659
|
+
return ResolveResult(
|
|
660
|
+
path=None,
|
|
661
|
+
strategy=ResolveStrategy.NOT_FOUND,
|
|
662
|
+
candidates=list(set(candidates)),
|
|
663
|
+
original_import=import_string,
|
|
664
|
+
confidence=0.0,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
def _detect_language(self, file_path: str) -> str:
|
|
668
|
+
"""Detect language from file extension."""
|
|
669
|
+
ext = Path(file_path).suffix.lower()
|
|
670
|
+
mapping = {
|
|
671
|
+
".ts": "typescript",
|
|
672
|
+
".tsx": "typescript",
|
|
673
|
+
".d.ts": "typescript",
|
|
674
|
+
".js": "javascript",
|
|
675
|
+
".jsx": "javascript",
|
|
676
|
+
".mjs": "javascript",
|
|
677
|
+
".py": "python",
|
|
678
|
+
".pyi": "python",
|
|
679
|
+
".go": "go",
|
|
680
|
+
".rs": "rust",
|
|
681
|
+
".rb": "ruby",
|
|
682
|
+
".java": "java",
|
|
683
|
+
}
|
|
684
|
+
return mapping.get(ext, "default")
|
|
685
|
+
|
|
686
|
+
def _normalize_import(self, import_string: str, language: str) -> str:
|
|
687
|
+
"""Convert import syntax to path-like format."""
|
|
688
|
+
imp = import_string.strip("\"'`")
|
|
689
|
+
|
|
690
|
+
# Python dots to slashes: app.core.config -> app/core/config
|
|
691
|
+
if language == "python" and "." in imp and "/" not in imp:
|
|
692
|
+
if not imp.startswith("."):
|
|
693
|
+
imp = imp.replace(".", "/")
|
|
694
|
+
|
|
695
|
+
# Rust :: to slashes
|
|
696
|
+
if language == "rust":
|
|
697
|
+
if imp.startswith("crate::"):
|
|
698
|
+
imp = imp[7:].replace("::", "/")
|
|
699
|
+
elif imp.startswith("super::"):
|
|
700
|
+
imp = imp.replace("::", "/")
|
|
701
|
+
elif "::" in imp:
|
|
702
|
+
imp = imp.replace("::", "/")
|
|
703
|
+
|
|
704
|
+
# Go: module/package/file -> package/file
|
|
705
|
+
if language == "go" and self.module_name:
|
|
706
|
+
if imp.startswith(self.module_name + "/"):
|
|
707
|
+
imp = imp[len(self.module_name) + 1 :]
|
|
708
|
+
|
|
709
|
+
return imp
|
|
710
|
+
|
|
711
|
+
def _resolve_relative(
|
|
712
|
+
self, import_string: str, source_dir: str, language: str
|
|
713
|
+
) -> ResolveResult:
|
|
714
|
+
"""Resolve relative imports (./foo, ../bar)."""
|
|
715
|
+
# Count parent levels
|
|
716
|
+
levels = 0
|
|
717
|
+
rest = import_string
|
|
718
|
+
while rest.startswith("../"):
|
|
719
|
+
levels += 1
|
|
720
|
+
rest = rest[3:]
|
|
721
|
+
rest = rest.lstrip("./")
|
|
722
|
+
|
|
723
|
+
# Navigate up
|
|
724
|
+
target_dir = Path(source_dir)
|
|
725
|
+
for _ in range(levels):
|
|
726
|
+
target_dir = target_dir.parent
|
|
727
|
+
|
|
728
|
+
# Build candidate path (normalize to forward slashes for cross-platform)
|
|
729
|
+
if str(target_dir) == ".":
|
|
730
|
+
candidate = rest
|
|
731
|
+
else:
|
|
732
|
+
candidate = str(target_dir / rest).replace("\\", "/")
|
|
733
|
+
|
|
734
|
+
result = self._try_resolve_path(candidate, language)
|
|
735
|
+
if result.found:
|
|
736
|
+
# Only set RELATIVE strategy if it wasn't resolved via INDEX
|
|
737
|
+
if result.strategy != ResolveStrategy.INDEX:
|
|
738
|
+
result.strategy = ResolveStrategy.RELATIVE
|
|
739
|
+
return result
|
|
740
|
+
|
|
741
|
+
def _try_resolve_path(self, path: str, language: str) -> ResolveResult:
|
|
742
|
+
"""Try to resolve a path with extensions and index files."""
|
|
743
|
+
candidates = []
|
|
744
|
+
extensions = self.EXTENSIONS.get(language, self.EXTENSIONS["default"])
|
|
745
|
+
index_files = self.INDEX_FILES.get(language, self.INDEX_FILES["default"])
|
|
746
|
+
|
|
747
|
+
# Try exact match first
|
|
748
|
+
if path in self.file_index["exact"]:
|
|
749
|
+
return ResolveResult(
|
|
750
|
+
path=path,
|
|
751
|
+
strategy=ResolveStrategy.EXACT,
|
|
752
|
+
candidates=[path],
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
# Try with extensions
|
|
756
|
+
for ext in extensions:
|
|
757
|
+
if not ext:
|
|
758
|
+
continue
|
|
759
|
+
candidate = path + ext
|
|
760
|
+
candidates.append(candidate)
|
|
761
|
+
if candidate in self.file_index["exact"]:
|
|
762
|
+
return ResolveResult(
|
|
763
|
+
path=candidate,
|
|
764
|
+
strategy=ResolveStrategy.EXACT,
|
|
765
|
+
candidates=candidates,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# Try without extension lookup
|
|
769
|
+
no_ext_index = cast("dict[str, set[str]]", self.file_index["no_ext"])
|
|
770
|
+
if path in no_ext_index:
|
|
771
|
+
matches = no_ext_index[path]
|
|
772
|
+
if len(matches) == 1:
|
|
773
|
+
return ResolveResult(
|
|
774
|
+
path=list(matches)[0],
|
|
775
|
+
strategy=ResolveStrategy.EXACT,
|
|
776
|
+
candidates=candidates,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
# Try index files (path is a directory)
|
|
780
|
+
for index_file in index_files:
|
|
781
|
+
# Normalize to forward slashes for cross-platform consistency
|
|
782
|
+
candidate = str(Path(path) / index_file).replace("\\", "/")
|
|
783
|
+
candidates.append(candidate)
|
|
784
|
+
if candidate in self.file_index["exact"]:
|
|
785
|
+
return ResolveResult(
|
|
786
|
+
path=candidate,
|
|
787
|
+
strategy=ResolveStrategy.INDEX,
|
|
788
|
+
candidates=candidates,
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
return ResolveResult(
|
|
792
|
+
path=None,
|
|
793
|
+
strategy=ResolveStrategy.NOT_FOUND,
|
|
794
|
+
candidates=candidates,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def _resolve_suffix(self, normalized: str, language: str) -> ResolveResult:
|
|
798
|
+
"""Resolve by matching path suffix."""
|
|
799
|
+
extensions = self.EXTENSIONS.get(language, self.EXTENSIONS["default"])
|
|
800
|
+
candidates = []
|
|
801
|
+
suffix_index = cast("dict[str, set[str]]", self.file_index["suffix"])
|
|
802
|
+
|
|
803
|
+
for ext in extensions:
|
|
804
|
+
candidate = normalized + ext if ext else normalized
|
|
805
|
+
candidates.append(candidate)
|
|
806
|
+
|
|
807
|
+
if candidate in suffix_index:
|
|
808
|
+
matches = suffix_index[candidate]
|
|
809
|
+
if len(matches) == 1:
|
|
810
|
+
return ResolveResult(
|
|
811
|
+
path=list(matches)[0],
|
|
812
|
+
strategy=ResolveStrategy.SUFFIX,
|
|
813
|
+
candidates=candidates,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
# Try __init__.py for Python packages
|
|
817
|
+
if language == "python":
|
|
818
|
+
# Normalize to forward slashes for cross-platform consistency
|
|
819
|
+
init_candidate = str(Path(normalized) / "__init__.py").replace("\\", "/")
|
|
820
|
+
candidates.append(init_candidate)
|
|
821
|
+
if init_candidate in suffix_index:
|
|
822
|
+
matches = suffix_index[init_candidate]
|
|
823
|
+
if len(matches) == 1:
|
|
824
|
+
return ResolveResult(
|
|
825
|
+
path=list(matches)[0],
|
|
826
|
+
strategy=ResolveStrategy.SUFFIX,
|
|
827
|
+
candidates=candidates,
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
return ResolveResult(
|
|
831
|
+
path=None,
|
|
832
|
+
strategy=ResolveStrategy.NOT_FOUND,
|
|
833
|
+
candidates=candidates,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
def resolve_all(
|
|
837
|
+
self,
|
|
838
|
+
source_file: str,
|
|
839
|
+
imports: list[str],
|
|
840
|
+
language: str | None = None,
|
|
841
|
+
) -> dict[str, ResolveResult]:
|
|
842
|
+
"""Resolve multiple imports at once.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
source_file: Source file path.
|
|
846
|
+
imports: List of import strings.
|
|
847
|
+
language: Source language.
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
Dict mapping import strings to ResolveResults.
|
|
851
|
+
"""
|
|
852
|
+
return {imp: self.resolve(source_file, imp, language) for imp in imports}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
# Convenience function for simple use cases
|
|
856
|
+
def resolve_import_path(
|
|
857
|
+
source_file: str,
|
|
858
|
+
import_string: str,
|
|
859
|
+
root_dir: str,
|
|
860
|
+
aliases: dict[str, list[str]] | None = None,
|
|
861
|
+
base_url: str = "",
|
|
862
|
+
) -> str | None:
|
|
863
|
+
"""Resolve an import path to an actual file.
|
|
864
|
+
|
|
865
|
+
This is a convenience function for simple use cases. For repeated
|
|
866
|
+
resolutions, use ImportResolver directly for better performance.
|
|
867
|
+
|
|
868
|
+
Args:
|
|
869
|
+
source_file: Path to file containing the import (relative to root).
|
|
870
|
+
import_string: The import string to resolve.
|
|
871
|
+
root_dir: Project root directory.
|
|
872
|
+
aliases: Optional alias configuration {pattern: [targets]}.
|
|
873
|
+
base_url: Base URL for alias resolution.
|
|
874
|
+
|
|
875
|
+
Returns:
|
|
876
|
+
Resolved file path (relative to root), or None if not found.
|
|
877
|
+
|
|
878
|
+
Example:
|
|
879
|
+
>>> path = resolve_import_path(
|
|
880
|
+
... 'src/app.ts',
|
|
881
|
+
... '@/utils/helpers',
|
|
882
|
+
... '/my/project',
|
|
883
|
+
... aliases={'@/*': ['src/*']},
|
|
884
|
+
... )
|
|
885
|
+
>>> print(path)
|
|
886
|
+
'src/utils/helpers.ts'
|
|
887
|
+
"""
|
|
888
|
+
resolver = ImportResolver(root_dir, aliases=aliases, base_url=base_url)
|
|
889
|
+
resolver.build_index()
|
|
890
|
+
result = resolver.resolve(source_file, import_string)
|
|
891
|
+
return result.path if result.found else None
|