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.
Files changed (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. 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)