tunacode-cli 0.1.21__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 tunacode-cli might be problematic. Click here for more details.
- tunacode/__init__.py +0 -0
- tunacode/cli/textual_repl.tcss +283 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +45 -0
- tunacode/configuration/models.py +147 -0
- tunacode/configuration/models_registry.json +1 -0
- tunacode/configuration/pricing.py +74 -0
- tunacode/configuration/settings.py +35 -0
- tunacode/constants.py +227 -0
- tunacode/core/__init__.py +6 -0
- tunacode/core/agents/__init__.py +39 -0
- tunacode/core/agents/agent_components/__init__.py +48 -0
- tunacode/core/agents/agent_components/agent_config.py +441 -0
- tunacode/core/agents/agent_components/agent_helpers.py +290 -0
- tunacode/core/agents/agent_components/message_handler.py +99 -0
- tunacode/core/agents/agent_components/node_processor.py +477 -0
- tunacode/core/agents/agent_components/response_state.py +129 -0
- tunacode/core/agents/agent_components/result_wrapper.py +51 -0
- tunacode/core/agents/agent_components/state_transition.py +112 -0
- tunacode/core/agents/agent_components/streaming.py +271 -0
- tunacode/core/agents/agent_components/task_completion.py +40 -0
- tunacode/core/agents/agent_components/tool_buffer.py +44 -0
- tunacode/core/agents/agent_components/tool_executor.py +101 -0
- tunacode/core/agents/agent_components/truncation_checker.py +37 -0
- tunacode/core/agents/delegation_tools.py +109 -0
- tunacode/core/agents/main.py +545 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/research_agent.py +231 -0
- tunacode/core/compaction.py +218 -0
- tunacode/core/prompting/__init__.py +27 -0
- tunacode/core/prompting/loader.py +66 -0
- tunacode/core/prompting/prompting_engine.py +98 -0
- tunacode/core/prompting/sections.py +50 -0
- tunacode/core/prompting/templates.py +69 -0
- tunacode/core/state.py +409 -0
- tunacode/exceptions.py +313 -0
- tunacode/indexing/__init__.py +5 -0
- tunacode/indexing/code_index.py +432 -0
- tunacode/indexing/constants.py +86 -0
- tunacode/lsp/__init__.py +112 -0
- tunacode/lsp/client.py +351 -0
- tunacode/lsp/diagnostics.py +19 -0
- tunacode/lsp/servers.py +101 -0
- tunacode/prompts/default_prompt.md +952 -0
- tunacode/prompts/research/sections/agent_role.xml +5 -0
- tunacode/prompts/research/sections/constraints.xml +14 -0
- tunacode/prompts/research/sections/output_format.xml +57 -0
- tunacode/prompts/research/sections/tool_use.xml +23 -0
- tunacode/prompts/sections/advanced_patterns.xml +255 -0
- tunacode/prompts/sections/agent_role.xml +8 -0
- tunacode/prompts/sections/completion.xml +10 -0
- tunacode/prompts/sections/critical_rules.xml +37 -0
- tunacode/prompts/sections/examples.xml +220 -0
- tunacode/prompts/sections/output_style.xml +94 -0
- tunacode/prompts/sections/parallel_exec.xml +105 -0
- tunacode/prompts/sections/search_pattern.xml +100 -0
- tunacode/prompts/sections/system_info.xml +6 -0
- tunacode/prompts/sections/tool_use.xml +84 -0
- tunacode/prompts/sections/user_instructions.xml +3 -0
- tunacode/py.typed +0 -0
- tunacode/templates/__init__.py +5 -0
- tunacode/templates/loader.py +15 -0
- tunacode/tools/__init__.py +10 -0
- tunacode/tools/authorization/__init__.py +29 -0
- tunacode/tools/authorization/context.py +32 -0
- tunacode/tools/authorization/factory.py +20 -0
- tunacode/tools/authorization/handler.py +58 -0
- tunacode/tools/authorization/notifier.py +35 -0
- tunacode/tools/authorization/policy.py +19 -0
- tunacode/tools/authorization/requests.py +119 -0
- tunacode/tools/authorization/rules.py +72 -0
- tunacode/tools/bash.py +222 -0
- tunacode/tools/decorators.py +213 -0
- tunacode/tools/glob.py +353 -0
- tunacode/tools/grep.py +468 -0
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +158 -0
- tunacode/tools/grep_components/result_formatter.py +87 -0
- tunacode/tools/grep_components/search_result.py +34 -0
- tunacode/tools/list_dir.py +205 -0
- tunacode/tools/prompts/bash_prompt.xml +10 -0
- tunacode/tools/prompts/glob_prompt.xml +7 -0
- tunacode/tools/prompts/grep_prompt.xml +10 -0
- tunacode/tools/prompts/list_dir_prompt.xml +7 -0
- tunacode/tools/prompts/read_file_prompt.xml +9 -0
- tunacode/tools/prompts/todoclear_prompt.xml +12 -0
- tunacode/tools/prompts/todoread_prompt.xml +16 -0
- tunacode/tools/prompts/todowrite_prompt.xml +28 -0
- tunacode/tools/prompts/update_file_prompt.xml +9 -0
- tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
- tunacode/tools/prompts/write_file_prompt.xml +7 -0
- tunacode/tools/react.py +111 -0
- tunacode/tools/read_file.py +68 -0
- tunacode/tools/todo.py +222 -0
- tunacode/tools/update_file.py +62 -0
- tunacode/tools/utils/__init__.py +1 -0
- tunacode/tools/utils/ripgrep.py +311 -0
- tunacode/tools/utils/text_match.py +352 -0
- tunacode/tools/web_fetch.py +245 -0
- tunacode/tools/write_file.py +34 -0
- tunacode/tools/xml_helper.py +34 -0
- tunacode/types/__init__.py +166 -0
- tunacode/types/base.py +94 -0
- tunacode/types/callbacks.py +53 -0
- tunacode/types/dataclasses.py +121 -0
- tunacode/types/pydantic_ai.py +31 -0
- tunacode/types/state.py +122 -0
- tunacode/ui/__init__.py +6 -0
- tunacode/ui/app.py +542 -0
- tunacode/ui/commands/__init__.py +430 -0
- tunacode/ui/components/__init__.py +1 -0
- tunacode/ui/headless/__init__.py +5 -0
- tunacode/ui/headless/output.py +72 -0
- tunacode/ui/main.py +252 -0
- tunacode/ui/renderers/__init__.py +41 -0
- tunacode/ui/renderers/errors.py +197 -0
- tunacode/ui/renderers/panels.py +550 -0
- tunacode/ui/renderers/search.py +314 -0
- tunacode/ui/renderers/tools/__init__.py +21 -0
- tunacode/ui/renderers/tools/bash.py +247 -0
- tunacode/ui/renderers/tools/diagnostics.py +186 -0
- tunacode/ui/renderers/tools/glob.py +226 -0
- tunacode/ui/renderers/tools/grep.py +228 -0
- tunacode/ui/renderers/tools/list_dir.py +198 -0
- tunacode/ui/renderers/tools/read_file.py +226 -0
- tunacode/ui/renderers/tools/research.py +294 -0
- tunacode/ui/renderers/tools/update_file.py +237 -0
- tunacode/ui/renderers/tools/web_fetch.py +182 -0
- tunacode/ui/repl_support.py +226 -0
- tunacode/ui/screens/__init__.py +16 -0
- tunacode/ui/screens/model_picker.py +303 -0
- tunacode/ui/screens/session_picker.py +181 -0
- tunacode/ui/screens/setup.py +218 -0
- tunacode/ui/screens/theme_picker.py +90 -0
- tunacode/ui/screens/update_confirm.py +69 -0
- tunacode/ui/shell_runner.py +129 -0
- tunacode/ui/styles/layout.tcss +98 -0
- tunacode/ui/styles/modals.tcss +38 -0
- tunacode/ui/styles/panels.tcss +81 -0
- tunacode/ui/styles/theme-nextstep.tcss +303 -0
- tunacode/ui/styles/widgets.tcss +33 -0
- tunacode/ui/styles.py +18 -0
- tunacode/ui/widgets/__init__.py +23 -0
- tunacode/ui/widgets/command_autocomplete.py +62 -0
- tunacode/ui/widgets/editor.py +402 -0
- tunacode/ui/widgets/file_autocomplete.py +47 -0
- tunacode/ui/widgets/messages.py +46 -0
- tunacode/ui/widgets/resource_bar.py +182 -0
- tunacode/ui/widgets/status_bar.py +98 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/config/__init__.py +13 -0
- tunacode/utils/config/user_configuration.py +91 -0
- tunacode/utils/messaging/__init__.py +10 -0
- tunacode/utils/messaging/message_utils.py +34 -0
- tunacode/utils/messaging/token_counter.py +77 -0
- tunacode/utils/parsing/__init__.py +13 -0
- tunacode/utils/parsing/command_parser.py +55 -0
- tunacode/utils/parsing/json_utils.py +188 -0
- tunacode/utils/parsing/retry.py +146 -0
- tunacode/utils/parsing/tool_parser.py +267 -0
- tunacode/utils/security/__init__.py +15 -0
- tunacode/utils/security/command.py +106 -0
- tunacode/utils/system/__init__.py +25 -0
- tunacode/utils/system/gitignore.py +155 -0
- tunacode/utils/system/paths.py +190 -0
- tunacode/utils/ui/__init__.py +9 -0
- tunacode/utils/ui/file_filter.py +135 -0
- tunacode/utils/ui/helpers.py +24 -0
- tunacode_cli-0.1.21.dist-info/METADATA +170 -0
- tunacode_cli-0.1.21.dist-info/RECORD +174 -0
- tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
- tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
- tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Fast in-memory code index for efficient file lookups."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from tunacode.indexing.constants import (
|
|
11
|
+
IGNORE_DIRS,
|
|
12
|
+
INDEXED_EXTENSIONS,
|
|
13
|
+
PRIORITY_DIRS,
|
|
14
|
+
QUICK_INDEX_THRESHOLD,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CodeIndex:
|
|
19
|
+
"""Fast in-memory code index for repository file lookups.
|
|
20
|
+
|
|
21
|
+
This index provides efficient file discovery without relying on
|
|
22
|
+
grep searches that can timeout in large repositories.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
_instance: Optional["CodeIndex"] = None
|
|
26
|
+
_instance_lock = threading.RLock()
|
|
27
|
+
|
|
28
|
+
def __init__(self, root_dir: str | None = None):
|
|
29
|
+
"""Initialize the code index.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
root_dir: Root directory to index. Defaults to current directory.
|
|
33
|
+
"""
|
|
34
|
+
self.root_dir = Path(root_dir or os.getcwd()).resolve()
|
|
35
|
+
self._lock = threading.RLock()
|
|
36
|
+
|
|
37
|
+
# Primary indices
|
|
38
|
+
self._basename_to_paths: dict[str, list[Path]] = defaultdict(list)
|
|
39
|
+
self._path_to_imports: dict[Path, set[str]] = {}
|
|
40
|
+
self._all_files: set[Path] = set()
|
|
41
|
+
|
|
42
|
+
# Symbol indices for common patterns
|
|
43
|
+
self._class_definitions: dict[str, list[Path]] = defaultdict(list)
|
|
44
|
+
self._function_definitions: dict[str, list[Path]] = defaultdict(list)
|
|
45
|
+
|
|
46
|
+
# Cache for directory contents
|
|
47
|
+
self._dir_cache: dict[Path, list[Path]] = {}
|
|
48
|
+
|
|
49
|
+
# Cache freshness tracking
|
|
50
|
+
self._cache_timestamps: dict[Path, float] = {}
|
|
51
|
+
self._cache_ttl = 5.0 # 5 seconds TTL for directory cache
|
|
52
|
+
|
|
53
|
+
self._indexed = False
|
|
54
|
+
self._partial_indexed = False
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def get_instance(cls, root_dir: str | None = None) -> "CodeIndex":
|
|
58
|
+
"""Get the singleton CodeIndex instance.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
root_dir: Root directory to index. Only used on first call.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The singleton CodeIndex instance.
|
|
65
|
+
"""
|
|
66
|
+
if cls._instance is None:
|
|
67
|
+
with cls._instance_lock:
|
|
68
|
+
if cls._instance is None:
|
|
69
|
+
cls._instance = cls(root_dir)
|
|
70
|
+
return cls._instance
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def reset_instance(cls) -> None:
|
|
74
|
+
"""Reset the singleton instance (for testing)."""
|
|
75
|
+
with cls._instance_lock:
|
|
76
|
+
cls._instance = None
|
|
77
|
+
|
|
78
|
+
def get_directory_contents(self, path: Path) -> list[str]:
|
|
79
|
+
"""Get cached directory contents if available and fresh.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
path: Directory path to check
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of filenames in directory, empty list if not cached/stale
|
|
86
|
+
"""
|
|
87
|
+
with self._lock:
|
|
88
|
+
if path not in self._dir_cache:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
if not self.is_cache_fresh(path):
|
|
92
|
+
# Remove stale entry
|
|
93
|
+
self._dir_cache.pop(path, None)
|
|
94
|
+
self._cache_timestamps.pop(path, None)
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
# Return just the filenames, not Path objects
|
|
98
|
+
return [p.name for p in self._dir_cache[path]]
|
|
99
|
+
|
|
100
|
+
def is_cache_fresh(self, path: Path) -> bool:
|
|
101
|
+
"""Check if cached directory data is still fresh.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: Directory path to check
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True if cache is fresh, False if stale or missing
|
|
108
|
+
"""
|
|
109
|
+
if path not in self._cache_timestamps:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
age = time.time() - self._cache_timestamps[path]
|
|
113
|
+
return age < self._cache_ttl
|
|
114
|
+
|
|
115
|
+
def update_directory_cache(self, path: Path, entries: list[str]) -> None:
|
|
116
|
+
"""Update the directory cache with fresh data.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
path: Directory path
|
|
120
|
+
entries: List of filenames in the directory
|
|
121
|
+
"""
|
|
122
|
+
with self._lock:
|
|
123
|
+
# Convert filenames back to Path objects for internal storage
|
|
124
|
+
self._dir_cache[path] = [Path(path) / entry for entry in entries]
|
|
125
|
+
self._cache_timestamps[path] = time.time()
|
|
126
|
+
|
|
127
|
+
def build_index(self, force: bool = False) -> None:
|
|
128
|
+
"""Build the file index for the repository.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
force: Force rebuild even if already indexed.
|
|
132
|
+
"""
|
|
133
|
+
with self._lock:
|
|
134
|
+
if self._indexed and not force:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
self._clear_indices()
|
|
138
|
+
|
|
139
|
+
self._scan_directory(self.root_dir)
|
|
140
|
+
self._indexed = True
|
|
141
|
+
|
|
142
|
+
def _clear_indices(self) -> None:
|
|
143
|
+
"""Clear all indices."""
|
|
144
|
+
self._basename_to_paths.clear()
|
|
145
|
+
self._path_to_imports.clear()
|
|
146
|
+
self._all_files.clear()
|
|
147
|
+
self._class_definitions.clear()
|
|
148
|
+
self._function_definitions.clear()
|
|
149
|
+
self._dir_cache.clear()
|
|
150
|
+
self._cache_timestamps.clear()
|
|
151
|
+
|
|
152
|
+
def quick_count(self) -> int:
|
|
153
|
+
"""Fast file count without full indexing.
|
|
154
|
+
|
|
155
|
+
Uses os.scandir for speed and exits early at threshold.
|
|
156
|
+
Does not acquire lock - read-only filesystem scan.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
File count, capped at QUICK_INDEX_THRESHOLD + 1.
|
|
160
|
+
"""
|
|
161
|
+
count = 0
|
|
162
|
+
stack = [self.root_dir]
|
|
163
|
+
|
|
164
|
+
while stack and count <= QUICK_INDEX_THRESHOLD:
|
|
165
|
+
current = stack.pop()
|
|
166
|
+
try:
|
|
167
|
+
for entry in os.scandir(current):
|
|
168
|
+
if entry.is_dir(follow_symlinks=False):
|
|
169
|
+
if entry.name not in IGNORE_DIRS and not entry.name.startswith("."):
|
|
170
|
+
stack.append(Path(entry.path))
|
|
171
|
+
elif entry.is_file(follow_symlinks=False):
|
|
172
|
+
ext = Path(entry.name).suffix.lower()
|
|
173
|
+
if ext in INDEXED_EXTENSIONS:
|
|
174
|
+
count += 1
|
|
175
|
+
if count > QUICK_INDEX_THRESHOLD:
|
|
176
|
+
break
|
|
177
|
+
except (PermissionError, OSError):
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
return count
|
|
181
|
+
|
|
182
|
+
def build_priority_index(self) -> int:
|
|
183
|
+
"""Build index for priority directories only.
|
|
184
|
+
|
|
185
|
+
Indexes top-level files and PRIORITY_DIRS subdirectories.
|
|
186
|
+
Sets _partial_indexed = True to indicate background expansion needed.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Number of files indexed.
|
|
190
|
+
"""
|
|
191
|
+
with self._lock:
|
|
192
|
+
self._clear_indices()
|
|
193
|
+
|
|
194
|
+
# Index top-level files only (not subdirectories)
|
|
195
|
+
for entry in os.scandir(self.root_dir):
|
|
196
|
+
if entry.is_file(follow_symlinks=False):
|
|
197
|
+
file_path = Path(entry.path)
|
|
198
|
+
if self._should_index_file(file_path):
|
|
199
|
+
self._index_file(file_path)
|
|
200
|
+
|
|
201
|
+
# Index priority subdirectories fully
|
|
202
|
+
for name in PRIORITY_DIRS:
|
|
203
|
+
priority_path = self.root_dir / name
|
|
204
|
+
if priority_path.is_dir():
|
|
205
|
+
self._scan_directory(priority_path)
|
|
206
|
+
|
|
207
|
+
self._partial_indexed = True
|
|
208
|
+
self._indexed = False
|
|
209
|
+
return len(self._all_files)
|
|
210
|
+
|
|
211
|
+
def expand_index(self) -> None:
|
|
212
|
+
"""Expand partial index to full index.
|
|
213
|
+
|
|
214
|
+
Safe to call in background. Only runs if _partial_indexed is True.
|
|
215
|
+
Scans remaining non-priority directories.
|
|
216
|
+
"""
|
|
217
|
+
with self._lock:
|
|
218
|
+
if not self._partial_indexed:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
# Scan remaining directories (non-priority)
|
|
222
|
+
for entry in os.scandir(self.root_dir):
|
|
223
|
+
if entry.is_dir(follow_symlinks=False):
|
|
224
|
+
dir_name = entry.name
|
|
225
|
+
if dir_name in IGNORE_DIRS or dir_name.startswith("."):
|
|
226
|
+
continue
|
|
227
|
+
if dir_name not in PRIORITY_DIRS:
|
|
228
|
+
self._scan_directory(Path(entry.path))
|
|
229
|
+
|
|
230
|
+
self._partial_indexed = False
|
|
231
|
+
self._indexed = True
|
|
232
|
+
|
|
233
|
+
def _should_ignore_path(self, path: Path) -> bool:
|
|
234
|
+
"""Check if a path should be ignored during indexing."""
|
|
235
|
+
# Check against ignore patterns
|
|
236
|
+
parts = path.parts
|
|
237
|
+
for part in parts:
|
|
238
|
+
if part in IGNORE_DIRS:
|
|
239
|
+
return True
|
|
240
|
+
if part.startswith(".") and part != ".":
|
|
241
|
+
# Skip hidden directories except current directory
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def _scan_directory(self, directory: Path) -> None:
|
|
247
|
+
"""Recursively scan a directory and index files."""
|
|
248
|
+
if self._should_ignore_path(directory):
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
entries = list(directory.iterdir())
|
|
253
|
+
file_list = []
|
|
254
|
+
|
|
255
|
+
for entry in entries:
|
|
256
|
+
if entry.is_dir():
|
|
257
|
+
self._scan_directory(entry)
|
|
258
|
+
elif entry.is_file() and self._should_index_file(entry):
|
|
259
|
+
self._index_file(entry)
|
|
260
|
+
file_list.append(entry)
|
|
261
|
+
|
|
262
|
+
# Cache directory contents with timestamp
|
|
263
|
+
self._dir_cache[directory] = file_list
|
|
264
|
+
self._cache_timestamps[directory] = time.time()
|
|
265
|
+
|
|
266
|
+
except PermissionError:
|
|
267
|
+
pass
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
def _should_index_file(self, file_path: Path) -> bool:
|
|
272
|
+
"""Check if a file should be indexed."""
|
|
273
|
+
# Check extension
|
|
274
|
+
if file_path.suffix.lower() not in INDEXED_EXTENSIONS:
|
|
275
|
+
# Also index files with no extension if they might be scripts
|
|
276
|
+
if file_path.suffix == "":
|
|
277
|
+
# Check for shebang or common script names
|
|
278
|
+
name = file_path.name.lower()
|
|
279
|
+
if name in {"makefile", "dockerfile", "jenkinsfile", "rakefile"}:
|
|
280
|
+
return True
|
|
281
|
+
# Try to detect shebang
|
|
282
|
+
try:
|
|
283
|
+
with open(file_path, "rb") as f:
|
|
284
|
+
first_bytes = f.read(2)
|
|
285
|
+
if first_bytes == b"#!":
|
|
286
|
+
return True
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
# Skip very large files
|
|
292
|
+
try:
|
|
293
|
+
if file_path.stat().st_size > 10 * 1024 * 1024: # 10MB
|
|
294
|
+
return False
|
|
295
|
+
except Exception:
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
def _index_file(self, file_path: Path) -> None:
|
|
301
|
+
"""Index a single file."""
|
|
302
|
+
relative_path = file_path.relative_to(self.root_dir)
|
|
303
|
+
|
|
304
|
+
# Add to all files set
|
|
305
|
+
self._all_files.add(relative_path)
|
|
306
|
+
|
|
307
|
+
# Index by basename
|
|
308
|
+
basename = file_path.name
|
|
309
|
+
self._basename_to_paths[basename].append(relative_path)
|
|
310
|
+
|
|
311
|
+
# For Python files, extract additional information
|
|
312
|
+
if file_path.suffix == ".py":
|
|
313
|
+
self._index_python_file(file_path, relative_path)
|
|
314
|
+
|
|
315
|
+
def _index_python_file(self, file_path: Path, relative_path: Path) -> None:
|
|
316
|
+
"""Extract Python-specific information from a file."""
|
|
317
|
+
try:
|
|
318
|
+
with open(file_path, encoding="utf-8", errors="ignore") as f:
|
|
319
|
+
content = f.read()
|
|
320
|
+
|
|
321
|
+
imports = set()
|
|
322
|
+
|
|
323
|
+
# Quick regex-free parsing for common patterns
|
|
324
|
+
for line in content.splitlines():
|
|
325
|
+
line = line.strip()
|
|
326
|
+
|
|
327
|
+
# Import statements
|
|
328
|
+
if line.startswith("import ") or line.startswith("from "):
|
|
329
|
+
parts = line.split()
|
|
330
|
+
if len(parts) >= 2: # noqa: SIM102
|
|
331
|
+
if parts[0] == "import" or parts[0] == "from" and len(parts) >= 3:
|
|
332
|
+
imports.add(parts[1].split(".")[0])
|
|
333
|
+
|
|
334
|
+
# Class definitions
|
|
335
|
+
if line.startswith("class ") and ":" in line:
|
|
336
|
+
class_name = line[6:].split("(")[0].split(":")[0].strip()
|
|
337
|
+
if class_name:
|
|
338
|
+
self._class_definitions[class_name].append(relative_path)
|
|
339
|
+
|
|
340
|
+
# Function definitions
|
|
341
|
+
if line.startswith("def ") and "(" in line:
|
|
342
|
+
func_name = line[4:].split("(")[0].strip()
|
|
343
|
+
if func_name:
|
|
344
|
+
self._function_definitions[func_name].append(relative_path)
|
|
345
|
+
|
|
346
|
+
if imports:
|
|
347
|
+
self._path_to_imports[relative_path] = imports
|
|
348
|
+
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
def get_all_files(self, file_type: str | None = None) -> list[Path]:
|
|
353
|
+
"""Get all indexed files.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
file_type: Optional file extension filter (e.g., '.py')
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
List of all file paths relative to root directory.
|
|
360
|
+
"""
|
|
361
|
+
with self._lock:
|
|
362
|
+
if not self._indexed:
|
|
363
|
+
self.build_index()
|
|
364
|
+
|
|
365
|
+
if file_type:
|
|
366
|
+
if not file_type.startswith("."):
|
|
367
|
+
file_type = "." + file_type
|
|
368
|
+
return sorted([p for p in self._all_files if p.suffix == file_type])
|
|
369
|
+
|
|
370
|
+
return sorted(self._all_files)
|
|
371
|
+
|
|
372
|
+
def refresh(self, path: str | None = None) -> None:
|
|
373
|
+
"""Refresh the index for a specific path or the entire repository.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
path: Optional specific path to refresh. If None, refreshes everything.
|
|
377
|
+
"""
|
|
378
|
+
with self._lock:
|
|
379
|
+
if path:
|
|
380
|
+
# Refresh a specific file or directory
|
|
381
|
+
target_path = Path(path)
|
|
382
|
+
if not target_path.is_absolute():
|
|
383
|
+
target_path = self.root_dir / target_path
|
|
384
|
+
|
|
385
|
+
if target_path.is_file():
|
|
386
|
+
# Re-index single file
|
|
387
|
+
relative_path = target_path.relative_to(self.root_dir)
|
|
388
|
+
|
|
389
|
+
# Remove from indices
|
|
390
|
+
self._remove_from_indices(relative_path)
|
|
391
|
+
|
|
392
|
+
# Re-index if it should be indexed
|
|
393
|
+
if self._should_index_file(target_path):
|
|
394
|
+
self._index_file(target_path)
|
|
395
|
+
|
|
396
|
+
elif target_path.is_dir():
|
|
397
|
+
# Remove all files under this directory
|
|
398
|
+
prefix = str(target_path.relative_to(self.root_dir))
|
|
399
|
+
to_remove = [p for p in self._all_files if str(p).startswith(prefix)]
|
|
400
|
+
for p in to_remove:
|
|
401
|
+
self._remove_from_indices(p)
|
|
402
|
+
|
|
403
|
+
# Re-scan directory
|
|
404
|
+
self._scan_directory(target_path)
|
|
405
|
+
else:
|
|
406
|
+
# Full refresh
|
|
407
|
+
self.build_index(force=True)
|
|
408
|
+
|
|
409
|
+
def _remove_from_indices(self, relative_path: Path) -> None:
|
|
410
|
+
"""Remove a file from all indices."""
|
|
411
|
+
# Remove from all files
|
|
412
|
+
self._all_files.discard(relative_path)
|
|
413
|
+
|
|
414
|
+
# Remove from basename index
|
|
415
|
+
basename = relative_path.name
|
|
416
|
+
if basename in self._basename_to_paths:
|
|
417
|
+
self._basename_to_paths[basename] = [
|
|
418
|
+
p for p in self._basename_to_paths[basename] if p != relative_path
|
|
419
|
+
]
|
|
420
|
+
if not self._basename_to_paths[basename]:
|
|
421
|
+
del self._basename_to_paths[basename]
|
|
422
|
+
|
|
423
|
+
# Remove from import index
|
|
424
|
+
if relative_path in self._path_to_imports:
|
|
425
|
+
del self._path_to_imports[relative_path]
|
|
426
|
+
|
|
427
|
+
# Remove from symbol indices
|
|
428
|
+
for symbol_dict in [self._class_definitions, self._function_definitions]:
|
|
429
|
+
for symbol, paths in list(symbol_dict.items()):
|
|
430
|
+
symbol_dict[symbol] = [p for p in paths if p != relative_path]
|
|
431
|
+
if not symbol_dict[symbol]:
|
|
432
|
+
del symbol_dict[symbol]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Configuration constants for code indexing."""
|
|
2
|
+
|
|
3
|
+
IGNORE_DIRS = {
|
|
4
|
+
".git",
|
|
5
|
+
".hg",
|
|
6
|
+
".svn",
|
|
7
|
+
".bzr",
|
|
8
|
+
"__pycache__",
|
|
9
|
+
".pytest_cache",
|
|
10
|
+
".mypy_cache",
|
|
11
|
+
"node_modules",
|
|
12
|
+
"bower_components",
|
|
13
|
+
".venv",
|
|
14
|
+
"venv",
|
|
15
|
+
"env",
|
|
16
|
+
".env",
|
|
17
|
+
"build",
|
|
18
|
+
"dist",
|
|
19
|
+
"_build",
|
|
20
|
+
"target",
|
|
21
|
+
".idea",
|
|
22
|
+
".vscode",
|
|
23
|
+
".vs",
|
|
24
|
+
"htmlcov",
|
|
25
|
+
".coverage",
|
|
26
|
+
".tox",
|
|
27
|
+
".eggs",
|
|
28
|
+
".egg-info",
|
|
29
|
+
".bundle",
|
|
30
|
+
"vendor",
|
|
31
|
+
".terraform",
|
|
32
|
+
".serverless",
|
|
33
|
+
".next",
|
|
34
|
+
".nuxt",
|
|
35
|
+
"coverage",
|
|
36
|
+
"tmp",
|
|
37
|
+
"temp",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
QUICK_INDEX_THRESHOLD = 1000
|
|
41
|
+
|
|
42
|
+
PRIORITY_DIRS = {"src", "lib", "app", "packages", "core", "internal"}
|
|
43
|
+
|
|
44
|
+
INDEXED_EXTENSIONS = {
|
|
45
|
+
".py",
|
|
46
|
+
".js",
|
|
47
|
+
".jsx",
|
|
48
|
+
".ts",
|
|
49
|
+
".tsx",
|
|
50
|
+
".java",
|
|
51
|
+
".c",
|
|
52
|
+
".cpp",
|
|
53
|
+
".cc",
|
|
54
|
+
".cxx",
|
|
55
|
+
".h",
|
|
56
|
+
".hpp",
|
|
57
|
+
".rs",
|
|
58
|
+
".go",
|
|
59
|
+
".rb",
|
|
60
|
+
".php",
|
|
61
|
+
".cs",
|
|
62
|
+
".swift",
|
|
63
|
+
".kt",
|
|
64
|
+
".scala",
|
|
65
|
+
".sh",
|
|
66
|
+
".bash",
|
|
67
|
+
".zsh",
|
|
68
|
+
".json",
|
|
69
|
+
".yaml",
|
|
70
|
+
".yml",
|
|
71
|
+
".toml",
|
|
72
|
+
".xml",
|
|
73
|
+
".md",
|
|
74
|
+
".rst",
|
|
75
|
+
".txt",
|
|
76
|
+
".html",
|
|
77
|
+
".css",
|
|
78
|
+
".scss",
|
|
79
|
+
".sass",
|
|
80
|
+
".sql",
|
|
81
|
+
".graphql",
|
|
82
|
+
".dockerfile",
|
|
83
|
+
".containerfile",
|
|
84
|
+
".gitignore",
|
|
85
|
+
".env.example",
|
|
86
|
+
}
|
tunacode/lsp/__init__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""LSP client orchestrator for diagnostic feedback.
|
|
2
|
+
|
|
3
|
+
This module provides the public API for getting diagnostics from language servers.
|
|
4
|
+
It manages server lifecycle and provides formatted diagnostic output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from tunacode.lsp.client import Diagnostic, LSPClient
|
|
10
|
+
from tunacode.lsp.diagnostics import truncate_diagnostic_message
|
|
11
|
+
from tunacode.lsp.servers import get_server_command
|
|
12
|
+
|
|
13
|
+
__all__ = ["get_diagnostics", "format_diagnostics"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_clients: dict[str, LSPClient] = {}
|
|
17
|
+
|
|
18
|
+
WORKSPACE_MARKERS: tuple[str, ...] = (
|
|
19
|
+
".git",
|
|
20
|
+
"pyproject.toml",
|
|
21
|
+
"setup.cfg",
|
|
22
|
+
"setup.py",
|
|
23
|
+
"requirements.txt",
|
|
24
|
+
"Pipfile",
|
|
25
|
+
"package.json",
|
|
26
|
+
"Cargo.toml",
|
|
27
|
+
"go.mod",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_workspace_root(path: Path) -> Path:
|
|
32
|
+
start_dir = path if path.is_dir() else path.parent
|
|
33
|
+
|
|
34
|
+
for candidate in (start_dir, *start_dir.parents):
|
|
35
|
+
if any((candidate / marker).exists() for marker in WORKSPACE_MARKERS):
|
|
36
|
+
return candidate
|
|
37
|
+
|
|
38
|
+
return start_dir
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def get_diagnostics(filepath: Path | str, timeout: float = 5.0) -> list[Diagnostic]:
|
|
42
|
+
"""Get diagnostics for a file from the appropriate language server.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
filepath: Path to the file to check
|
|
46
|
+
timeout: Maximum time to wait for diagnostics in seconds
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
List of diagnostics, empty if server unavailable or no errors
|
|
50
|
+
"""
|
|
51
|
+
path = Path(filepath).resolve()
|
|
52
|
+
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
root = _resolve_workspace_root(path)
|
|
57
|
+
command = get_server_command(path)
|
|
58
|
+
if command is None:
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
command_key = " ".join(command)
|
|
62
|
+
client_key = f"{root}::{command_key}"
|
|
63
|
+
|
|
64
|
+
if client_key not in _clients:
|
|
65
|
+
client = LSPClient(command=command, root=root)
|
|
66
|
+
started = await client.start()
|
|
67
|
+
if not started:
|
|
68
|
+
return []
|
|
69
|
+
_clients[client_key] = client
|
|
70
|
+
|
|
71
|
+
client = _clients[client_key]
|
|
72
|
+
return await client.get_diagnostics(path, timeout=timeout)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
MAX_DIAGNOSTICS_COUNT = 10
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_diagnostics(diagnostics: list[Diagnostic]) -> str:
|
|
79
|
+
"""Format diagnostics as XML block for tool output.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
diagnostics: List of diagnostics to format
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Formatted XML string or empty string if no diagnostics
|
|
86
|
+
"""
|
|
87
|
+
if not diagnostics:
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
errors = sum(1 for d in diagnostics if d.severity == "error")
|
|
91
|
+
warnings = sum(1 for d in diagnostics if d.severity == "warning")
|
|
92
|
+
|
|
93
|
+
lines: list[str] = ["<file_diagnostics>"]
|
|
94
|
+
|
|
95
|
+
if errors > 0:
|
|
96
|
+
lines.append(f"ACTION REQUIRED: {errors} error(s) found - fix before continuing")
|
|
97
|
+
if warnings > 0:
|
|
98
|
+
lines.append(f"Additional: {warnings} warning(s)")
|
|
99
|
+
else:
|
|
100
|
+
lines.append(f"Summary: {warnings} warning(s)")
|
|
101
|
+
|
|
102
|
+
for diag in diagnostics[:MAX_DIAGNOSTICS_COUNT]:
|
|
103
|
+
severity = diag.severity.capitalize()
|
|
104
|
+
line = diag.line
|
|
105
|
+
message = truncate_diagnostic_message(diag.message)
|
|
106
|
+
lines.append(f"{severity} (line {line}): {message}")
|
|
107
|
+
|
|
108
|
+
lines.append("</file_diagnostics>")
|
|
109
|
+
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|