kopipasta 0.38.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.
Potentially problematic release.
This version of kopipasta might be problematic. Click here for more details.
- kopipasta/__init__.py +0 -0
- kopipasta/cache.py +40 -0
- kopipasta/file.py +225 -0
- kopipasta/import_parser.py +356 -0
- kopipasta/main.py +1449 -0
- kopipasta/prompt.py +174 -0
- kopipasta/tree_selector.py +791 -0
- kopipasta-0.38.0.dist-info/LICENSE +21 -0
- kopipasta-0.38.0.dist-info/METADATA +111 -0
- kopipasta-0.38.0.dist-info/RECORD +13 -0
- kopipasta-0.38.0.dist-info/WHEEL +5 -0
- kopipasta-0.38.0.dist-info/entry_points.txt +2 -0
- kopipasta-0.38.0.dist-info/top_level.txt +1 -0
kopipasta/__init__.py
ADDED
|
File without changes
|
kopipasta/cache.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
# Define FileTuple for type hinting
|
|
7
|
+
FileTuple = Tuple[str, bool, List[str] | None, str]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_cache_file_path() -> Path:
|
|
11
|
+
"""Gets the cross-platform path to the cache file for the last selection."""
|
|
12
|
+
cache_dir = Path.home() / ".cache" / "kopipasta"
|
|
13
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
return cache_dir / "last_selection.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save_selection_to_cache(files_to_include: List[FileTuple]):
|
|
18
|
+
"""Saves the list of selected file relative paths to the cache."""
|
|
19
|
+
cache_file = get_cache_file_path()
|
|
20
|
+
relative_paths = sorted([os.path.relpath(f[0]) for f in files_to_include])
|
|
21
|
+
try:
|
|
22
|
+
with open(cache_file, "w", encoding="utf-8") as f:
|
|
23
|
+
json.dump(relative_paths, f, indent=2)
|
|
24
|
+
except IOError as e:
|
|
25
|
+
print(f"\nWarning: Could not save selection to cache: {e}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_selection_from_cache() -> List[str]:
|
|
29
|
+
"""Loads the list of selected files from the cache file."""
|
|
30
|
+
cache_file = get_cache_file_path()
|
|
31
|
+
if not cache_file.exists():
|
|
32
|
+
return []
|
|
33
|
+
try:
|
|
34
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
35
|
+
paths = json.load(f)
|
|
36
|
+
# Filter out paths that no longer exist
|
|
37
|
+
return [p for p in paths if os.path.exists(p)]
|
|
38
|
+
except (IOError, json.JSONDecodeError) as e:
|
|
39
|
+
print(f"\nWarning: Could not load previous selection from cache: {e}")
|
|
40
|
+
return []
|
kopipasta/file.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import os
|
|
3
|
+
from typing import List, Optional, Tuple, Set
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
FileTuple = Tuple[str, bool, Optional[List[str]], str]
|
|
7
|
+
|
|
8
|
+
# --- Caches ---
|
|
9
|
+
_gitignore_cache: dict[str, list[str]] = {}
|
|
10
|
+
_is_ignored_cache: dict[str, bool] = {}
|
|
11
|
+
_is_binary_cache: dict[str, bool] = {}
|
|
12
|
+
|
|
13
|
+
# --- Known File Extensions for is_binary ---
|
|
14
|
+
# Using sets for O(1) average time complexity lookups
|
|
15
|
+
TEXT_EXTENSIONS = {
|
|
16
|
+
# Code
|
|
17
|
+
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h", ".hpp",
|
|
18
|
+
".cs", ".go", ".rs", ".sh", ".bash", ".ps1", ".rb", ".php", ".swift",
|
|
19
|
+
".kt", ".kts", ".scala", ".pl", ".pm", ".tcl",
|
|
20
|
+
# Markup & Data
|
|
21
|
+
".html", ".htm", ".xml", ".css", ".scss", ".sass", ".less", ".json",
|
|
22
|
+
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".md", ".txt", ".rtf",
|
|
23
|
+
".csv", ".tsv", ".sql", ".graphql", ".gql",
|
|
24
|
+
# Config & Other
|
|
25
|
+
".gitignore", ".dockerfile", "dockerfile", ".env", ".properties", ".mdx",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
BINARY_EXTENSIONS = {
|
|
29
|
+
# Images
|
|
30
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico", ".webp", ".svg",
|
|
31
|
+
# Audio/Video
|
|
32
|
+
".mp3", ".wav", ".ogg", ".flac", ".mp4", ".avi", ".mov", ".wmv", ".mkv",
|
|
33
|
+
# Archives
|
|
34
|
+
".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz",
|
|
35
|
+
# Documents
|
|
36
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt",
|
|
37
|
+
# Executables & Compiled
|
|
38
|
+
".exe", ".dll", ".so", ".dylib", ".class", ".jar", ".pyc", ".pyd", ".whl",
|
|
39
|
+
# Databases & Other
|
|
40
|
+
".db", ".sqlite", ".sqlite3", ".db-wal", ".db-shm", ".lock",
|
|
41
|
+
".bak", ".swo", ".swp",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def _read_gitignore_patterns(gitignore_path: str) -> list[str]:
|
|
45
|
+
"""Reads patterns from a single .gitignore file and caches them."""
|
|
46
|
+
if gitignore_path in _gitignore_cache:
|
|
47
|
+
return _gitignore_cache[gitignore_path]
|
|
48
|
+
if not os.path.isfile(gitignore_path):
|
|
49
|
+
_gitignore_cache[gitignore_path] = []
|
|
50
|
+
return []
|
|
51
|
+
patterns = []
|
|
52
|
+
try:
|
|
53
|
+
with open(gitignore_path, "r", encoding="utf-8") as f:
|
|
54
|
+
for line in f:
|
|
55
|
+
stripped_line = line.strip()
|
|
56
|
+
if stripped_line and not stripped_line.startswith("#"):
|
|
57
|
+
patterns.append(stripped_line)
|
|
58
|
+
except IOError:
|
|
59
|
+
pass
|
|
60
|
+
_gitignore_cache[gitignore_path] = patterns
|
|
61
|
+
return patterns
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_ignored(
|
|
65
|
+
path: str, default_ignore_patterns: list[str], project_root: Optional[str] = None
|
|
66
|
+
) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
Checks if a path should be ignored by splitting patterns into fast (basename)
|
|
69
|
+
and slow (full path) checks, with heavy caching and optimized inner loops.
|
|
70
|
+
"""
|
|
71
|
+
path_abs = os.path.abspath(path)
|
|
72
|
+
if path_abs in _is_ignored_cache:
|
|
73
|
+
return _is_ignored_cache[path_abs]
|
|
74
|
+
|
|
75
|
+
parent_dir = os.path.dirname(path_abs)
|
|
76
|
+
if parent_dir != path_abs and _is_ignored_cache.get(parent_dir, False):
|
|
77
|
+
_is_ignored_cache[path_abs] = True
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
if project_root is None:
|
|
81
|
+
project_root = os.getcwd()
|
|
82
|
+
project_root_abs = os.path.abspath(project_root)
|
|
83
|
+
|
|
84
|
+
basename_patterns, path_patterns = get_all_patterns(
|
|
85
|
+
default_ignore_patterns, path_abs, project_root_abs
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# --- Step 1: Fast check for basename patterns ---
|
|
89
|
+
path_basename = os.path.basename(path_abs)
|
|
90
|
+
for pattern in basename_patterns:
|
|
91
|
+
if fnmatch.fnmatch(path_basename, pattern):
|
|
92
|
+
_is_ignored_cache[path_abs] = True
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
# --- Step 2: Optimized nested check for path patterns ---
|
|
96
|
+
try:
|
|
97
|
+
path_rel_to_root = os.path.relpath(path_abs, project_root_abs)
|
|
98
|
+
except ValueError:
|
|
99
|
+
_is_ignored_cache[path_abs] = False
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# Pre-calculate all path prefixes to check, avoiding re-joins in the loop.
|
|
103
|
+
path_parts = Path(path_rel_to_root).parts
|
|
104
|
+
path_prefixes = [os.path.join(*path_parts[:i]) for i in range(1, len(path_parts) + 1)]
|
|
105
|
+
|
|
106
|
+
# Pre-process patterns to remove trailing slashes once.
|
|
107
|
+
processed_path_patterns = [p.rstrip("/") for p in path_patterns]
|
|
108
|
+
|
|
109
|
+
for prefix in path_prefixes:
|
|
110
|
+
for pattern in processed_path_patterns:
|
|
111
|
+
if fnmatch.fnmatch(prefix, pattern):
|
|
112
|
+
_is_ignored_cache[path_abs] = True
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
_is_ignored_cache[path_abs] = False
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def get_all_patterns(default_ignore_patterns, path_abs, project_root_abs) -> Tuple[Set[str], Set[str]]:
|
|
119
|
+
"""
|
|
120
|
+
Gathers all applicable ignore patterns, splitting them into two sets
|
|
121
|
+
for optimized checking: one for basenames, one for full paths.
|
|
122
|
+
"""
|
|
123
|
+
basename_patterns = set()
|
|
124
|
+
path_patterns = set()
|
|
125
|
+
|
|
126
|
+
for p in default_ignore_patterns:
|
|
127
|
+
if "/" in p:
|
|
128
|
+
path_patterns.add(p)
|
|
129
|
+
else:
|
|
130
|
+
basename_patterns.add(p)
|
|
131
|
+
|
|
132
|
+
search_start_dir = (
|
|
133
|
+
path_abs if os.path.isdir(path_abs) else os.path.dirname(path_abs)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
current_dir = search_start_dir
|
|
137
|
+
while True:
|
|
138
|
+
gitignore_path = os.path.join(current_dir, ".gitignore")
|
|
139
|
+
patterns_from_file = _read_gitignore_patterns(gitignore_path)
|
|
140
|
+
|
|
141
|
+
if patterns_from_file:
|
|
142
|
+
gitignore_dir_rel = os.path.relpath(current_dir, project_root_abs)
|
|
143
|
+
if gitignore_dir_rel == ".":
|
|
144
|
+
gitignore_dir_rel = ""
|
|
145
|
+
|
|
146
|
+
for p in patterns_from_file:
|
|
147
|
+
if "/" in p:
|
|
148
|
+
# Path patterns are relative to the .gitignore file's location
|
|
149
|
+
path_patterns.add(os.path.join(gitignore_dir_rel, p.lstrip("/")))
|
|
150
|
+
else:
|
|
151
|
+
basename_patterns.add(p)
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
not current_dir.startswith(project_root_abs)
|
|
155
|
+
or current_dir == project_root_abs
|
|
156
|
+
):
|
|
157
|
+
break
|
|
158
|
+
parent = os.path.dirname(current_dir)
|
|
159
|
+
if parent == current_dir:
|
|
160
|
+
break
|
|
161
|
+
current_dir = parent
|
|
162
|
+
return basename_patterns, path_patterns
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def read_file_contents(file_path):
|
|
166
|
+
try:
|
|
167
|
+
with open(file_path, "r") as file:
|
|
168
|
+
return file.read()
|
|
169
|
+
except Exception as e:
|
|
170
|
+
print(f"Error reading {file_path}: {e}")
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def is_binary(file_path: str) -> bool:
|
|
175
|
+
"""
|
|
176
|
+
Efficiently checks if a file is binary.
|
|
177
|
+
|
|
178
|
+
The check follows a fast, multi-step process to minimize I/O:
|
|
179
|
+
1. Checks a memory cache for a previously determined result.
|
|
180
|
+
2. Checks the file extension against a list of known text file types.
|
|
181
|
+
3. Checks the file extension against a list of known binary file types.
|
|
182
|
+
4. As a last resort, reads the first 512 bytes of the file to check for
|
|
183
|
+
a null byte, a common indicator of a binary file.
|
|
184
|
+
"""
|
|
185
|
+
# Step 1: Check cache first for fastest response
|
|
186
|
+
if file_path in _is_binary_cache:
|
|
187
|
+
return _is_binary_cache[file_path]
|
|
188
|
+
|
|
189
|
+
# Step 2: Fast check based on known text/binary extensions (no I/O)
|
|
190
|
+
_, extension = os.path.splitext(file_path)
|
|
191
|
+
extension = extension.lower()
|
|
192
|
+
|
|
193
|
+
if extension in TEXT_EXTENSIONS:
|
|
194
|
+
_is_binary_cache[file_path] = False
|
|
195
|
+
return False
|
|
196
|
+
if extension in BINARY_EXTENSIONS:
|
|
197
|
+
_is_binary_cache[file_path] = True
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
# Step 3: Fallback to content analysis for unknown extensions
|
|
201
|
+
try:
|
|
202
|
+
with open(file_path, "rb") as file:
|
|
203
|
+
# Read a smaller chunk, 512 bytes is usually enough to find a null byte
|
|
204
|
+
chunk = file.read(512)
|
|
205
|
+
if b"\0" in chunk:
|
|
206
|
+
_is_binary_cache[file_path] = True
|
|
207
|
+
return True
|
|
208
|
+
# If no null byte, assume it's a text file
|
|
209
|
+
_is_binary_cache[file_path] = False
|
|
210
|
+
return False
|
|
211
|
+
except IOError:
|
|
212
|
+
# If we can't open it, treat it as binary to be safe
|
|
213
|
+
_is_binary_cache[file_path] = True
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_human_readable_size(size):
|
|
218
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
219
|
+
if size < 1024.0:
|
|
220
|
+
return f"{size:.2f} {unit}"
|
|
221
|
+
size /= 1024.0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def is_large_file(file_path, threshold=102400):
|
|
225
|
+
return os.path.getsize(file_path) > threshold
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
import ast
|
|
5
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
6
|
+
|
|
7
|
+
# --- Global Cache for tsconfig.json data ---
|
|
8
|
+
# Key: absolute path to tsconfig.json file
|
|
9
|
+
# Value: Tuple (absolute_base_url: Optional[str], alias_paths_map: Dict[str, List[str]])
|
|
10
|
+
_tsconfig_configs_cache: Dict[str, Tuple[Optional[str], Dict[str, List[str]]]] = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# --- TypeScript Alias and Import Resolution ---
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_relevant_tsconfig_path(
|
|
17
|
+
file_path_abs: str, project_root_abs: str
|
|
18
|
+
) -> Optional[str]:
|
|
19
|
+
"""
|
|
20
|
+
Finds the most relevant tsconfig.json by searching upwards from the file's directory,
|
|
21
|
+
stopping at project_root_abs.
|
|
22
|
+
Searches for 'tsconfig.json' first, then 'tsconfig.*.json' in each directory.
|
|
23
|
+
"""
|
|
24
|
+
current_dir = os.path.dirname(os.path.normpath(file_path_abs))
|
|
25
|
+
project_root_abs_norm = os.path.normpath(project_root_abs)
|
|
26
|
+
|
|
27
|
+
while current_dir.startswith(project_root_abs_norm) and len(current_dir) >= len(
|
|
28
|
+
project_root_abs_norm
|
|
29
|
+
):
|
|
30
|
+
potential_tsconfig = os.path.join(current_dir, "tsconfig.json")
|
|
31
|
+
if os.path.isfile(potential_tsconfig):
|
|
32
|
+
return os.path.normpath(potential_tsconfig)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
variant_tsconfigs = sorted(
|
|
36
|
+
[
|
|
37
|
+
f
|
|
38
|
+
for f in os.listdir(current_dir)
|
|
39
|
+
if f.startswith("tsconfig.")
|
|
40
|
+
and f.endswith(".json")
|
|
41
|
+
and os.path.isfile(os.path.join(current_dir, f))
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
if variant_tsconfigs:
|
|
45
|
+
return os.path.normpath(os.path.join(current_dir, variant_tsconfigs[0]))
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
if current_dir == project_root_abs_norm:
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
parent_dir = os.path.dirname(current_dir)
|
|
53
|
+
if parent_dir == current_dir:
|
|
54
|
+
break
|
|
55
|
+
current_dir = parent_dir
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_tsconfig_config(
|
|
60
|
+
tsconfig_path_abs: str,
|
|
61
|
+
) -> Tuple[Optional[str], Dict[str, List[str]]]:
|
|
62
|
+
"""
|
|
63
|
+
Loads baseUrl and paths from a specific tsconfig.json.
|
|
64
|
+
Caches results.
|
|
65
|
+
Returns (absolute_base_url, paths_map).
|
|
66
|
+
"""
|
|
67
|
+
if tsconfig_path_abs in _tsconfig_configs_cache:
|
|
68
|
+
return _tsconfig_configs_cache[tsconfig_path_abs]
|
|
69
|
+
|
|
70
|
+
if not os.path.isfile(tsconfig_path_abs):
|
|
71
|
+
_tsconfig_configs_cache[tsconfig_path_abs] = (None, {})
|
|
72
|
+
return None, {}
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with open(tsconfig_path_abs, "r", encoding="utf-8") as f:
|
|
76
|
+
content = f.read()
|
|
77
|
+
content = re.sub(r"//.*?\n", "\n", content)
|
|
78
|
+
content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
|
|
79
|
+
config = json.loads(content)
|
|
80
|
+
|
|
81
|
+
compiler_options = config.get("compilerOptions", {})
|
|
82
|
+
tsconfig_dir = os.path.dirname(tsconfig_path_abs)
|
|
83
|
+
base_url_from_config = compiler_options.get("baseUrl", ".")
|
|
84
|
+
abs_base_url = os.path.normpath(
|
|
85
|
+
os.path.join(tsconfig_dir, base_url_from_config)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
paths = compiler_options.get("paths", {})
|
|
89
|
+
processed_paths = {
|
|
90
|
+
key: (val if isinstance(val, list) else [val]) for key, val in paths.items()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# print(f"DEBUG: Loaded config from {os.path.relpath(tsconfig_path_abs)}: effective abs_baseUrl='{abs_base_url}', {len(processed_paths)} path alias(es).")
|
|
94
|
+
_tsconfig_configs_cache[tsconfig_path_abs] = (abs_base_url, processed_paths)
|
|
95
|
+
return abs_base_url, processed_paths
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"Warning: Could not parse {os.path.relpath(tsconfig_path_abs)}: {e}")
|
|
98
|
+
_tsconfig_configs_cache[tsconfig_path_abs] = (None, {})
|
|
99
|
+
return None, {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _probe_ts_path_candidates(candidate_base_path_abs: str) -> Optional[str]:
|
|
103
|
+
"""
|
|
104
|
+
Given a candidate base absolute path, tries to find a corresponding file.
|
|
105
|
+
"""
|
|
106
|
+
possible_extensions = [".ts", ".tsx", ".js", ".jsx", ".json"]
|
|
107
|
+
|
|
108
|
+
if os.path.isfile(candidate_base_path_abs):
|
|
109
|
+
return candidate_base_path_abs
|
|
110
|
+
|
|
111
|
+
stem, original_ext = os.path.splitext(candidate_base_path_abs)
|
|
112
|
+
base_for_ext_check = (
|
|
113
|
+
stem if original_ext.lower() in possible_extensions else candidate_base_path_abs
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
for ext in possible_extensions:
|
|
117
|
+
path_with_ext = base_for_ext_check + ext
|
|
118
|
+
if os.path.isfile(path_with_ext):
|
|
119
|
+
return path_with_ext
|
|
120
|
+
|
|
121
|
+
if os.path.isdir(base_for_ext_check):
|
|
122
|
+
for ext in possible_extensions:
|
|
123
|
+
index_file_path = os.path.join(base_for_ext_check, "index" + ext)
|
|
124
|
+
if os.path.isfile(index_file_path):
|
|
125
|
+
return index_file_path
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve_ts_import_path(
|
|
130
|
+
import_str: str,
|
|
131
|
+
current_file_dir_abs: str,
|
|
132
|
+
abs_base_url: Optional[str],
|
|
133
|
+
alias_map: Dict[str, List[str]],
|
|
134
|
+
) -> Optional[str]:
|
|
135
|
+
"""
|
|
136
|
+
Resolves a TypeScript import string to an absolute file path.
|
|
137
|
+
"""
|
|
138
|
+
candidate_targets_abs: List[str] = []
|
|
139
|
+
sorted_alias_keys = sorted(alias_map.keys(), key=len, reverse=True)
|
|
140
|
+
alias_matched_and_resolved = False
|
|
141
|
+
|
|
142
|
+
for alias_pattern in sorted_alias_keys:
|
|
143
|
+
alias_prefix_pattern = alias_pattern.replace("/*", "")
|
|
144
|
+
if import_str.startswith(alias_prefix_pattern):
|
|
145
|
+
import_suffix = import_str[len(alias_prefix_pattern) :]
|
|
146
|
+
for mapping_path_template_list in alias_map[alias_pattern]:
|
|
147
|
+
for mapping_path_template in (
|
|
148
|
+
mapping_path_template_list
|
|
149
|
+
if isinstance(mapping_path_template_list, list)
|
|
150
|
+
else [mapping_path_template_list]
|
|
151
|
+
):
|
|
152
|
+
if "/*" in alias_pattern:
|
|
153
|
+
resolved_relative_to_base = mapping_path_template.replace(
|
|
154
|
+
"*", import_suffix, 1
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
resolved_relative_to_base = mapping_path_template
|
|
158
|
+
if abs_base_url:
|
|
159
|
+
abs_candidate = os.path.normpath(
|
|
160
|
+
os.path.join(abs_base_url, resolved_relative_to_base)
|
|
161
|
+
)
|
|
162
|
+
candidate_targets_abs.append(abs_candidate)
|
|
163
|
+
else:
|
|
164
|
+
print(
|
|
165
|
+
f"Warning: TS Alias '{alias_pattern}' used, but no abs_base_url for context of '{current_file_dir_abs}'."
|
|
166
|
+
)
|
|
167
|
+
if candidate_targets_abs:
|
|
168
|
+
alias_matched_and_resolved = True
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if not alias_matched_and_resolved and import_str.startswith("."):
|
|
172
|
+
abs_candidate = os.path.normpath(os.path.join(current_file_dir_abs, import_str))
|
|
173
|
+
candidate_targets_abs.append(abs_candidate)
|
|
174
|
+
elif (
|
|
175
|
+
not alias_matched_and_resolved
|
|
176
|
+
and abs_base_url
|
|
177
|
+
and not import_str.startswith(".")
|
|
178
|
+
):
|
|
179
|
+
abs_candidate = os.path.normpath(os.path.join(abs_base_url, import_str))
|
|
180
|
+
candidate_targets_abs.append(abs_candidate)
|
|
181
|
+
|
|
182
|
+
for cand_abs_path in candidate_targets_abs:
|
|
183
|
+
resolved_file = _probe_ts_path_candidates(cand_abs_path)
|
|
184
|
+
if resolved_file:
|
|
185
|
+
return resolved_file
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_typescript_imports(
|
|
190
|
+
file_content: str, file_path_abs: str, project_root_abs: str
|
|
191
|
+
) -> Set[str]:
|
|
192
|
+
resolved_imports_abs_paths = set()
|
|
193
|
+
relevant_tsconfig_abs_path = find_relevant_tsconfig_path(
|
|
194
|
+
file_path_abs, project_root_abs
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
abs_base_url, alias_map = None, {}
|
|
198
|
+
if relevant_tsconfig_abs_path:
|
|
199
|
+
abs_base_url, alias_map = load_tsconfig_config(relevant_tsconfig_abs_path)
|
|
200
|
+
else:
|
|
201
|
+
# print(f"Warning: No tsconfig.json found for {os.path.relpath(file_path_abs, project_root_abs)}. Import resolution might be limited.")
|
|
202
|
+
abs_base_url = project_root_abs
|
|
203
|
+
|
|
204
|
+
import_regex = re.compile(
|
|
205
|
+
r"""
|
|
206
|
+
(?:import|export)
|
|
207
|
+
(?:\s+(?:type\s+)?(?:[\w*{}\s,\[\]:\."'`-]+)\s+from)?
|
|
208
|
+
\s*['"`]([^'"\n`]+?)['"`]
|
|
209
|
+
|require\s*\(\s*['"`]([^'"\n`]+?)['"`]\s*\)
|
|
210
|
+
|import\s*\(\s*['"`]([^'"\n`]+?)['"`]\s*\)
|
|
211
|
+
""",
|
|
212
|
+
re.VERBOSE | re.MULTILINE,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
current_file_dir_abs = os.path.dirname(file_path_abs)
|
|
216
|
+
|
|
217
|
+
for match in import_regex.finditer(file_content):
|
|
218
|
+
import_str_candidate = next((g for g in match.groups() if g is not None), None)
|
|
219
|
+
if import_str_candidate:
|
|
220
|
+
is_likely_external = (
|
|
221
|
+
not import_str_candidate.startswith((".", "/"))
|
|
222
|
+
and not any(
|
|
223
|
+
import_str_candidate.startswith(alias_pattern.replace("/*", ""))
|
|
224
|
+
for alias_pattern in alias_map
|
|
225
|
+
)
|
|
226
|
+
and not (
|
|
227
|
+
abs_base_url
|
|
228
|
+
and os.path.exists(os.path.join(abs_base_url, import_str_candidate))
|
|
229
|
+
)
|
|
230
|
+
and (
|
|
231
|
+
import_str_candidate.count("/") == 0
|
|
232
|
+
or (
|
|
233
|
+
import_str_candidate.startswith("@")
|
|
234
|
+
and import_str_candidate.count("/") == 1
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
and "." not in import_str_candidate.split("/")[0]
|
|
238
|
+
)
|
|
239
|
+
if is_likely_external:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
resolved_abs_path = resolve_ts_import_path(
|
|
243
|
+
import_str_candidate, current_file_dir_abs, abs_base_url, alias_map
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if resolved_abs_path:
|
|
247
|
+
norm_resolved_path = os.path.normpath(resolved_abs_path)
|
|
248
|
+
if norm_resolved_path.startswith(os.path.normpath(project_root_abs)):
|
|
249
|
+
resolved_imports_abs_paths.add(norm_resolved_path)
|
|
250
|
+
return resolved_imports_abs_paths
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- Python Import Resolution ---
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def resolve_python_import(
|
|
257
|
+
module_name_parts: List[str],
|
|
258
|
+
current_file_dir_abs: str,
|
|
259
|
+
project_root_abs: str,
|
|
260
|
+
level: int,
|
|
261
|
+
) -> Optional[str]:
|
|
262
|
+
base_path_to_search = ""
|
|
263
|
+
if level > 0:
|
|
264
|
+
base_path_to_search = current_file_dir_abs
|
|
265
|
+
for _ in range(level - 1):
|
|
266
|
+
base_path_to_search = os.path.dirname(base_path_to_search)
|
|
267
|
+
else:
|
|
268
|
+
base_path_to_search = project_root_abs
|
|
269
|
+
|
|
270
|
+
candidate_rel_path = os.path.join(*module_name_parts)
|
|
271
|
+
potential_abs_path = os.path.join(base_path_to_search, candidate_rel_path)
|
|
272
|
+
|
|
273
|
+
py_file = potential_abs_path + ".py"
|
|
274
|
+
if os.path.isfile(py_file):
|
|
275
|
+
return os.path.normpath(py_file)
|
|
276
|
+
|
|
277
|
+
init_file = os.path.join(potential_abs_path, "__init__.py")
|
|
278
|
+
if os.path.isdir(potential_abs_path) and os.path.isfile(init_file):
|
|
279
|
+
return os.path.normpath(init_file)
|
|
280
|
+
|
|
281
|
+
if level == 0 and base_path_to_search == project_root_abs:
|
|
282
|
+
src_base_path = os.path.join(project_root_abs, "src")
|
|
283
|
+
if os.path.isdir(src_base_path):
|
|
284
|
+
potential_abs_path_src = os.path.join(src_base_path, candidate_rel_path)
|
|
285
|
+
py_file_src = potential_abs_path_src + ".py"
|
|
286
|
+
if os.path.isfile(py_file_src):
|
|
287
|
+
return os.path.normpath(py_file_src)
|
|
288
|
+
init_file_src = os.path.join(potential_abs_path_src, "__init__.py")
|
|
289
|
+
if os.path.isdir(potential_abs_path_src) and os.path.isfile(init_file_src):
|
|
290
|
+
return os.path.normpath(init_file_src)
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def parse_python_imports(
|
|
295
|
+
file_content: str, file_path_abs: str, project_root_abs: str
|
|
296
|
+
) -> Set[str]:
|
|
297
|
+
resolved_imports = set()
|
|
298
|
+
current_file_dir_abs = os.path.dirname(file_path_abs)
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
tree = ast.parse(file_content, filename=file_path_abs)
|
|
302
|
+
except SyntaxError:
|
|
303
|
+
# print(f"Warning: Syntax error in {file_path_abs}, cannot parse Python imports.")
|
|
304
|
+
return resolved_imports
|
|
305
|
+
|
|
306
|
+
for node in ast.walk(tree):
|
|
307
|
+
if isinstance(node, ast.Import):
|
|
308
|
+
for alias in node.names:
|
|
309
|
+
module_parts = alias.name.split(".")
|
|
310
|
+
resolved = resolve_python_import(
|
|
311
|
+
module_parts, current_file_dir_abs, project_root_abs, level=0
|
|
312
|
+
)
|
|
313
|
+
if (
|
|
314
|
+
resolved
|
|
315
|
+
and os.path.exists(resolved)
|
|
316
|
+
and os.path.normpath(resolved).startswith(
|
|
317
|
+
os.path.normpath(project_root_abs)
|
|
318
|
+
)
|
|
319
|
+
):
|
|
320
|
+
resolved_imports.add(os.path.normpath(resolved))
|
|
321
|
+
elif isinstance(node, ast.ImportFrom):
|
|
322
|
+
level_to_resolve = node.level
|
|
323
|
+
if node.module:
|
|
324
|
+
module_parts = node.module.split(".")
|
|
325
|
+
resolved = resolve_python_import(
|
|
326
|
+
module_parts,
|
|
327
|
+
current_file_dir_abs,
|
|
328
|
+
project_root_abs,
|
|
329
|
+
level_to_resolve,
|
|
330
|
+
)
|
|
331
|
+
if (
|
|
332
|
+
resolved
|
|
333
|
+
and os.path.exists(resolved)
|
|
334
|
+
and os.path.normpath(resolved).startswith(
|
|
335
|
+
os.path.normpath(project_root_abs)
|
|
336
|
+
)
|
|
337
|
+
):
|
|
338
|
+
resolved_imports.add(os.path.normpath(resolved))
|
|
339
|
+
else:
|
|
340
|
+
for alias in node.names:
|
|
341
|
+
item_name_parts = alias.name.split(".")
|
|
342
|
+
resolved = resolve_python_import(
|
|
343
|
+
item_name_parts,
|
|
344
|
+
current_file_dir_abs,
|
|
345
|
+
project_root_abs,
|
|
346
|
+
level=level_to_resolve,
|
|
347
|
+
)
|
|
348
|
+
if (
|
|
349
|
+
resolved
|
|
350
|
+
and os.path.exists(resolved)
|
|
351
|
+
and os.path.normpath(resolved).startswith(
|
|
352
|
+
os.path.normpath(project_root_abs)
|
|
353
|
+
)
|
|
354
|
+
):
|
|
355
|
+
resolved_imports.add(os.path.normpath(resolved))
|
|
356
|
+
return resolved_imports
|