codemaster-cli 2.2.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.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- vibe/whats_new.md +5 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from vibe.core.autocompletion.file_indexer import FileIndexer, IndexEntry
|
|
8
|
+
from vibe.core.autocompletion.fuzzy import fuzzy_match
|
|
9
|
+
|
|
10
|
+
DEFAULT_MAX_ENTRIES_TO_PROCESS = 32000
|
|
11
|
+
DEFAULT_TARGET_MATCHES = 100
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Completer:
|
|
15
|
+
def get_completions(self, text: str, cursor_pos: int) -> list[str]:
|
|
16
|
+
return []
|
|
17
|
+
|
|
18
|
+
def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
|
|
19
|
+
return [
|
|
20
|
+
(completion, "") for completion in self.get_completions(text, cursor_pos)
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
def get_replacement_range(
|
|
24
|
+
self, text: str, cursor_pos: int
|
|
25
|
+
) -> tuple[int, int] | None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CommandCompleter(Completer):
|
|
30
|
+
def __init__(self, entries: Callable[[], list[tuple[str, str]]]) -> None:
|
|
31
|
+
self._get_entries = entries
|
|
32
|
+
|
|
33
|
+
def _build_lookup(self) -> tuple[list[str], dict[str, str]]:
|
|
34
|
+
descriptions: dict[str, str] = {}
|
|
35
|
+
for alias, description in self._get_entries():
|
|
36
|
+
descriptions[alias] = description
|
|
37
|
+
return list(descriptions.keys()), descriptions
|
|
38
|
+
|
|
39
|
+
def get_completions(self, text: str, cursor_pos: int) -> list[str]:
|
|
40
|
+
if not text.startswith("/"):
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
aliases, _ = self._build_lookup()
|
|
44
|
+
word = text[1:cursor_pos].lower()
|
|
45
|
+
search_str = "/" + word
|
|
46
|
+
return [alias for alias in aliases if alias.lower().startswith(search_str)]
|
|
47
|
+
|
|
48
|
+
def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
|
|
49
|
+
if not text.startswith("/"):
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
aliases, descriptions = self._build_lookup()
|
|
53
|
+
word = text[1:cursor_pos].lower()
|
|
54
|
+
search_str = "/" + word
|
|
55
|
+
return [
|
|
56
|
+
(alias, descriptions.get(alias, ""))
|
|
57
|
+
for alias in aliases
|
|
58
|
+
if alias.lower().startswith(search_str)
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def get_replacement_range(
|
|
62
|
+
self, text: str, cursor_pos: int
|
|
63
|
+
) -> tuple[int, int] | None:
|
|
64
|
+
if text.startswith("/"):
|
|
65
|
+
return (0, cursor_pos)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PathCompleter(Completer):
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
max_entries_to_process: int = DEFAULT_MAX_ENTRIES_TO_PROCESS,
|
|
73
|
+
target_matches: int = DEFAULT_TARGET_MATCHES,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._indexer = FileIndexer()
|
|
76
|
+
self._max_entries_to_process = max_entries_to_process
|
|
77
|
+
self._target_matches = target_matches
|
|
78
|
+
|
|
79
|
+
class _SearchContext(NamedTuple):
|
|
80
|
+
suffix: str
|
|
81
|
+
search_pattern: str
|
|
82
|
+
path_prefix: str
|
|
83
|
+
immediate_only: bool
|
|
84
|
+
|
|
85
|
+
def _extract_partial(self, before_cursor: str) -> str | None:
|
|
86
|
+
if "@" not in before_cursor:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
at_index = before_cursor.rfind("@")
|
|
90
|
+
fragment = before_cursor[at_index + 1 :]
|
|
91
|
+
|
|
92
|
+
if " " in fragment:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
return fragment
|
|
96
|
+
|
|
97
|
+
def _build_search_context(self, partial_path: str) -> _SearchContext:
|
|
98
|
+
suffix = partial_path.split("/")[-1]
|
|
99
|
+
|
|
100
|
+
if not partial_path:
|
|
101
|
+
# "@" => show top-level dir and files
|
|
102
|
+
return self._SearchContext(
|
|
103
|
+
search_pattern="", path_prefix="", suffix=suffix, immediate_only=True
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if partial_path.endswith("/"):
|
|
107
|
+
# "@something/" => list immediate children
|
|
108
|
+
return self._SearchContext(
|
|
109
|
+
search_pattern="",
|
|
110
|
+
path_prefix=partial_path,
|
|
111
|
+
suffix=suffix,
|
|
112
|
+
immediate_only=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return self._SearchContext(
|
|
116
|
+
# => run fuzzy search across the index
|
|
117
|
+
search_pattern=partial_path,
|
|
118
|
+
path_prefix="",
|
|
119
|
+
suffix=suffix,
|
|
120
|
+
immediate_only=False,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _matches_prefix(self, entry: IndexEntry, context: _SearchContext) -> bool:
|
|
124
|
+
path_str = entry.rel
|
|
125
|
+
|
|
126
|
+
if context.path_prefix:
|
|
127
|
+
prefix_without_slash = context.path_prefix.rstrip("/")
|
|
128
|
+
prefix_with_slash = f"{prefix_without_slash}/"
|
|
129
|
+
|
|
130
|
+
if path_str == prefix_without_slash and entry.is_dir:
|
|
131
|
+
# do not suggest the dir itself (e.g. "@src/" => don't suggest "@src/")
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
if path_str.startswith(prefix_with_slash):
|
|
135
|
+
after_prefix = path_str[len(prefix_with_slash) :]
|
|
136
|
+
else:
|
|
137
|
+
idx = path_str.find(prefix_with_slash)
|
|
138
|
+
if idx == -1 or (idx > 0 and path_str[idx - 1] != "/"):
|
|
139
|
+
return False
|
|
140
|
+
after_prefix = path_str[idx + len(prefix_with_slash) :]
|
|
141
|
+
|
|
142
|
+
# only suggest files/dirs that are immediate children of the prefix
|
|
143
|
+
return bool(after_prefix) and "/" not in after_prefix
|
|
144
|
+
|
|
145
|
+
if context.immediate_only and "/" in path_str:
|
|
146
|
+
# when user just typed "@", only show top-level entries
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
# entry matches the prefix: let the fuzzy matcher decide if it's a good match
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
def _is_visible(self, entry: IndexEntry, context: _SearchContext) -> bool:
|
|
153
|
+
return not (entry.name.startswith(".") and not context.suffix.startswith("."))
|
|
154
|
+
|
|
155
|
+
def _format_label(self, entry: IndexEntry) -> str:
|
|
156
|
+
suffix = "/" if entry.is_dir else ""
|
|
157
|
+
return f"@{entry.rel}{suffix}"
|
|
158
|
+
|
|
159
|
+
def _score_matches(
|
|
160
|
+
self, entries: list[IndexEntry], context: _SearchContext
|
|
161
|
+
) -> list[tuple[str, float]]:
|
|
162
|
+
scored_matches: list[tuple[str, float]] = []
|
|
163
|
+
MAX_MATCHES = 50
|
|
164
|
+
|
|
165
|
+
for i, entry in enumerate(entries):
|
|
166
|
+
if i >= self._max_entries_to_process:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
if not self._matches_prefix(entry, context):
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
if not self._is_visible(entry, context):
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
label = self._format_label(entry)
|
|
176
|
+
|
|
177
|
+
if not context.search_pattern:
|
|
178
|
+
scored_matches.append((label, 0.0))
|
|
179
|
+
if len(scored_matches) >= self._target_matches:
|
|
180
|
+
break
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
match_result = fuzzy_match(
|
|
184
|
+
context.search_pattern, entry.rel, entry.rel_lower
|
|
185
|
+
)
|
|
186
|
+
if match_result.matched:
|
|
187
|
+
scored_matches.append((label, match_result.score))
|
|
188
|
+
if (
|
|
189
|
+
len(scored_matches) >= self._target_matches
|
|
190
|
+
and match_result.score > MAX_MATCHES
|
|
191
|
+
):
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
scored_matches.sort(key=lambda x: (-x[1], x[0]))
|
|
195
|
+
return scored_matches
|
|
196
|
+
|
|
197
|
+
def _collect_matches(self, text: str, cursor_pos: int) -> list[str]:
|
|
198
|
+
before_cursor = text[:cursor_pos]
|
|
199
|
+
partial_path = self._extract_partial(before_cursor)
|
|
200
|
+
if partial_path is None:
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
context = self._build_search_context(partial_path)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
# TODO (Vince): doing the assumption that "." is the root directory... Reliable?
|
|
207
|
+
file_index = self._indexer.get_index(Path("."))
|
|
208
|
+
except (OSError, RuntimeError):
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
scored_matches = self._score_matches(file_index, context)
|
|
212
|
+
return [path for path, _ in scored_matches]
|
|
213
|
+
|
|
214
|
+
def get_completions(self, text: str, cursor_pos: int) -> list[str]:
|
|
215
|
+
return self._collect_matches(text, cursor_pos)
|
|
216
|
+
|
|
217
|
+
def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
|
|
218
|
+
matches = self._collect_matches(text, cursor_pos)
|
|
219
|
+
return [(completion, "") for completion in matches]
|
|
220
|
+
|
|
221
|
+
def get_replacement_range(
|
|
222
|
+
self, text: str, cursor_pos: int
|
|
223
|
+
) -> tuple[int, int] | None:
|
|
224
|
+
before_cursor = text[:cursor_pos]
|
|
225
|
+
if "@" in before_cursor:
|
|
226
|
+
at_index = before_cursor.rfind("@")
|
|
227
|
+
return (at_index, cursor_pos)
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class MultiCompleter(Completer):
|
|
232
|
+
def __init__(self, completers: list[Completer]) -> None:
|
|
233
|
+
self.completers = completers
|
|
234
|
+
|
|
235
|
+
def get_completions(self, text: str, cursor_pos: int) -> list[str]:
|
|
236
|
+
all_completions = []
|
|
237
|
+
for completer in self.completers:
|
|
238
|
+
completions = completer.get_completions(text, cursor_pos)
|
|
239
|
+
all_completions.extend(completions)
|
|
240
|
+
|
|
241
|
+
seen = set()
|
|
242
|
+
unique = []
|
|
243
|
+
for comp in all_completions:
|
|
244
|
+
if comp not in seen:
|
|
245
|
+
seen.add(comp)
|
|
246
|
+
unique.append(comp)
|
|
247
|
+
|
|
248
|
+
return unique
|
|
249
|
+
|
|
250
|
+
def get_replacement_range(
|
|
251
|
+
self, text: str, cursor_pos: int
|
|
252
|
+
) -> tuple[int, int] | None:
|
|
253
|
+
for completer in self.completers:
|
|
254
|
+
range_result = completer.get_replacement_range(text, cursor_pos)
|
|
255
|
+
if range_result is not None:
|
|
256
|
+
return range_result
|
|
257
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from vibe.core.autocompletion.file_indexer.indexer import FileIndexer
|
|
4
|
+
from vibe.core.autocompletion.file_indexer.store import (
|
|
5
|
+
FileIndexStats,
|
|
6
|
+
FileIndexStore,
|
|
7
|
+
IndexEntry,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["FileIndexStats", "FileIndexStore", "FileIndexer", "IndexEntry"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import fnmatch
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_IGNORE_PATTERNS: list[tuple[str, bool]] = [
|
|
8
|
+
(".git/", True),
|
|
9
|
+
("__pycache__/", True),
|
|
10
|
+
("node_modules/", True),
|
|
11
|
+
(".DS_Store", True),
|
|
12
|
+
("*.pyc", True),
|
|
13
|
+
("*.log", True),
|
|
14
|
+
(".vscode/", True),
|
|
15
|
+
(".idea/", True),
|
|
16
|
+
("/build/", True),
|
|
17
|
+
("dist/", True),
|
|
18
|
+
("target/", True),
|
|
19
|
+
(".next/", True),
|
|
20
|
+
(".nuxt/", True),
|
|
21
|
+
("coverage/", True),
|
|
22
|
+
(".nyc_output/", True),
|
|
23
|
+
("*.egg-info", True),
|
|
24
|
+
(".pytest_cache/", True),
|
|
25
|
+
(".tox/", True),
|
|
26
|
+
("vendor/", True),
|
|
27
|
+
("third_party/", True),
|
|
28
|
+
("deps/", True),
|
|
29
|
+
("*.min.js", True),
|
|
30
|
+
("*.min.css", True),
|
|
31
|
+
("*.bundle.js", True),
|
|
32
|
+
("*.chunk.js", True),
|
|
33
|
+
(".cache/", True),
|
|
34
|
+
("tmp/", True),
|
|
35
|
+
("temp/", True),
|
|
36
|
+
("logs/", True),
|
|
37
|
+
(".uv-cache/", True),
|
|
38
|
+
(".ruff_cache/", True),
|
|
39
|
+
(".venv/", True),
|
|
40
|
+
("venv/", True),
|
|
41
|
+
(".mypy_cache/", True),
|
|
42
|
+
("htmlcov/", True),
|
|
43
|
+
(".coverage", True),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class CompiledPattern:
|
|
49
|
+
raw: str
|
|
50
|
+
stripped: str
|
|
51
|
+
is_exclude: bool
|
|
52
|
+
dir_only: bool
|
|
53
|
+
name_only: bool
|
|
54
|
+
anchor_root: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class IgnoreRules:
|
|
58
|
+
def __init__(self, defaults: list[tuple[str, bool]] | None = None) -> None:
|
|
59
|
+
self._defaults = defaults or DEFAULT_IGNORE_PATTERNS
|
|
60
|
+
self._patterns: list[CompiledPattern] | None = None
|
|
61
|
+
self._root: Path | None = None
|
|
62
|
+
|
|
63
|
+
def ensure_for_root(self, root: Path) -> None:
|
|
64
|
+
resolved_root = root.resolve()
|
|
65
|
+
if self._patterns is None or self._root != resolved_root:
|
|
66
|
+
self._patterns = self._build_patterns(resolved_root)
|
|
67
|
+
self._root = resolved_root
|
|
68
|
+
|
|
69
|
+
def should_ignore(self, rel_str: str, name: str, is_dir: bool) -> bool:
|
|
70
|
+
if not self._patterns:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
ignored = False
|
|
74
|
+
for pattern in self._patterns:
|
|
75
|
+
if self._matches(rel_str, name, is_dir, pattern):
|
|
76
|
+
ignored = pattern.is_exclude
|
|
77
|
+
return ignored
|
|
78
|
+
|
|
79
|
+
def reset(self) -> None:
|
|
80
|
+
self._patterns = None
|
|
81
|
+
self._root = None
|
|
82
|
+
|
|
83
|
+
def _build_patterns(self, root: Path) -> list[CompiledPattern]:
|
|
84
|
+
patterns: list[CompiledPattern] = []
|
|
85
|
+
for raw, is_exclude in self._defaults:
|
|
86
|
+
anchor_root = raw.startswith("/")
|
|
87
|
+
if anchor_root:
|
|
88
|
+
raw = raw[1:]
|
|
89
|
+
|
|
90
|
+
stripped = raw.rstrip("/")
|
|
91
|
+
patterns.append(
|
|
92
|
+
CompiledPattern(
|
|
93
|
+
raw=raw,
|
|
94
|
+
stripped=stripped,
|
|
95
|
+
is_exclude=is_exclude,
|
|
96
|
+
dir_only=raw.endswith("/"),
|
|
97
|
+
name_only="/" not in stripped,
|
|
98
|
+
anchor_root=anchor_root,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
gitignore_path = root / ".gitignore"
|
|
103
|
+
if gitignore_path.exists():
|
|
104
|
+
try:
|
|
105
|
+
text = gitignore_path.read_text(encoding="utf-8")
|
|
106
|
+
except Exception:
|
|
107
|
+
return patterns
|
|
108
|
+
|
|
109
|
+
for line in text.splitlines():
|
|
110
|
+
raw = line.strip()
|
|
111
|
+
if not raw or raw.startswith("#"):
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if "#" in raw:
|
|
115
|
+
raw = raw.split("#", 1)[0].rstrip()
|
|
116
|
+
if not raw:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
is_exclude = not raw.startswith("!")
|
|
120
|
+
if not is_exclude:
|
|
121
|
+
raw = raw[1:].lstrip()
|
|
122
|
+
if not raw:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
anchor_root = raw.startswith("/")
|
|
126
|
+
if anchor_root:
|
|
127
|
+
raw = raw[1:]
|
|
128
|
+
|
|
129
|
+
stripped = raw.rstrip("/")
|
|
130
|
+
patterns.append(
|
|
131
|
+
CompiledPattern(
|
|
132
|
+
raw=raw,
|
|
133
|
+
stripped=stripped,
|
|
134
|
+
is_exclude=is_exclude,
|
|
135
|
+
dir_only=raw.endswith("/"),
|
|
136
|
+
name_only="/" not in stripped,
|
|
137
|
+
anchor_root=anchor_root,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return patterns
|
|
142
|
+
|
|
143
|
+
def _matches(
|
|
144
|
+
self, rel_str: str, name: str, is_dir: bool, pattern: CompiledPattern
|
|
145
|
+
) -> bool:
|
|
146
|
+
if pattern.name_only:
|
|
147
|
+
if pattern.anchor_root and "/" in rel_str:
|
|
148
|
+
return False
|
|
149
|
+
target = name
|
|
150
|
+
else:
|
|
151
|
+
target = rel_str
|
|
152
|
+
|
|
153
|
+
if not fnmatch.fnmatch(target, pattern.stripped):
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
return not pattern.dir_only or is_dir
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from threading import Event, RLock
|
|
9
|
+
|
|
10
|
+
from vibe.core.autocompletion.file_indexer.ignore_rules import IgnoreRules
|
|
11
|
+
from vibe.core.autocompletion.file_indexer.store import (
|
|
12
|
+
FileIndexStats,
|
|
13
|
+
FileIndexStore,
|
|
14
|
+
IndexEntry,
|
|
15
|
+
)
|
|
16
|
+
from vibe.core.autocompletion.file_indexer.watcher import Change, WatchController
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class _RebuildTask:
|
|
21
|
+
cancel_event: Event
|
|
22
|
+
done_event: Event
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FileIndexer:
|
|
26
|
+
def __init__(self, mass_change_threshold: int = 200) -> None:
|
|
27
|
+
self._lock = RLock() # guards _store snapshot access and watcher callbacks.
|
|
28
|
+
self._stats = FileIndexStats()
|
|
29
|
+
self._ignore_rules = IgnoreRules()
|
|
30
|
+
self._store = FileIndexStore(
|
|
31
|
+
self._ignore_rules, self._stats, mass_change_threshold=mass_change_threshold
|
|
32
|
+
)
|
|
33
|
+
self._watcher = WatchController(self._handle_watch_changes)
|
|
34
|
+
self._rebuild_executor = ThreadPoolExecutor(
|
|
35
|
+
max_workers=1, thread_name_prefix="file-indexer"
|
|
36
|
+
)
|
|
37
|
+
self._active_rebuilds: dict[Path, _RebuildTask] = {}
|
|
38
|
+
self._rebuild_lock = (
|
|
39
|
+
RLock()
|
|
40
|
+
) # coordinates updates to _active_rebuilds and _target_root.
|
|
41
|
+
self._target_root: Path | None = None
|
|
42
|
+
self._shutdown = False
|
|
43
|
+
|
|
44
|
+
atexit.register(self.shutdown)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def stats(self) -> FileIndexStats:
|
|
48
|
+
return self._stats
|
|
49
|
+
|
|
50
|
+
def get_index(self, root: Path) -> list[IndexEntry]:
|
|
51
|
+
resolved_root = root.resolve()
|
|
52
|
+
|
|
53
|
+
with self._lock: # read current root without blocking rebuild bookkeeping
|
|
54
|
+
root_changed = (
|
|
55
|
+
self._store.root is not None and self._store.root != resolved_root
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if root_changed:
|
|
59
|
+
self._watcher.stop()
|
|
60
|
+
with self._rebuild_lock: # cancel rebuilds targeting other roots
|
|
61
|
+
self._target_root = resolved_root
|
|
62
|
+
for other_root, task in self._active_rebuilds.items():
|
|
63
|
+
if other_root != resolved_root:
|
|
64
|
+
task.cancel_event.set()
|
|
65
|
+
task.done_event.set()
|
|
66
|
+
self._active_rebuilds.pop(other_root, None)
|
|
67
|
+
|
|
68
|
+
with self._lock:
|
|
69
|
+
needs_rebuild = self._store.root != resolved_root
|
|
70
|
+
|
|
71
|
+
if needs_rebuild:
|
|
72
|
+
with self._rebuild_lock:
|
|
73
|
+
self._target_root = resolved_root
|
|
74
|
+
self._start_background_rebuild(resolved_root)
|
|
75
|
+
self._wait_for_rebuild(resolved_root)
|
|
76
|
+
|
|
77
|
+
self._watcher.start(resolved_root)
|
|
78
|
+
|
|
79
|
+
with self._lock: # ensure root reference is fresh before snapshotting
|
|
80
|
+
return self._store.snapshot()
|
|
81
|
+
|
|
82
|
+
def refresh(self) -> None:
|
|
83
|
+
self._watcher.stop()
|
|
84
|
+
with self._rebuild_lock:
|
|
85
|
+
for task in self._active_rebuilds.values():
|
|
86
|
+
task.cancel_event.set()
|
|
87
|
+
task.done_event.set()
|
|
88
|
+
self._active_rebuilds.clear()
|
|
89
|
+
self._target_root = None
|
|
90
|
+
with self._lock:
|
|
91
|
+
self._store.clear()
|
|
92
|
+
self._ignore_rules.reset()
|
|
93
|
+
|
|
94
|
+
def shutdown(self) -> None:
|
|
95
|
+
if self._shutdown:
|
|
96
|
+
return
|
|
97
|
+
self._shutdown = True
|
|
98
|
+
self.refresh()
|
|
99
|
+
self._rebuild_executor.shutdown(wait=True)
|
|
100
|
+
|
|
101
|
+
def __del__(self) -> None:
|
|
102
|
+
if not self._shutdown:
|
|
103
|
+
try:
|
|
104
|
+
self.shutdown()
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def _start_background_rebuild(self, root: Path) -> None:
|
|
109
|
+
with self._rebuild_lock: # one rebuild per root
|
|
110
|
+
if root in self._active_rebuilds:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
cancel_event = Event()
|
|
114
|
+
done_event = Event()
|
|
115
|
+
self._active_rebuilds[root] = _RebuildTask(
|
|
116
|
+
cancel_event=cancel_event, done_event=done_event
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
self._rebuild_executor.submit(
|
|
121
|
+
self._rebuild_worker, root, self._active_rebuilds[root]
|
|
122
|
+
)
|
|
123
|
+
except RuntimeError:
|
|
124
|
+
with self._rebuild_lock:
|
|
125
|
+
self._active_rebuilds.pop(root, None)
|
|
126
|
+
done_event.set()
|
|
127
|
+
|
|
128
|
+
def _rebuild_worker(self, root: Path, task: _RebuildTask) -> None:
|
|
129
|
+
try:
|
|
130
|
+
if task.cancel_event.is_set(): # cancelled before work began
|
|
131
|
+
with self._rebuild_lock:
|
|
132
|
+
self._active_rebuilds.pop(root, None)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
with self._rebuild_lock: # bail if another root took ownership
|
|
136
|
+
if self._target_root != root:
|
|
137
|
+
self._active_rebuilds.pop(root, None)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
with self._lock: # exclusive access while rebuilding the store
|
|
141
|
+
if task.cancel_event.is_set():
|
|
142
|
+
with self._rebuild_lock:
|
|
143
|
+
self._active_rebuilds.pop(root, None)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
self._store.rebuild(
|
|
147
|
+
root, should_cancel=lambda: task.cancel_event.is_set()
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
with self._rebuild_lock:
|
|
151
|
+
self._active_rebuilds.pop(root, None)
|
|
152
|
+
except Exception:
|
|
153
|
+
with self._rebuild_lock:
|
|
154
|
+
self._active_rebuilds.pop(root, None)
|
|
155
|
+
finally:
|
|
156
|
+
task.done_event.set()
|
|
157
|
+
|
|
158
|
+
def _wait_for_rebuild(self, root: Path) -> None:
|
|
159
|
+
with self._rebuild_lock:
|
|
160
|
+
task = self._active_rebuilds.get(root)
|
|
161
|
+
if task:
|
|
162
|
+
task.done_event.wait()
|
|
163
|
+
|
|
164
|
+
def _handle_watch_changes(
|
|
165
|
+
self, root: Path, raw_changes: Iterable[tuple[Change, str]]
|
|
166
|
+
) -> None:
|
|
167
|
+
normalized: list[tuple[Change, Path]] = []
|
|
168
|
+
for change, path_str in raw_changes:
|
|
169
|
+
if change not in {Change.added, Change.deleted, Change.modified}:
|
|
170
|
+
continue
|
|
171
|
+
normalized.append((change, Path(path_str).resolve()))
|
|
172
|
+
|
|
173
|
+
if not normalized:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
with self._lock: # make watcher ignore stale roots
|
|
177
|
+
if self._store.root != root:
|
|
178
|
+
return
|
|
179
|
+
self._store.apply_changes(normalized)
|