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.
Files changed (41) hide show
  1. codegraph_nav/__init__.py +194 -0
  2. codegraph_nav/ast_grep_analyzer.py +448 -0
  3. codegraph_nav/cli.py +223 -0
  4. codegraph_nav/code_navigator.py +1328 -0
  5. codegraph_nav/code_search.py +1009 -0
  6. codegraph_nav/colors.py +209 -0
  7. codegraph_nav/completions.py +354 -0
  8. codegraph_nav/dart_analyzer.py +301 -0
  9. codegraph_nav/dependency_graph.py +814 -0
  10. codegraph_nav/domain/__init__.py +20 -0
  11. codegraph_nav/domain/routes.py +337 -0
  12. codegraph_nav/domain/schemas.py +229 -0
  13. codegraph_nav/domain/tags.py +87 -0
  14. codegraph_nav/exporters.py +563 -0
  15. codegraph_nav/go_analyzer.py +273 -0
  16. codegraph_nav/graph/__init__.py +72 -0
  17. codegraph_nav/graph/builder.py +409 -0
  18. codegraph_nav/graph/communities.py +402 -0
  19. codegraph_nav/graph/flows.py +311 -0
  20. codegraph_nav/graph/query.py +380 -0
  21. codegraph_nav/graph/schema.py +266 -0
  22. codegraph_nav/graph/search.py +257 -0
  23. codegraph_nav/graph/store.py +517 -0
  24. codegraph_nav/hints.py +195 -0
  25. codegraph_nav/import_resolver.py +891 -0
  26. codegraph_nav/js_ts_analyzer.py +564 -0
  27. codegraph_nav/line_reader.py +664 -0
  28. codegraph_nav/mcp/__init__.py +39 -0
  29. codegraph_nav/mcp/__main__.py +5 -0
  30. codegraph_nav/mcp/server.py +2228 -0
  31. codegraph_nav/py.typed +2 -0
  32. codegraph_nav/ruby_analyzer.py +259 -0
  33. codegraph_nav/rust_analyzer.py +379 -0
  34. codegraph_nav/token_efficient_renderer.py +743 -0
  35. codegraph_nav/watcher.py +382 -0
  36. codegraph_nav-0.1.0.dist-info/METADATA +487 -0
  37. codegraph_nav-0.1.0.dist-info/RECORD +41 -0
  38. codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
  39. codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
  40. codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. 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