repl-toolkit 1.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.
- repl_toolkit/__init__.py +70 -0
- repl_toolkit/actions/__init__.py +24 -0
- repl_toolkit/actions/action.py +223 -0
- repl_toolkit/actions/registry.py +564 -0
- repl_toolkit/async_repl.py +374 -0
- repl_toolkit/completion/__init__.py +15 -0
- repl_toolkit/completion/prefix.py +109 -0
- repl_toolkit/completion/shell_expansion.py +453 -0
- repl_toolkit/formatting.py +152 -0
- repl_toolkit/headless_repl.py +251 -0
- repl_toolkit/ptypes.py +122 -0
- repl_toolkit/tests/__init__.py +5 -0
- repl_toolkit/tests/conftest.py +79 -0
- repl_toolkit/tests/test_actions.py +578 -0
- repl_toolkit/tests/test_async_repl.py +381 -0
- repl_toolkit/tests/test_completion.py +656 -0
- repl_toolkit/tests/test_formatting.py +232 -0
- repl_toolkit/tests/test_headless.py +677 -0
- repl_toolkit/tests/test_types.py +174 -0
- repl_toolkit-1.2.0.dist-info/METADATA +761 -0
- repl_toolkit-1.2.0.dist-info/RECORD +24 -0
- repl_toolkit-1.2.0.dist-info/WHEEL +5 -0
- repl_toolkit-1.2.0.dist-info/licenses/LICENSE +21 -0
- repl_toolkit-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async REPL interface with action support for repl_toolkit.
|
|
3
|
+
|
|
4
|
+
Provides an interactive chat interface with full UI features including
|
|
5
|
+
history, action handling (commands + keyboard shortcuts), and
|
|
6
|
+
robust cancellation of long-running tasks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
from prompt_toolkit import HTML, PromptSession
|
|
16
|
+
from prompt_toolkit import print_formatted_text as print
|
|
17
|
+
from prompt_toolkit.application import Application
|
|
18
|
+
from prompt_toolkit.history import FileHistory
|
|
19
|
+
from prompt_toolkit.input import create_input
|
|
20
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
21
|
+
from prompt_toolkit.keys import Keys
|
|
22
|
+
from prompt_toolkit.output import DummyOutput
|
|
23
|
+
|
|
24
|
+
from .actions import ActionRegistry
|
|
25
|
+
from .ptypes import ActionHandler, AsyncBackend, Completer
|
|
26
|
+
|
|
27
|
+
THINKING = HTML("<i><grey>Thinking... (Press Alt+C to cancel)</grey></i>")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncREPL:
|
|
31
|
+
"""
|
|
32
|
+
Manages an interactive async REPL session with action support.
|
|
33
|
+
|
|
34
|
+
Provides user input handling, action processing (commands and shortcuts),
|
|
35
|
+
and robust cancellation of long-running tasks with a clean, extensible interface.
|
|
36
|
+
|
|
37
|
+
The AsyncREPL supports late backend binding, allowing initialization without
|
|
38
|
+
a backend for scenarios where the backend is only available within a resource
|
|
39
|
+
context block.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
action_registry: Optional[ActionHandler] = None,
|
|
45
|
+
completer: Optional[Completer] = None,
|
|
46
|
+
prompt_string: Optional[str] = None,
|
|
47
|
+
history_path: Optional[Path] = None,
|
|
48
|
+
**kwargs,
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Initialize the async REPL interface.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
action_registry: Action registry for commands and shortcuts (optional)
|
|
55
|
+
completer: Optional tab-completion provider
|
|
56
|
+
prompt_string: Custom prompt string (default: "User: ")
|
|
57
|
+
history_path: Optional path for command history storage
|
|
58
|
+
|
|
59
|
+
Note:
|
|
60
|
+
Backend is provided later via the run() method to support scenarios
|
|
61
|
+
where the backend is only available within a resource context.
|
|
62
|
+
"""
|
|
63
|
+
logger.trace("AsyncREPL.__init__() entry")
|
|
64
|
+
|
|
65
|
+
self.prompt_string = HTML(prompt_string or "User: ")
|
|
66
|
+
self.action_registry = action_registry or ActionRegistry()
|
|
67
|
+
self.session = PromptSession( # type: ignore[var-annotated]
|
|
68
|
+
message=self.prompt_string,
|
|
69
|
+
history=self._create_history(history_path),
|
|
70
|
+
key_bindings=self._create_key_bindings(),
|
|
71
|
+
multiline=True,
|
|
72
|
+
completer=completer, # type: ignore[arg-type]
|
|
73
|
+
**kwargs,
|
|
74
|
+
)
|
|
75
|
+
self.main_app = self.session.app
|
|
76
|
+
|
|
77
|
+
logger.trace("AsyncREPL.__init__() exit")
|
|
78
|
+
|
|
79
|
+
def _create_history(self, path: Optional[Path]) -> Optional[FileHistory]:
|
|
80
|
+
"""
|
|
81
|
+
Create file history if path is provided.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
path: Optional path to history file
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
FileHistory instance or None
|
|
88
|
+
"""
|
|
89
|
+
logger.trace("AsyncREPL._create_history() entry")
|
|
90
|
+
|
|
91
|
+
if path: # pragma: no cover
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
result = FileHistory(str(path))
|
|
94
|
+
logger.trace("AsyncREPL._create_history() exit - with history")
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
logger.trace("AsyncREPL._create_history() exit - no history")
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def _create_key_bindings(self) -> KeyBindings:
|
|
101
|
+
"""
|
|
102
|
+
Create key bindings for the REPL session.
|
|
103
|
+
|
|
104
|
+
This method creates both built-in key bindings and dynamic bindings
|
|
105
|
+
from the action registry, providing a shortcut system.
|
|
106
|
+
|
|
107
|
+
Built-in Key Bindings:
|
|
108
|
+
- Enter: Add new line
|
|
109
|
+
- Alt+Enter: Send message
|
|
110
|
+
- Alt+C: Cancel operation (during processing)
|
|
111
|
+
|
|
112
|
+
Dynamic bindings are loaded from the action registry.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
KeyBindings instance with configured shortcuts
|
|
116
|
+
"""
|
|
117
|
+
logger.trace("AsyncREPL._create_key_bindings() entry")
|
|
118
|
+
|
|
119
|
+
bindings = KeyBindings()
|
|
120
|
+
|
|
121
|
+
# Built-in bindings for core REPL functionality
|
|
122
|
+
@bindings.add("enter") # pragma: no cover
|
|
123
|
+
def _(event):
|
|
124
|
+
"""Handle Enter key - add new line."""
|
|
125
|
+
event.app.current_buffer.insert_text("\n")
|
|
126
|
+
|
|
127
|
+
@bindings.add(Keys.Escape, "enter") # pragma: no cover
|
|
128
|
+
def _(event):
|
|
129
|
+
"""Handle Alt+Enter - send message."""
|
|
130
|
+
event.app.current_buffer.validate_and_handle()
|
|
131
|
+
|
|
132
|
+
# Register dynamic key bindings from action registry
|
|
133
|
+
self._register_action_shortcuts(bindings)
|
|
134
|
+
|
|
135
|
+
logger.trace("AsyncREPL._create_key_bindings() exit")
|
|
136
|
+
return bindings
|
|
137
|
+
|
|
138
|
+
def _register_action_shortcuts(self, bindings: KeyBindings) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Register keyboard shortcuts from the action registry.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
bindings: KeyBindings instance to add shortcuts to
|
|
144
|
+
"""
|
|
145
|
+
logger.trace("AsyncREPL._register_action_shortcuts() entry")
|
|
146
|
+
|
|
147
|
+
if not hasattr(self.action_registry, "key_map"):
|
|
148
|
+
logger.trace("AsyncREPL._register_action_shortcuts() exit - no key_map")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
for key_combo, action_name in self.action_registry.key_map.items():
|
|
152
|
+
self._register_shortcut(bindings, key_combo, action_name)
|
|
153
|
+
|
|
154
|
+
logger.trace("AsyncREPL._register_action_shortcuts() exit")
|
|
155
|
+
|
|
156
|
+
def _register_shortcut(self, bindings: KeyBindings, key_combo: str, action_name: str) -> None:
|
|
157
|
+
"""
|
|
158
|
+
Register a single keyboard shortcut.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
bindings: KeyBindings instance
|
|
162
|
+
key_combo: Key combination string (e.g., "F1", "ctrl-s")
|
|
163
|
+
action_name: Name of action to execute
|
|
164
|
+
"""
|
|
165
|
+
logger.trace("AsyncREPL._register_shortcut() entry")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
# Parse key combination - handle common formats
|
|
169
|
+
keys = self._parse_key_combination(key_combo)
|
|
170
|
+
|
|
171
|
+
@bindings.add(*keys) # pragma: no cover
|
|
172
|
+
def _(event):
|
|
173
|
+
# Execute action synchronously
|
|
174
|
+
try:
|
|
175
|
+
self.action_registry.handle_shortcut(key_combo, event)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.error(f"Error executing shortcut '{key_combo}': {e}")
|
|
178
|
+
print(f"Error: {e}")
|
|
179
|
+
|
|
180
|
+
logger.debug(f"Registered shortcut '{key_combo}' -> '{action_name}'")
|
|
181
|
+
logger.trace("AsyncREPL._register_shortcut() exit - success")
|
|
182
|
+
|
|
183
|
+
except Exception as e: # pragma: no cover
|
|
184
|
+
logger.error(
|
|
185
|
+
f"Failed to register shortcut '{key_combo}' for action '{action_name}': {e}"
|
|
186
|
+
)
|
|
187
|
+
logger.trace("AsyncREPL._register_shortcut() exit - error")
|
|
188
|
+
|
|
189
|
+
def _parse_key_combination(self, key_combo: str) -> tuple:
|
|
190
|
+
"""
|
|
191
|
+
Parse key combination string into prompt_toolkit format.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
key_combo: Key combination (e.g., "F1", "ctrl-s", "alt-enter")
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Tuple of keys for prompt_toolkit
|
|
198
|
+
"""
|
|
199
|
+
logger.trace("AsyncREPL._parse_key_combination() entry")
|
|
200
|
+
|
|
201
|
+
# Handle common key formats
|
|
202
|
+
key_combo = key_combo.lower().strip()
|
|
203
|
+
|
|
204
|
+
# Single function keys
|
|
205
|
+
if key_combo.startswith("f") and key_combo[1:].isdigit():
|
|
206
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - function key")
|
|
207
|
+
return (key_combo,)
|
|
208
|
+
|
|
209
|
+
# Handle modifier combinations
|
|
210
|
+
if "-" in key_combo:
|
|
211
|
+
parts = key_combo.split("-")
|
|
212
|
+
if len(parts) == 2:
|
|
213
|
+
modifier, key = parts
|
|
214
|
+
|
|
215
|
+
# Map common modifiers
|
|
216
|
+
if modifier == "ctrl":
|
|
217
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - ctrl combo")
|
|
218
|
+
return ("c-" + key,)
|
|
219
|
+
elif modifier == "alt":
|
|
220
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - alt combo")
|
|
221
|
+
return (Keys.Escape, key)
|
|
222
|
+
elif modifier == "shift": # pragma: no cover
|
|
223
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - shift combo")
|
|
224
|
+
return ("s-" + key,)
|
|
225
|
+
|
|
226
|
+
# Single keys
|
|
227
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - single key")
|
|
228
|
+
return (key_combo,)
|
|
229
|
+
|
|
230
|
+
async def run(
|
|
231
|
+
self, backend: AsyncBackend, initial_message: Optional[str] = None
|
|
232
|
+
): # pragma: no cover
|
|
233
|
+
"""
|
|
234
|
+
Run the async REPL session with the provided backend.
|
|
235
|
+
|
|
236
|
+
This method accepts the backend at runtime, supporting scenarios where
|
|
237
|
+
the backend is only available within a resource context block.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
backend: Backend responsible for processing user input
|
|
241
|
+
initial_message: Optional message to process before starting loop
|
|
242
|
+
"""
|
|
243
|
+
logger.trace("AsyncREPL.run() entry")
|
|
244
|
+
|
|
245
|
+
# Set backend in action registry for action handlers to access
|
|
246
|
+
self.action_registry.backend = backend # type: ignore[attr-defined]
|
|
247
|
+
|
|
248
|
+
if initial_message:
|
|
249
|
+
print(self.prompt_string, end="")
|
|
250
|
+
print(initial_message)
|
|
251
|
+
await self._process_input(initial_message, backend)
|
|
252
|
+
print()
|
|
253
|
+
|
|
254
|
+
while True:
|
|
255
|
+
try:
|
|
256
|
+
user_input = await self.session.prompt_async()
|
|
257
|
+
if self._should_exit(user_input):
|
|
258
|
+
break
|
|
259
|
+
if not user_input.strip():
|
|
260
|
+
continue
|
|
261
|
+
if user_input.strip().startswith("/"):
|
|
262
|
+
# Handle commands synchronously
|
|
263
|
+
self.action_registry.handle_command(user_input.strip())
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
logger.debug(f"Processing user input: {user_input}")
|
|
267
|
+
await self._process_input(user_input, backend)
|
|
268
|
+
|
|
269
|
+
except (KeyboardInterrupt, EOFError):
|
|
270
|
+
print()
|
|
271
|
+
break
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.error(f"Error in REPL loop: {e}")
|
|
274
|
+
print(f"An error occurred: {e}", file=sys.stderr)
|
|
275
|
+
|
|
276
|
+
logger.trace("AsyncREPL.run() exit")
|
|
277
|
+
|
|
278
|
+
def _should_exit(self, user_input: str) -> bool:
|
|
279
|
+
"""Check if input is an exit command."""
|
|
280
|
+
logger.trace("AsyncREPL._should_exit() entry/exit")
|
|
281
|
+
return user_input.strip().lower() in ["/exit", "/quit"]
|
|
282
|
+
|
|
283
|
+
async def _process_input(self, user_input: str, backend: AsyncBackend): # pragma: no cover
|
|
284
|
+
"""
|
|
285
|
+
Process user input with cancellation support.
|
|
286
|
+
|
|
287
|
+
Runs the backend processing task concurrently with a cancellation
|
|
288
|
+
listener, allowing users to cancel long-running operations.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
user_input: Input string to process
|
|
292
|
+
backend: Backend to process the input
|
|
293
|
+
"""
|
|
294
|
+
logger.trace("AsyncREPL._process_input() entry")
|
|
295
|
+
|
|
296
|
+
cancel_future = asyncio.Future() # type: ignore[var-annotated]
|
|
297
|
+
|
|
298
|
+
kb = KeyBindings()
|
|
299
|
+
|
|
300
|
+
@kb.add("escape", "c")
|
|
301
|
+
def _(event):
|
|
302
|
+
if not cancel_future.done():
|
|
303
|
+
cancel_future.set_result(None)
|
|
304
|
+
event.app.exit()
|
|
305
|
+
|
|
306
|
+
cancel_app = Application(key_bindings=kb, output=DummyOutput(), input=create_input()) # type: ignore[var-annotated]
|
|
307
|
+
|
|
308
|
+
backend_task = asyncio.create_task(backend.handle_input(user_input))
|
|
309
|
+
listener_task = asyncio.create_task(cancel_app.run_async())
|
|
310
|
+
print(THINKING)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
done, pending = await asyncio.wait(
|
|
314
|
+
[backend_task, cancel_future],
|
|
315
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if cancel_future in done:
|
|
319
|
+
print("\nOperation cancelled by user.")
|
|
320
|
+
backend_task.cancel()
|
|
321
|
+
else:
|
|
322
|
+
success = backend_task.result()
|
|
323
|
+
if not success:
|
|
324
|
+
print("Operation failed.")
|
|
325
|
+
|
|
326
|
+
except Exception as e:
|
|
327
|
+
print(f"\nAn error occurred: {e}")
|
|
328
|
+
if not backend_task.done():
|
|
329
|
+
backend_task.cancel()
|
|
330
|
+
|
|
331
|
+
finally:
|
|
332
|
+
# Cleanup
|
|
333
|
+
if not cancel_app.is_done:
|
|
334
|
+
cancel_app.exit()
|
|
335
|
+
|
|
336
|
+
await listener_task
|
|
337
|
+
|
|
338
|
+
self.main_app.renderer.reset()
|
|
339
|
+
self.main_app.invalidate()
|
|
340
|
+
await asyncio.sleep(0)
|
|
341
|
+
|
|
342
|
+
logger.trace("AsyncREPL._process_input() exit")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Convenience function
|
|
346
|
+
async def run_async_repl( # pragma: no cover
|
|
347
|
+
backend: AsyncBackend,
|
|
348
|
+
action_registry: Optional[ActionHandler] = None,
|
|
349
|
+
completer: Optional[Completer] = None,
|
|
350
|
+
initial_message: Optional[str] = None,
|
|
351
|
+
prompt_string: Optional[str] = None,
|
|
352
|
+
history_path: Optional[Path] = None,
|
|
353
|
+
**kwargs,
|
|
354
|
+
):
|
|
355
|
+
"""
|
|
356
|
+
Convenience function to create and run an AsyncREPL with action support.
|
|
357
|
+
|
|
358
|
+
This function creates an AsyncREPL instance and runs it with the provided
|
|
359
|
+
backend, supporting the late backend binding pattern.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
backend: Backend for processing input
|
|
363
|
+
action_registry: Action registry for commands and shortcuts (optional)
|
|
364
|
+
completer: Optional completer
|
|
365
|
+
initial_message: Optional initial message
|
|
366
|
+
prompt_string: Optional custom prompt
|
|
367
|
+
history_path: Optional history file path
|
|
368
|
+
"""
|
|
369
|
+
logger.trace("run_async_repl() entry")
|
|
370
|
+
|
|
371
|
+
repl = AsyncREPL(action_registry, completer, prompt_string, history_path, **kwargs)
|
|
372
|
+
await repl.run(backend, initial_message)
|
|
373
|
+
|
|
374
|
+
logger.trace("run_async_repl() exit")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Completion utilities for REPL toolkit.
|
|
3
|
+
|
|
4
|
+
This module provides completers that can be used with prompt_toolkit:
|
|
5
|
+
- ShellExpansionCompleter: Environment variable and shell command expansion
|
|
6
|
+
- PrefixCompleter: Static string matching with optional prefix character
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .prefix import PrefixCompleter
|
|
10
|
+
from .shell_expansion import ShellExpansionCompleter
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ShellExpansionCompleter",
|
|
14
|
+
"PrefixCompleter",
|
|
15
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prefix-based string completer.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Iterable, List, Optional
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
9
|
+
from prompt_toolkit.document import Document
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PrefixCompleter(Completer):
|
|
13
|
+
"""
|
|
14
|
+
Completer for static string matching with optional prefix character.
|
|
15
|
+
|
|
16
|
+
This completer matches strings from a predefined list. If a prefix is specified,
|
|
17
|
+
it only completes after that prefix character at word boundaries, avoiding false
|
|
18
|
+
positives in paths or other contexts.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
words: List of words to complete
|
|
22
|
+
prefix: Optional prefix character (e.g., '/', '@', '#')
|
|
23
|
+
If provided, only completes after this prefix at word boundaries.
|
|
24
|
+
If None, completes anywhere (standard word completion).
|
|
25
|
+
ignore_case: Case-insensitive matching (default: True)
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
>>> # Slash commands
|
|
29
|
+
>>> completer = PrefixCompleter(['/help', '/exit', '/quit'], prefix='/')
|
|
30
|
+
>>> # Or let it add the prefix
|
|
31
|
+
>>> completer = PrefixCompleter(['help', 'exit', 'quit'], prefix='/')
|
|
32
|
+
|
|
33
|
+
>>> # At-mentions
|
|
34
|
+
>>> completer = PrefixCompleter(['alice', 'bob', 'charlie'], prefix='@')
|
|
35
|
+
|
|
36
|
+
>>> # Hashtags
|
|
37
|
+
>>> completer = PrefixCompleter(['python', 'coding', 'opensource'], prefix='#')
|
|
38
|
+
|
|
39
|
+
>>> # SQL keywords (no prefix)
|
|
40
|
+
>>> completer = PrefixCompleter(['SELECT', 'FROM', 'WHERE'], prefix=None)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, words: List[str], prefix: Optional[str] = None, ignore_case: bool = True):
|
|
44
|
+
"""Initialize the completer."""
|
|
45
|
+
self.prefix = prefix
|
|
46
|
+
self.ignore_case = ignore_case
|
|
47
|
+
|
|
48
|
+
# Normalize words: ensure they have the prefix if specified
|
|
49
|
+
if prefix:
|
|
50
|
+
self.words = [word if word.startswith(prefix) else f"{prefix}{word}" for word in words]
|
|
51
|
+
else:
|
|
52
|
+
self.words = words
|
|
53
|
+
|
|
54
|
+
# Build pattern for matching
|
|
55
|
+
if prefix:
|
|
56
|
+
# Match prefix at start of line or after whitespace/newline
|
|
57
|
+
# This prevents matching "/" in paths like "path/to/file"
|
|
58
|
+
escaped_prefix = re.escape(prefix)
|
|
59
|
+
self.pattern = re.compile(rf"(?:^|[\s\n])({escaped_prefix}\S*)$")
|
|
60
|
+
else:
|
|
61
|
+
# No prefix - match word at cursor
|
|
62
|
+
self.pattern = re.compile(r"(\S*)$")
|
|
63
|
+
|
|
64
|
+
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
|
|
65
|
+
"""
|
|
66
|
+
Get completions for words matching the prefix pattern.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
document: Current document
|
|
70
|
+
complete_event: Completion event
|
|
71
|
+
|
|
72
|
+
Yields:
|
|
73
|
+
Completion objects for matching words
|
|
74
|
+
"""
|
|
75
|
+
# Get text before cursor
|
|
76
|
+
text_before_cursor = document.text_before_cursor
|
|
77
|
+
|
|
78
|
+
# Try to match the pattern
|
|
79
|
+
match = self.pattern.search(text_before_cursor)
|
|
80
|
+
|
|
81
|
+
if not match:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Get the partial word being typed
|
|
85
|
+
partial = match.group(1)
|
|
86
|
+
|
|
87
|
+
# Find matching words
|
|
88
|
+
for word in self.words:
|
|
89
|
+
if self._matches(word, partial):
|
|
90
|
+
# Calculate start position (negative, relative to cursor)
|
|
91
|
+
start_pos = -len(partial)
|
|
92
|
+
|
|
93
|
+
yield Completion(text=word, start_position=start_pos, display=word)
|
|
94
|
+
|
|
95
|
+
def _matches(self, word: str, partial: str) -> bool:
|
|
96
|
+
"""
|
|
97
|
+
Check if word matches partial string.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
word: Complete word from word list
|
|
101
|
+
partial: Partial string typed by user
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if word matches partial
|
|
105
|
+
"""
|
|
106
|
+
if self.ignore_case:
|
|
107
|
+
return word.lower().startswith(partial.lower())
|
|
108
|
+
else:
|
|
109
|
+
return word.startswith(partial)
|