##// END OF EJS Templates
Fix autosuggestions in multi-line mode, vi command mode delay (#13991)...
Matthias Bussonnier -
r28198:4e7b9408 merge
parent child Browse files
Show More
@@ -1,606 +1,622 b''
1 1 """
2 2 Module to define and register Terminal IPython shortcuts with
3 3 :mod:`prompt_toolkit`
4 4 """
5 5
6 6 # Copyright (c) IPython Development Team.
7 7 # Distributed under the terms of the Modified BSD License.
8 8
9 9 import os
10 10 import signal
11 11 import sys
12 12 import warnings
13 13 from dataclasses import dataclass
14 14 from typing import Callable, Any, Optional, List
15 15
16 16 from prompt_toolkit.application.current import get_app
17 17 from prompt_toolkit.key_binding import KeyBindings
18 18 from prompt_toolkit.key_binding.key_processor import KeyPressEvent
19 19 from prompt_toolkit.key_binding.bindings import named_commands as nc
20 20 from prompt_toolkit.key_binding.bindings.completion import (
21 21 display_completions_like_readline,
22 22 )
23 23 from prompt_toolkit.key_binding.vi_state import InputMode, ViState
24 24 from prompt_toolkit.filters import Condition
25 25
26 26 from IPython.core.getipython import get_ipython
27 27 from IPython.terminal.shortcuts import auto_match as match
28 28 from IPython.terminal.shortcuts import auto_suggest
29 29 from IPython.terminal.shortcuts.filters import filter_from_string
30 30 from IPython.utils.decorators import undoc
31 31
32 32 from prompt_toolkit.enums import DEFAULT_BUFFER
33 33
34 34 __all__ = ["create_ipython_shortcuts"]
35 35
36 36
37 37 @dataclass
38 38 class BaseBinding:
39 39 command: Callable[[KeyPressEvent], Any]
40 40 keys: List[str]
41 41
42 42
43 43 @dataclass
44 44 class RuntimeBinding(BaseBinding):
45 45 filter: Condition
46 46
47 47
48 48 @dataclass
49 49 class Binding(BaseBinding):
50 50 # while filter could be created by referencing variables directly (rather
51 51 # than created from strings), by using strings we ensure that users will
52 52 # be able to create filters in configuration (e.g. JSON) files too, which
53 53 # also benefits the documentation by enforcing human-readable filter names.
54 54 condition: Optional[str] = None
55 55
56 56 def __post_init__(self):
57 57 if self.condition:
58 58 self.filter = filter_from_string(self.condition)
59 59 else:
60 60 self.filter = None
61 61
62 62
63 63 def create_identifier(handler: Callable):
64 64 parts = handler.__module__.split(".")
65 65 name = handler.__name__
66 66 package = parts[0]
67 67 if len(parts) > 1:
68 68 final_module = parts[-1]
69 69 return f"{package}:{final_module}.{name}"
70 70 else:
71 71 return f"{package}:{name}"
72 72
73 73
74 74 AUTO_MATCH_BINDINGS = [
75 75 *[
76 76 Binding(
77 77 cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end"
78 78 )
79 79 for key, cmd in match.auto_match_parens.items()
80 80 ],
81 81 *[
82 82 # raw string
83 83 Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix")
84 84 for key, cmd in match.auto_match_parens_raw_string.items()
85 85 ],
86 86 Binding(
87 87 match.double_quote,
88 88 ['"'],
89 89 "focused_insert"
90 90 " & auto_match"
91 91 " & not_inside_unclosed_string"
92 92 " & preceded_by_paired_double_quotes"
93 93 " & followed_by_closing_paren_or_end",
94 94 ),
95 95 Binding(
96 96 match.single_quote,
97 97 ["'"],
98 98 "focused_insert"
99 99 " & auto_match"
100 100 " & not_inside_unclosed_string"
101 101 " & preceded_by_paired_single_quotes"
102 102 " & followed_by_closing_paren_or_end",
103 103 ),
104 104 Binding(
105 105 match.docstring_double_quotes,
106 106 ['"'],
107 107 "focused_insert"
108 108 " & auto_match"
109 109 " & not_inside_unclosed_string"
110 110 " & preceded_by_two_double_quotes",
111 111 ),
112 112 Binding(
113 113 match.docstring_single_quotes,
114 114 ["'"],
115 115 "focused_insert"
116 116 " & auto_match"
117 117 " & not_inside_unclosed_string"
118 118 " & preceded_by_two_single_quotes",
119 119 ),
120 120 Binding(
121 121 match.skip_over,
122 122 [")"],
123 123 "focused_insert & auto_match & followed_by_closing_round_paren",
124 124 ),
125 125 Binding(
126 126 match.skip_over,
127 127 ["]"],
128 128 "focused_insert & auto_match & followed_by_closing_bracket",
129 129 ),
130 130 Binding(
131 131 match.skip_over,
132 132 ["}"],
133 133 "focused_insert & auto_match & followed_by_closing_brace",
134 134 ),
135 135 Binding(
136 136 match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote"
137 137 ),
138 138 Binding(
139 139 match.skip_over, ["'"], "focused_insert & auto_match & followed_by_single_quote"
140 140 ),
141 141 Binding(
142 142 match.delete_pair,
143 143 ["backspace"],
144 144 "focused_insert"
145 145 " & preceded_by_opening_round_paren"
146 146 " & auto_match"
147 147 " & followed_by_closing_round_paren",
148 148 ),
149 149 Binding(
150 150 match.delete_pair,
151 151 ["backspace"],
152 152 "focused_insert"
153 153 " & preceded_by_opening_bracket"
154 154 " & auto_match"
155 155 " & followed_by_closing_bracket",
156 156 ),
157 157 Binding(
158 158 match.delete_pair,
159 159 ["backspace"],
160 160 "focused_insert"
161 161 " & preceded_by_opening_brace"
162 162 " & auto_match"
163 163 " & followed_by_closing_brace",
164 164 ),
165 165 Binding(
166 166 match.delete_pair,
167 167 ["backspace"],
168 168 "focused_insert"
169 169 " & preceded_by_double_quote"
170 170 " & auto_match"
171 171 " & followed_by_double_quote",
172 172 ),
173 173 Binding(
174 174 match.delete_pair,
175 175 ["backspace"],
176 176 "focused_insert"
177 177 " & preceded_by_single_quote"
178 178 " & auto_match"
179 179 " & followed_by_single_quote",
180 180 ),
181 181 ]
182 182
183 183 AUTO_SUGGEST_BINDINGS = [
184 # there are two reasons for re-defining bindings defined upstream:
185 # 1) prompt-toolkit does not execute autosuggestion bindings in vi mode,
186 # 2) prompt-toolkit checks if we are at the end of text, not end of line
187 # hence it does not work in multi-line mode of navigable provider
184 188 Binding(
185 auto_suggest.accept_in_vi_insert_mode,
189 auto_suggest.accept_or_jump_to_end,
186 190 ["end"],
187 "default_buffer_focused & (ebivim | ~vi_insert_mode)",
191 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
188 192 ),
189 193 Binding(
190 auto_suggest.accept_in_vi_insert_mode,
194 auto_suggest.accept_or_jump_to_end,
191 195 ["c-e"],
192 "vi_insert_mode & default_buffer_focused & ebivim",
196 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
197 ),
198 Binding(
199 auto_suggest.accept,
200 ["c-f"],
201 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
202 ),
203 Binding(
204 auto_suggest.accept,
205 ["right"],
206 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
193 207 ),
194 Binding(auto_suggest.accept, ["c-f"], "vi_insert_mode & default_buffer_focused"),
195 208 Binding(
196 209 auto_suggest.accept_word,
197 210 ["escape", "f"],
198 "vi_insert_mode & default_buffer_focused & ebivim",
211 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
199 212 ),
200 213 Binding(
201 214 auto_suggest.accept_token,
202 215 ["c-right"],
203 "has_suggestion & default_buffer_focused",
216 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
204 217 ),
205 218 Binding(
206 219 auto_suggest.discard,
207 220 ["escape"],
221 # note this one is using `emacs_insert_mode`, not `emacs_like_insert_mode`
222 # as in `vi_insert_mode` we do not want `escape` to be shadowed (ever).
208 223 "has_suggestion & default_buffer_focused & emacs_insert_mode",
209 224 ),
210 225 Binding(
211 226 auto_suggest.discard,
212 227 ["delete"],
213 228 "has_suggestion & default_buffer_focused & emacs_insert_mode",
214 229 ),
215 230 Binding(
216 231 auto_suggest.swap_autosuggestion_up,
217 232 ["up"],
218 233 "navigable_suggestions"
219 234 " & ~has_line_above"
220 235 " & has_suggestion"
221 236 " & default_buffer_focused",
222 237 ),
223 238 Binding(
224 239 auto_suggest.swap_autosuggestion_down,
225 240 ["down"],
226 241 "navigable_suggestions"
227 242 " & ~has_line_below"
228 243 " & has_suggestion"
229 244 " & default_buffer_focused",
230 245 ),
231 246 Binding(
232 247 auto_suggest.up_and_update_hint,
233 248 ["up"],
234 249 "has_line_above & navigable_suggestions & default_buffer_focused",
235 250 ),
236 251 Binding(
237 252 auto_suggest.down_and_update_hint,
238 253 ["down"],
239 254 "has_line_below & navigable_suggestions & default_buffer_focused",
240 255 ),
241 256 Binding(
242 257 auto_suggest.accept_character,
243 258 ["escape", "right"],
244 "has_suggestion & default_buffer_focused",
259 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
245 260 ),
246 261 Binding(
247 262 auto_suggest.accept_and_move_cursor_left,
248 263 ["c-left"],
249 "has_suggestion & default_buffer_focused",
264 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
250 265 ),
251 266 Binding(
252 267 auto_suggest.accept_and_keep_cursor,
253 268 ["c-down"],
254 "has_suggestion & default_buffer_focused",
269 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
255 270 ),
256 271 Binding(
257 272 auto_suggest.backspace_and_resume_hint,
258 273 ["backspace"],
259 "has_suggestion & default_buffer_focused",
274 # no `has_suggestion` here to allow resuming if no suggestion
275 "default_buffer_focused & emacs_like_insert_mode",
260 276 ),
261 277 ]
262 278
263 279
264 280 SIMPLE_CONTROL_BINDINGS = [
265 281 Binding(cmd, [key], "vi_insert_mode & default_buffer_focused & ebivim")
266 282 for key, cmd in {
267 283 "c-a": nc.beginning_of_line,
268 284 "c-b": nc.backward_char,
269 285 "c-k": nc.kill_line,
270 286 "c-w": nc.backward_kill_word,
271 287 "c-y": nc.yank,
272 288 "c-_": nc.undo,
273 289 }.items()
274 290 ]
275 291
276 292
277 293 ALT_AND_COMOBO_CONTROL_BINDINGS = [
278 294 Binding(cmd, list(keys), "vi_insert_mode & default_buffer_focused & ebivim")
279 295 for keys, cmd in {
280 296 # Control Combos
281 297 ("c-x", "c-e"): nc.edit_and_execute,
282 298 ("c-x", "e"): nc.edit_and_execute,
283 299 # Alt
284 300 ("escape", "b"): nc.backward_word,
285 301 ("escape", "c"): nc.capitalize_word,
286 302 ("escape", "d"): nc.kill_word,
287 303 ("escape", "h"): nc.backward_kill_word,
288 304 ("escape", "l"): nc.downcase_word,
289 305 ("escape", "u"): nc.uppercase_word,
290 306 ("escape", "y"): nc.yank_pop,
291 307 ("escape", "."): nc.yank_last_arg,
292 308 }.items()
293 309 ]
294 310
295 311
296 312 def add_binding(bindings: KeyBindings, binding: Binding):
297 313 bindings.add(
298 314 *binding.keys,
299 315 **({"filter": binding.filter} if binding.filter is not None else {}),
300 316 )(binding.command)
301 317
302 318
303 319 def create_ipython_shortcuts(shell, skip=None) -> KeyBindings:
304 320 """Set up the prompt_toolkit keyboard shortcuts for IPython.
305 321
306 322 Parameters
307 323 ----------
308 324 shell: InteractiveShell
309 325 The current IPython shell Instance
310 326 skip: List[Binding]
311 327 Bindings to skip.
312 328
313 329 Returns
314 330 -------
315 331 KeyBindings
316 332 the keybinding instance for prompt toolkit.
317 333
318 334 """
319 335 kb = KeyBindings()
320 336 skip = skip or []
321 337 for binding in KEY_BINDINGS:
322 338 skip_this_one = False
323 339 for to_skip in skip:
324 340 if (
325 341 to_skip.command == binding.command
326 342 and to_skip.filter == binding.filter
327 343 and to_skip.keys == binding.keys
328 344 ):
329 345 skip_this_one = True
330 346 break
331 347 if skip_this_one:
332 348 continue
333 349 add_binding(kb, binding)
334 350
335 351 def get_input_mode(self):
336 352 app = get_app()
337 353 app.ttimeoutlen = shell.ttimeoutlen
338 354 app.timeoutlen = shell.timeoutlen
339 355
340 356 return self._input_mode
341 357
342 358 def set_input_mode(self, mode):
343 359 shape = {InputMode.NAVIGATION: 2, InputMode.REPLACE: 4}.get(mode, 6)
344 360 cursor = "\x1b[{} q".format(shape)
345 361
346 362 sys.stdout.write(cursor)
347 363 sys.stdout.flush()
348 364
349 365 self._input_mode = mode
350 366
351 367 if shell.editing_mode == "vi" and shell.modal_cursor:
352 368 ViState._input_mode = InputMode.INSERT # type: ignore
353 369 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
354 370
355 371 return kb
356 372
357 373
358 374 def reformat_and_execute(event):
359 375 """Reformat code and execute it"""
360 376 shell = get_ipython()
361 377 reformat_text_before_cursor(
362 378 event.current_buffer, event.current_buffer.document, shell
363 379 )
364 380 event.current_buffer.validate_and_handle()
365 381
366 382
367 383 def reformat_text_before_cursor(buffer, document, shell):
368 384 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
369 385 try:
370 386 formatted_text = shell.reformat_handler(text)
371 387 buffer.insert_text(formatted_text)
372 388 except Exception as e:
373 389 buffer.insert_text(text)
374 390
375 391
376 392 def handle_return_or_newline_or_execute(event):
377 393 shell = get_ipython()
378 394 if getattr(shell, "handle_return", None):
379 395 return shell.handle_return(shell)(event)
380 396 else:
381 397 return newline_or_execute_outer(shell)(event)
382 398
383 399
384 400 def newline_or_execute_outer(shell):
385 401 def newline_or_execute(event):
386 402 """When the user presses return, insert a newline or execute the code."""
387 403 b = event.current_buffer
388 404 d = b.document
389 405
390 406 if b.complete_state:
391 407 cc = b.complete_state.current_completion
392 408 if cc:
393 409 b.apply_completion(cc)
394 410 else:
395 411 b.cancel_completion()
396 412 return
397 413
398 414 # If there's only one line, treat it as if the cursor is at the end.
399 415 # See https://github.com/ipython/ipython/issues/10425
400 416 if d.line_count == 1:
401 417 check_text = d.text
402 418 else:
403 419 check_text = d.text[: d.cursor_position]
404 420 status, indent = shell.check_complete(check_text)
405 421
406 422 # if all we have after the cursor is whitespace: reformat current text
407 423 # before cursor
408 424 after_cursor = d.text[d.cursor_position :]
409 425 reformatted = False
410 426 if not after_cursor.strip():
411 427 reformat_text_before_cursor(b, d, shell)
412 428 reformatted = True
413 429 if not (
414 430 d.on_last_line
415 431 or d.cursor_position_row >= d.line_count - d.empty_line_count_at_the_end()
416 432 ):
417 433 if shell.autoindent:
418 434 b.insert_text("\n" + indent)
419 435 else:
420 436 b.insert_text("\n")
421 437 return
422 438
423 439 if (status != "incomplete") and b.accept_handler:
424 440 if not reformatted:
425 441 reformat_text_before_cursor(b, d, shell)
426 442 b.validate_and_handle()
427 443 else:
428 444 if shell.autoindent:
429 445 b.insert_text("\n" + indent)
430 446 else:
431 447 b.insert_text("\n")
432 448
433 449 return newline_or_execute
434 450
435 451
436 452 def previous_history_or_previous_completion(event):
437 453 """
438 454 Control-P in vi edit mode on readline is history next, unlike default prompt toolkit.
439 455
440 456 If completer is open this still select previous completion.
441 457 """
442 458 event.current_buffer.auto_up()
443 459
444 460
445 461 def next_history_or_next_completion(event):
446 462 """
447 463 Control-N in vi edit mode on readline is history previous, unlike default prompt toolkit.
448 464
449 465 If completer is open this still select next completion.
450 466 """
451 467 event.current_buffer.auto_down()
452 468
453 469
454 470 def dismiss_completion(event):
455 471 """Dismiss completion"""
456 472 b = event.current_buffer
457 473 if b.complete_state:
458 474 b.cancel_completion()
459 475
460 476
461 477 def reset_buffer(event):
462 478 """Reset buffer"""
463 479 b = event.current_buffer
464 480 if b.complete_state:
465 481 b.cancel_completion()
466 482 else:
467 483 b.reset()
468 484
469 485
470 486 def reset_search_buffer(event):
471 487 """Reset search buffer"""
472 488 if event.current_buffer.document.text:
473 489 event.current_buffer.reset()
474 490 else:
475 491 event.app.layout.focus(DEFAULT_BUFFER)
476 492
477 493
478 494 def suspend_to_bg(event):
479 495 """Suspend to background"""
480 496 event.app.suspend_to_background()
481 497
482 498
483 499 def quit(event):
484 500 """
485 501 Quit application with ``SIGQUIT`` if supported or ``sys.exit`` otherwise.
486 502
487 503 On platforms that support SIGQUIT, send SIGQUIT to the current process.
488 504 On other platforms, just exit the process with a message.
489 505 """
490 506 sigquit = getattr(signal, "SIGQUIT", None)
491 507 if sigquit is not None:
492 508 os.kill(0, signal.SIGQUIT)
493 509 else:
494 510 sys.exit("Quit")
495 511
496 512
497 513 def indent_buffer(event):
498 514 """Indent buffer"""
499 515 event.current_buffer.insert_text(" " * 4)
500 516
501 517
502 518 def newline_autoindent(event):
503 519 """Insert a newline after the cursor indented appropriately.
504 520
505 521 Fancier version of former ``newline_with_copy_margin`` which should
506 522 compute the correct indentation of the inserted line. That is to say, indent
507 523 by 4 extra space after a function definition, class definition, context
508 524 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
509 525 """
510 526 shell = get_ipython()
511 527 inputsplitter = shell.input_transformer_manager
512 528 b = event.current_buffer
513 529 d = b.document
514 530
515 531 if b.complete_state:
516 532 b.cancel_completion()
517 533 text = d.text[: d.cursor_position] + "\n"
518 534 _, indent = inputsplitter.check_complete(text)
519 535 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
520 536
521 537
522 538 def open_input_in_editor(event):
523 539 """Open code from input in external editor"""
524 540 event.app.current_buffer.open_in_editor()
525 541
526 542
527 543 if sys.platform == "win32":
528 544 from IPython.core.error import TryNext
529 545 from IPython.lib.clipboard import (
530 546 ClipboardEmpty,
531 547 tkinter_clipboard_get,
532 548 win32_clipboard_get,
533 549 )
534 550
535 551 @undoc
536 552 def win_paste(event):
537 553 try:
538 554 text = win32_clipboard_get()
539 555 except TryNext:
540 556 try:
541 557 text = tkinter_clipboard_get()
542 558 except (TryNext, ClipboardEmpty):
543 559 return
544 560 except ClipboardEmpty:
545 561 return
546 562 event.current_buffer.insert_text(text.replace("\t", " " * 4))
547 563
548 564 else:
549 565
550 566 @undoc
551 567 def win_paste(event):
552 568 """Stub used on other platforms"""
553 569 pass
554 570
555 571
556 572 KEY_BINDINGS = [
557 573 Binding(
558 574 handle_return_or_newline_or_execute,
559 575 ["enter"],
560 576 "default_buffer_focused & ~has_selection & insert_mode",
561 577 ),
562 578 Binding(
563 579 reformat_and_execute,
564 580 ["escape", "enter"],
565 581 "default_buffer_focused & ~has_selection & insert_mode & ebivim",
566 582 ),
567 583 Binding(quit, ["c-\\"]),
568 584 Binding(
569 585 previous_history_or_previous_completion,
570 586 ["c-p"],
571 587 "vi_insert_mode & default_buffer_focused",
572 588 ),
573 589 Binding(
574 590 next_history_or_next_completion,
575 591 ["c-n"],
576 592 "vi_insert_mode & default_buffer_focused",
577 593 ),
578 594 Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"),
579 595 Binding(reset_buffer, ["c-c"], "default_buffer_focused"),
580 596 Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"),
581 597 Binding(suspend_to_bg, ["c-z"], "supports_suspend"),
582 598 Binding(
583 599 indent_buffer,
584 600 ["tab"], # Ctrl+I == Tab
585 601 "default_buffer_focused"
586 602 " & ~has_selection"
587 603 " & insert_mode"
588 604 " & cursor_in_leading_ws",
589 605 ),
590 606 Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"),
591 607 Binding(open_input_in_editor, ["f2"], "default_buffer_focused"),
592 608 *AUTO_MATCH_BINDINGS,
593 609 *AUTO_SUGGEST_BINDINGS,
594 610 Binding(
595 611 display_completions_like_readline,
596 612 ["c-i"],
597 613 "readline_like_completions"
598 614 " & default_buffer_focused"
599 615 " & ~has_selection"
600 616 " & insert_mode"
601 617 " & ~cursor_in_leading_ws",
602 618 ),
603 619 Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"),
604 620 *SIMPLE_CONTROL_BINDINGS,
605 621 *ALT_AND_COMOBO_CONTROL_BINDINGS,
606 622 ]
@@ -1,375 +1,397 b''
1 1 import re
2 2 import tokenize
3 3 from io import StringIO
4 4 from typing import Callable, List, Optional, Union, Generator, Tuple
5 import warnings
5 6
6 7 from prompt_toolkit.buffer import Buffer
7 8 from prompt_toolkit.key_binding import KeyPressEvent
8 9 from prompt_toolkit.key_binding.bindings import named_commands as nc
9 10 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
10 11 from prompt_toolkit.document import Document
11 12 from prompt_toolkit.history import History
12 13 from prompt_toolkit.shortcuts import PromptSession
13 14 from prompt_toolkit.layout.processors import (
14 15 Processor,
15 16 Transformation,
16 17 TransformationInput,
17 18 )
18 19
19 20 from IPython.core.getipython import get_ipython
20 21 from IPython.utils.tokenutil import generate_tokens
21 22
22 23
23 24 def _get_query(document: Document):
24 25 return document.lines[document.cursor_position_row]
25 26
26 27
27 28 class AppendAutoSuggestionInAnyLine(Processor):
28 29 """
29 30 Append the auto suggestion to lines other than the last (appending to the
30 31 last line is natively supported by the prompt toolkit).
31 32 """
32 33
33 34 def __init__(self, style: str = "class:auto-suggestion") -> None:
34 35 self.style = style
35 36
36 37 def apply_transformation(self, ti: TransformationInput) -> Transformation:
37 38 is_last_line = ti.lineno == ti.document.line_count - 1
38 39 is_active_line = ti.lineno == ti.document.cursor_position_row
39 40
40 41 if not is_last_line and is_active_line:
41 42 buffer = ti.buffer_control.buffer
42 43
43 44 if buffer.suggestion and ti.document.is_cursor_at_the_end_of_line:
44 45 suggestion = buffer.suggestion.text
45 46 else:
46 47 suggestion = ""
47 48
48 49 return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
49 50 else:
50 51 return Transformation(fragments=ti.fragments)
51 52
52 53
53 54 class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
54 55 """
55 56 A subclass of AutoSuggestFromHistory that allow navigation to next/previous
56 57 suggestion from history. To do so it remembers the current position, but it
57 58 state need to carefully be cleared on the right events.
58 59 """
59 60
60 61 def __init__(
61 62 self,
62 63 ):
63 64 self.skip_lines = 0
64 65 self._connected_apps = []
65 66
66 67 def reset_history_position(self, _: Buffer):
67 68 self.skip_lines = 0
68 69
69 70 def disconnect(self):
70 71 for pt_app in self._connected_apps:
71 72 text_insert_event = pt_app.default_buffer.on_text_insert
72 73 text_insert_event.remove_handler(self.reset_history_position)
73 74
74 75 def connect(self, pt_app: PromptSession):
75 76 self._connected_apps.append(pt_app)
76 77 # note: `on_text_changed` could be used for a bit different behaviour
77 78 # on character deletion (i.e. reseting history position on backspace)
78 79 pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
79 80 pt_app.default_buffer.on_cursor_position_changed.add_handler(self._dismiss)
80 81
81 82 def get_suggestion(
82 83 self, buffer: Buffer, document: Document
83 84 ) -> Optional[Suggestion]:
84 85 text = _get_query(document)
85 86
86 87 if text.strip():
87 88 for suggestion, _ in self._find_next_match(
88 89 text, self.skip_lines, buffer.history
89 90 ):
90 91 return Suggestion(suggestion)
91 92
92 93 return None
93 94
94 95 def _dismiss(self, buffer, *args, **kwargs):
95 96 buffer.suggestion = None
96 97
97 98 def _find_match(
98 99 self, text: str, skip_lines: float, history: History, previous: bool
99 100 ) -> Generator[Tuple[str, float], None, None]:
100 101 """
101 102 text : str
102 103 Text content to find a match for, the user cursor is most of the
103 104 time at the end of this text.
104 105 skip_lines : float
105 106 number of items to skip in the search, this is used to indicate how
106 107 far in the list the user has navigated by pressing up or down.
107 108 The float type is used as the base value is +inf
108 109 history : History
109 110 prompt_toolkit History instance to fetch previous entries from.
110 111 previous : bool
111 112 Direction of the search, whether we are looking previous match
112 113 (True), or next match (False).
113 114
114 115 Yields
115 116 ------
116 117 Tuple with:
117 118 str:
118 119 current suggestion.
119 120 float:
120 121 will actually yield only ints, which is passed back via skip_lines,
121 122 which may be a +inf (float)
122 123
123 124
124 125 """
125 126 line_number = -1
126 127 for string in reversed(list(history.get_strings())):
127 128 for line in reversed(string.splitlines()):
128 129 line_number += 1
129 130 if not previous and line_number < skip_lines:
130 131 continue
131 132 # do not return empty suggestions as these
132 133 # close the auto-suggestion overlay (and are useless)
133 134 if line.startswith(text) and len(line) > len(text):
134 135 yield line[len(text) :], line_number
135 136 if previous and line_number >= skip_lines:
136 137 return
137 138
138 139 def _find_next_match(
139 140 self, text: str, skip_lines: float, history: History
140 141 ) -> Generator[Tuple[str, float], None, None]:
141 142 return self._find_match(text, skip_lines, history, previous=False)
142 143
143 144 def _find_previous_match(self, text: str, skip_lines: float, history: History):
144 145 return reversed(
145 146 list(self._find_match(text, skip_lines, history, previous=True))
146 147 )
147 148
148 149 def up(self, query: str, other_than: str, history: History) -> None:
149 150 for suggestion, line_number in self._find_next_match(
150 151 query, self.skip_lines, history
151 152 ):
152 153 # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
153 154 # we want to switch from 'very.b' to 'very.a' because a) if the
154 155 # suggestion equals current text, prompt-toolkit aborts suggesting
155 156 # b) user likely would not be interested in 'very' anyways (they
156 157 # already typed it).
157 158 if query + suggestion != other_than:
158 159 self.skip_lines = line_number
159 160 break
160 161 else:
161 162 # no matches found, cycle back to beginning
162 163 self.skip_lines = 0
163 164
164 165 def down(self, query: str, other_than: str, history: History) -> None:
165 166 for suggestion, line_number in self._find_previous_match(
166 167 query, self.skip_lines, history
167 168 ):
168 169 if query + suggestion != other_than:
169 170 self.skip_lines = line_number
170 171 break
171 172 else:
172 173 # no matches found, cycle to end
173 174 for suggestion, line_number in self._find_previous_match(
174 175 query, float("Inf"), history
175 176 ):
176 177 if query + suggestion != other_than:
177 178 self.skip_lines = line_number
178 179 break
179 180
180 181
181 # Needed for to accept autosuggestions in vi insert mode
182 def accept_in_vi_insert_mode(event: KeyPressEvent):
183 """Apply autosuggestion if at end of line."""
182 def accept_or_jump_to_end(event: KeyPressEvent):
183 """Apply autosuggestion or jump to end of line."""
184 184 buffer = event.current_buffer
185 185 d = buffer.document
186 186 after_cursor = d.text[d.cursor_position :]
187 187 lines = after_cursor.split("\n")
188 188 end_of_current_line = lines[0].strip()
189 189 suggestion = buffer.suggestion
190 190 if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
191 191 buffer.insert_text(suggestion.text)
192 192 else:
193 193 nc.end_of_line(event)
194 194
195 195
196 def _deprected_accept_in_vi_insert_mode(event: KeyPressEvent):
197 """Accept autosuggestion or jump to end of line.
198
199 .. deprecated:: 8.12
200 Use `accept_or_jump_to_end` instead.
201 """
202 return accept_or_jump_to_end(event)
203
204
196 205 def accept(event: KeyPressEvent):
197 206 """Accept autosuggestion"""
198 207 buffer = event.current_buffer
199 208 suggestion = buffer.suggestion
200 209 if suggestion:
201 210 buffer.insert_text(suggestion.text)
202 211 else:
203 212 nc.forward_char(event)
204 213
205 214
206 215 def discard(event: KeyPressEvent):
207 216 """Discard autosuggestion"""
208 217 buffer = event.current_buffer
209 218 buffer.suggestion = None
210 219
211 220
212 221 def accept_word(event: KeyPressEvent):
213 222 """Fill partial autosuggestion by word"""
214 223 buffer = event.current_buffer
215 224 suggestion = buffer.suggestion
216 225 if suggestion:
217 226 t = re.split(r"(\S+\s+)", suggestion.text)
218 227 buffer.insert_text(next((x for x in t if x), ""))
219 228 else:
220 229 nc.forward_word(event)
221 230
222 231
223 232 def accept_character(event: KeyPressEvent):
224 233 """Fill partial autosuggestion by character"""
225 234 b = event.current_buffer
226 235 suggestion = b.suggestion
227 236 if suggestion and suggestion.text:
228 237 b.insert_text(suggestion.text[0])
229 238
230 239
231 240 def accept_and_keep_cursor(event: KeyPressEvent):
232 241 """Accept autosuggestion and keep cursor in place"""
233 242 buffer = event.current_buffer
234 243 old_position = buffer.cursor_position
235 244 suggestion = buffer.suggestion
236 245 if suggestion:
237 246 buffer.insert_text(suggestion.text)
238 247 buffer.cursor_position = old_position
239 248
240 249
241 250 def accept_and_move_cursor_left(event: KeyPressEvent):
242 251 """Accept autosuggestion and move cursor left in place"""
243 252 accept_and_keep_cursor(event)
244 253 nc.backward_char(event)
245 254
246 255
247 256 def _update_hint(buffer: Buffer):
248 257 if buffer.auto_suggest:
249 258 suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
250 259 buffer.suggestion = suggestion
251 260
252 261
253 262 def backspace_and_resume_hint(event: KeyPressEvent):
254 263 """Resume autosuggestions after deleting last character"""
255 264 current_buffer = event.current_buffer
256 265
257 266 def resume_hinting(buffer: Buffer):
258 267 _update_hint(buffer)
259 268 current_buffer.on_text_changed.remove_handler(resume_hinting)
260 269
261 270 current_buffer.on_text_changed.add_handler(resume_hinting)
262 271 nc.backward_delete_char(event)
263 272
264 273
265 274 def up_and_update_hint(event: KeyPressEvent):
266 275 """Go up and update hint"""
267 276 current_buffer = event.current_buffer
268 277
269 278 current_buffer.auto_up(count=event.arg)
270 279 _update_hint(current_buffer)
271 280
272 281
273 282 def down_and_update_hint(event: KeyPressEvent):
274 283 """Go down and update hint"""
275 284 current_buffer = event.current_buffer
276 285
277 286 current_buffer.auto_down(count=event.arg)
278 287 _update_hint(current_buffer)
279 288
280 289
281 290 def accept_token(event: KeyPressEvent):
282 291 """Fill partial autosuggestion by token"""
283 292 b = event.current_buffer
284 293 suggestion = b.suggestion
285 294
286 295 if suggestion:
287 296 prefix = _get_query(b.document)
288 297 text = prefix + suggestion.text
289 298
290 299 tokens: List[Optional[str]] = [None, None, None]
291 300 substrings = [""]
292 301 i = 0
293 302
294 303 for token in generate_tokens(StringIO(text).readline):
295 304 if token.type == tokenize.NEWLINE:
296 305 index = len(text)
297 306 else:
298 307 index = text.index(token[1], len(substrings[-1]))
299 308 substrings.append(text[:index])
300 309 tokenized_so_far = substrings[-1]
301 310 if tokenized_so_far.startswith(prefix):
302 311 if i == 0 and len(tokenized_so_far) > len(prefix):
303 312 tokens[0] = tokenized_so_far[len(prefix) :]
304 313 substrings.append(tokenized_so_far)
305 314 i += 1
306 315 tokens[i] = token[1]
307 316 if i == 2:
308 317 break
309 318 i += 1
310 319
311 320 if tokens[0]:
312 321 to_insert: str
313 322 insert_text = substrings[-2]
314 323 if tokens[1] and len(tokens[1]) == 1:
315 324 insert_text = substrings[-1]
316 325 to_insert = insert_text[len(prefix) :]
317 326 b.insert_text(to_insert)
318 327 return
319 328
320 329 nc.forward_word(event)
321 330
322 331
323 332 Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
324 333
325 334
326 335 def _swap_autosuggestion(
327 336 buffer: Buffer,
328 337 provider: NavigableAutoSuggestFromHistory,
329 338 direction_method: Callable,
330 339 ):
331 340 """
332 341 We skip most recent history entry (in either direction) if it equals the
333 342 current autosuggestion because if user cycles when auto-suggestion is shown
334 343 they most likely want something else than what was suggested (otherwise
335 344 they would have accepted the suggestion).
336 345 """
337 346 suggestion = buffer.suggestion
338 347 if not suggestion:
339 348 return
340 349
341 350 query = _get_query(buffer.document)
342 351 current = query + suggestion.text
343 352
344 353 direction_method(query=query, other_than=current, history=buffer.history)
345 354
346 355 new_suggestion = provider.get_suggestion(buffer, buffer.document)
347 356 buffer.suggestion = new_suggestion
348 357
349 358
350 359 def swap_autosuggestion_up(event: KeyPressEvent):
351 360 """Get next autosuggestion from history."""
352 361 shell = get_ipython()
353 362 provider = shell.auto_suggest
354 363
355 364 if not isinstance(provider, NavigableAutoSuggestFromHistory):
356 365 return
357 366
358 367 return _swap_autosuggestion(
359 368 buffer=event.current_buffer, provider=provider, direction_method=provider.up
360 369 )
361 370
362 371
363 372 def swap_autosuggestion_down(event: KeyPressEvent):
364 373 """Get previous autosuggestion from history."""
365 374 shell = get_ipython()
366 375 provider = shell.auto_suggest
367 376
368 377 if not isinstance(provider, NavigableAutoSuggestFromHistory):
369 378 return
370 379
371 380 return _swap_autosuggestion(
372 381 buffer=event.current_buffer,
373 382 provider=provider,
374 383 direction_method=provider.down,
375 384 )
385
386
387 def __getattr__(key):
388 if key == "accept_in_vi_insert_mode":
389 warnings.warn(
390 "`accept_in_vi_insert_mode` is deprecated since IPython 8.12 and "
391 "renamed to `accept_or_jump_to_end`. Please update your configuration "
392 "accordingly",
393 DeprecationWarning,
394 stacklevel=2,
395 )
396 return _deprected_accept_in_vi_insert_mode
397 raise AttributeError
@@ -1,256 +1,279 b''
1 1 """
2 2 Filters restricting scope of IPython Terminal shortcuts.
3 3 """
4 4
5 5 # Copyright (c) IPython Development Team.
6 6 # Distributed under the terms of the Modified BSD License.
7 7
8 8 import ast
9 9 import re
10 10 import signal
11 11 import sys
12 12 from typing import Callable, Dict, Union
13 13
14 14 from prompt_toolkit.application.current import get_app
15 15 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
16 16 from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
17 17 from prompt_toolkit.filters import has_focus as has_focus_impl
18 18 from prompt_toolkit.filters import (
19 19 Always,
20 20 has_selection,
21 21 has_suggestion,
22 22 vi_insert_mode,
23 23 vi_mode,
24 24 )
25 25 from prompt_toolkit.layout.layout import FocusableElement
26 26
27 27 from IPython.core.getipython import get_ipython
28 28 from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
29 29 from IPython.terminal.shortcuts import auto_suggest
30 30 from IPython.utils.decorators import undoc
31 31
32 32
33 33 @undoc
34 34 @Condition
35 35 def cursor_in_leading_ws():
36 36 before = get_app().current_buffer.document.current_line_before_cursor
37 37 return (not before) or before.isspace()
38 38
39 39
40 40 def has_focus(value: FocusableElement):
41 41 """Wrapper around has_focus adding a nice `__name__` to tester function"""
42 42 tester = has_focus_impl(value).func
43 43 tester.__name__ = f"is_focused({value})"
44 44 return Condition(tester)
45 45
46 46
47 47 @undoc
48 48 @Condition
49 49 def has_line_below() -> bool:
50 50 document = get_app().current_buffer.document
51 51 return document.cursor_position_row < len(document.lines) - 1
52 52
53 53
54 54 @undoc
55 55 @Condition
56 56 def has_line_above() -> bool:
57 57 document = get_app().current_buffer.document
58 58 return document.cursor_position_row != 0
59 59
60 60
61 61 @Condition
62 62 def ebivim():
63 63 shell = get_ipython()
64 64 return shell.emacs_bindings_in_vi_insert_mode
65 65
66 66
67 67 @Condition
68 68 def supports_suspend():
69 69 return hasattr(signal, "SIGTSTP")
70 70
71 71
72 72 @Condition
73 73 def auto_match():
74 74 shell = get_ipython()
75 75 return shell.auto_match
76 76
77 77
78 78 def all_quotes_paired(quote, buf):
79 79 paired = True
80 80 i = 0
81 81 while i < len(buf):
82 82 c = buf[i]
83 83 if c == quote:
84 84 paired = not paired
85 85 elif c == "\\":
86 86 i += 1
87 87 i += 1
88 88 return paired
89 89
90 90
91 91 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
92 92 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
93 93
94 94
95 95 def preceding_text(pattern: Union[str, Callable]):
96 96 if pattern in _preceding_text_cache:
97 97 return _preceding_text_cache[pattern]
98 98
99 99 if callable(pattern):
100 100
101 101 def _preceding_text():
102 102 app = get_app()
103 103 before_cursor = app.current_buffer.document.current_line_before_cursor
104 104 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
105 105 return bool(pattern(before_cursor)) # type: ignore[operator]
106 106
107 107 else:
108 108 m = re.compile(pattern)
109 109
110 110 def _preceding_text():
111 111 app = get_app()
112 112 before_cursor = app.current_buffer.document.current_line_before_cursor
113 113 return bool(m.match(before_cursor))
114 114
115 115 _preceding_text.__name__ = f"preceding_text({pattern!r})"
116 116
117 117 condition = Condition(_preceding_text)
118 118 _preceding_text_cache[pattern] = condition
119 119 return condition
120 120
121 121
122 122 def following_text(pattern):
123 123 try:
124 124 return _following_text_cache[pattern]
125 125 except KeyError:
126 126 pass
127 127 m = re.compile(pattern)
128 128
129 129 def _following_text():
130 130 app = get_app()
131 131 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
132 132
133 133 _following_text.__name__ = f"following_text({pattern!r})"
134 134
135 135 condition = Condition(_following_text)
136 136 _following_text_cache[pattern] = condition
137 137 return condition
138 138
139 139
140 140 @Condition
141 141 def not_inside_unclosed_string():
142 142 app = get_app()
143 143 s = app.current_buffer.document.text_before_cursor
144 144 # remove escaped quotes
145 145 s = s.replace('\\"', "").replace("\\'", "")
146 146 # remove triple-quoted string literals
147 147 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
148 148 # remove single-quoted string literals
149 149 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
150 150 return not ('"' in s or "'" in s)
151 151
152 152
153 153 @Condition
154 154 def navigable_suggestions():
155 155 shell = get_ipython()
156 156 return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
157 157
158 158
159 159 @Condition
160 160 def readline_like_completions():
161 161 shell = get_ipython()
162 162 return shell.display_completions == "readlinelike"
163 163
164 164
165 165 @Condition
166 166 def is_windows_os():
167 167 return sys.platform == "win32"
168 168
169 169
170 170 # these one is callable and re-used multiple times hence needs to be
171 171 # only defined once beforhand so that transforming back to human-readable
172 172 # names works well in the documentation.
173 173 default_buffer_focused = has_focus(DEFAULT_BUFFER)
174 174
175 175 KEYBINDING_FILTERS = {
176 176 "always": Always(),
177 177 "has_line_below": has_line_below,
178 178 "has_line_above": has_line_above,
179 179 "has_selection": has_selection,
180 180 "has_suggestion": has_suggestion,
181 181 "vi_mode": vi_mode,
182 182 "vi_insert_mode": vi_insert_mode,
183 183 "emacs_insert_mode": emacs_insert_mode,
184 # https://github.com/ipython/ipython/pull/12603 argued for inclusion of
185 # emacs key bindings with a configurable `emacs_bindings_in_vi_insert_mode`
186 # toggle; when the toggle is on user can access keybindigns like `ctrl + e`
187 # in vi insert mode. Because some of the emacs bindings involve `escape`
188 # followed by another key, e.g. `escape` followed by `f`, prompt-toolkit
189 # needs to wait to see if there will be another character typed in before
190 # executing pure `escape` keybinding; in vi insert mode `escape` switches to
191 # command mode which is common and performance critical action for vi users.
192 # To avoid the delay users employ a workaround:
193 # https://github.com/ipython/ipython/issues/13443#issuecomment-1032753703
194 # which involves switching `emacs_bindings_in_vi_insert_mode` off.
195 #
196 # For the workaround to work:
197 # 1) end users need to toggle `emacs_bindings_in_vi_insert_mode` off
198 # 2) all keybindings which would involve `escape` need to respect that
199 # toggle by including either:
200 # - `vi_insert_mode & ebivim` for actions which have emacs keybindings
201 # predefined upstream in prompt-toolkit, or
202 # - `emacs_like_insert_mode` for actions which do not have existing
203 # emacs keybindings predefined upstream (or need overriding of the
204 # upstream bindings to modify behaviour), defined below.
205 "emacs_like_insert_mode": (vi_insert_mode & ebivim) | emacs_insert_mode,
184 206 "has_completions": has_completions,
185 207 "insert_mode": vi_insert_mode | emacs_insert_mode,
186 208 "default_buffer_focused": default_buffer_focused,
187 209 "search_buffer_focused": has_focus(SEARCH_BUFFER),
210 # `ebivim` stands for emacs bindings in vi insert mode
188 211 "ebivim": ebivim,
189 212 "supports_suspend": supports_suspend,
190 213 "is_windows_os": is_windows_os,
191 214 "auto_match": auto_match,
192 215 "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
193 216 "not_inside_unclosed_string": not_inside_unclosed_string,
194 217 "readline_like_completions": readline_like_completions,
195 218 "preceded_by_paired_double_quotes": preceding_text(
196 219 lambda line: all_quotes_paired('"', line)
197 220 ),
198 221 "preceded_by_paired_single_quotes": preceding_text(
199 222 lambda line: all_quotes_paired("'", line)
200 223 ),
201 224 "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
202 225 "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
203 226 "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
204 227 "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
205 228 "preceded_by_opening_round_paren": preceding_text(r".*\($"),
206 229 "preceded_by_opening_bracket": preceding_text(r".*\[$"),
207 230 "preceded_by_opening_brace": preceding_text(r".*\{$"),
208 231 "preceded_by_double_quote": preceding_text('.*"$'),
209 232 "preceded_by_single_quote": preceding_text(r".*'$"),
210 233 "followed_by_closing_round_paren": following_text(r"^\)"),
211 234 "followed_by_closing_bracket": following_text(r"^\]"),
212 235 "followed_by_closing_brace": following_text(r"^\}"),
213 236 "followed_by_double_quote": following_text('^"'),
214 237 "followed_by_single_quote": following_text("^'"),
215 238 "navigable_suggestions": navigable_suggestions,
216 239 "cursor_in_leading_ws": cursor_in_leading_ws,
217 240 }
218 241
219 242
220 243 def eval_node(node: Union[ast.AST, None]):
221 244 if node is None:
222 245 return None
223 246 if isinstance(node, ast.Expression):
224 247 return eval_node(node.body)
225 248 if isinstance(node, ast.BinOp):
226 249 left = eval_node(node.left)
227 250 right = eval_node(node.right)
228 251 dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
229 252 if dunders:
230 253 return getattr(left, dunders[0])(right)
231 254 raise ValueError(f"Unknown binary operation: {node.op}")
232 255 if isinstance(node, ast.UnaryOp):
233 256 value = eval_node(node.operand)
234 257 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
235 258 if dunders:
236 259 return getattr(value, dunders[0])()
237 260 raise ValueError(f"Unknown unary operation: {node.op}")
238 261 if isinstance(node, ast.Name):
239 262 if node.id in KEYBINDING_FILTERS:
240 263 return KEYBINDING_FILTERS[node.id]
241 264 else:
242 265 sep = "\n - "
243 266 known_filters = sep.join(sorted(KEYBINDING_FILTERS))
244 267 raise NameError(
245 268 f"{node.id} is not a known shortcut filter."
246 269 f" Known filters are: {sep}{known_filters}."
247 270 )
248 271 raise ValueError("Unhandled node", ast.dump(node))
249 272
250 273
251 274 def filter_from_string(code: str):
252 275 expression = ast.parse(code, mode="eval")
253 276 return eval_node(expression)
254 277
255 278
256 279 __all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
@@ -1,461 +1,468 b''
1 1 import pytest
2 2 from IPython.terminal.shortcuts.auto_suggest import (
3 3 accept,
4 accept_in_vi_insert_mode,
4 accept_or_jump_to_end,
5 5 accept_token,
6 6 accept_character,
7 7 accept_word,
8 8 accept_and_keep_cursor,
9 9 discard,
10 10 NavigableAutoSuggestFromHistory,
11 11 swap_autosuggestion_up,
12 12 swap_autosuggestion_down,
13 13 )
14 14 from IPython.terminal.shortcuts.auto_match import skip_over
15 15 from IPython.terminal.shortcuts import create_ipython_shortcuts
16 16
17 17 from prompt_toolkit.history import InMemoryHistory
18 18 from prompt_toolkit.buffer import Buffer
19 19 from prompt_toolkit.document import Document
20 20 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
21 21
22 22 from unittest.mock import patch, Mock
23 23
24 24
25 def test_deprected():
26 import IPython.terminal.shortcuts.auto_suggest as iptsa
27
28 with pytest.warns(DeprecationWarning, match=r"8\.12.+accept_or_jump_to_end"):
29 iptsa.accept_in_vi_insert_mode
30
31
25 32 def make_event(text, cursor, suggestion):
26 33 event = Mock()
27 34 event.current_buffer = Mock()
28 35 event.current_buffer.suggestion = Mock()
29 36 event.current_buffer.text = text
30 37 event.current_buffer.cursor_position = cursor
31 38 event.current_buffer.suggestion.text = suggestion
32 39 event.current_buffer.document = Document(text=text, cursor_position=cursor)
33 40 return event
34 41
35 42
36 43 @pytest.mark.parametrize(
37 44 "text, suggestion, expected",
38 45 [
39 46 ("", "def out(tag: str, n=50):", "def out(tag: str, n=50):"),
40 47 ("def ", "out(tag: str, n=50):", "out(tag: str, n=50):"),
41 48 ],
42 49 )
43 50 def test_accept(text, suggestion, expected):
44 51 event = make_event(text, len(text), suggestion)
45 52 buffer = event.current_buffer
46 53 buffer.insert_text = Mock()
47 54 accept(event)
48 55 assert buffer.insert_text.called
49 56 assert buffer.insert_text.call_args[0] == (expected,)
50 57
51 58
52 59 @pytest.mark.parametrize(
53 60 "text, suggestion",
54 61 [
55 62 ("", "def out(tag: str, n=50):"),
56 63 ("def ", "out(tag: str, n=50):"),
57 64 ],
58 65 )
59 66 def test_discard(text, suggestion):
60 67 event = make_event(text, len(text), suggestion)
61 68 buffer = event.current_buffer
62 69 buffer.insert_text = Mock()
63 70 discard(event)
64 71 assert not buffer.insert_text.called
65 72 assert buffer.suggestion is None
66 73
67 74
68 75 @pytest.mark.parametrize(
69 76 "text, cursor, suggestion, called",
70 77 [
71 78 ("123456", 6, "123456789", True),
72 79 ("123456", 3, "123456789", False),
73 80 ("123456 \n789", 6, "123456789", True),
74 81 ],
75 82 )
76 83 def test_autosuggest_at_EOL(text, cursor, suggestion, called):
77 84 """
78 85 test that autosuggest is only applied at end of line.
79 86 """
80 87
81 88 event = make_event(text, cursor, suggestion)
82 89 event.current_buffer.insert_text = Mock()
83 accept_in_vi_insert_mode(event)
90 accept_or_jump_to_end(event)
84 91 if called:
85 92 event.current_buffer.insert_text.assert_called()
86 93 else:
87 94 event.current_buffer.insert_text.assert_not_called()
88 95 # event.current_buffer.document.get_end_of_line_position.assert_called()
89 96
90 97
91 98 @pytest.mark.parametrize(
92 99 "text, suggestion, expected",
93 100 [
94 101 ("", "def out(tag: str, n=50):", "def "),
95 102 ("d", "ef out(tag: str, n=50):", "ef "),
96 103 ("de ", "f out(tag: str, n=50):", "f "),
97 104 ("def", " out(tag: str, n=50):", " "),
98 105 ("def ", "out(tag: str, n=50):", "out("),
99 106 ("def o", "ut(tag: str, n=50):", "ut("),
100 107 ("def ou", "t(tag: str, n=50):", "t("),
101 108 ("def out", "(tag: str, n=50):", "("),
102 109 ("def out(", "tag: str, n=50):", "tag: "),
103 110 ("def out(t", "ag: str, n=50):", "ag: "),
104 111 ("def out(ta", "g: str, n=50):", "g: "),
105 112 ("def out(tag", ": str, n=50):", ": "),
106 113 ("def out(tag:", " str, n=50):", " "),
107 114 ("def out(tag: ", "str, n=50):", "str, "),
108 115 ("def out(tag: s", "tr, n=50):", "tr, "),
109 116 ("def out(tag: st", "r, n=50):", "r, "),
110 117 ("def out(tag: str", ", n=50):", ", n"),
111 118 ("def out(tag: str,", " n=50):", " n"),
112 119 ("def out(tag: str, ", "n=50):", "n="),
113 120 ("def out(tag: str, n", "=50):", "="),
114 121 ("def out(tag: str, n=", "50):", "50)"),
115 122 ("def out(tag: str, n=5", "0):", "0)"),
116 123 ("def out(tag: str, n=50", "):", "):"),
117 124 ("def out(tag: str, n=50)", ":", ":"),
118 125 ],
119 126 )
120 127 def test_autosuggest_token(text, suggestion, expected):
121 128 event = make_event(text, len(text), suggestion)
122 129 event.current_buffer.insert_text = Mock()
123 130 accept_token(event)
124 131 assert event.current_buffer.insert_text.called
125 132 assert event.current_buffer.insert_text.call_args[0] == (expected,)
126 133
127 134
128 135 @pytest.mark.parametrize(
129 136 "text, suggestion, expected",
130 137 [
131 138 ("", "def out(tag: str, n=50):", "d"),
132 139 ("d", "ef out(tag: str, n=50):", "e"),
133 140 ("de ", "f out(tag: str, n=50):", "f"),
134 141 ("def", " out(tag: str, n=50):", " "),
135 142 ],
136 143 )
137 144 def test_accept_character(text, suggestion, expected):
138 145 event = make_event(text, len(text), suggestion)
139 146 event.current_buffer.insert_text = Mock()
140 147 accept_character(event)
141 148 assert event.current_buffer.insert_text.called
142 149 assert event.current_buffer.insert_text.call_args[0] == (expected,)
143 150
144 151
145 152 @pytest.mark.parametrize(
146 153 "text, suggestion, expected",
147 154 [
148 155 ("", "def out(tag: str, n=50):", "def "),
149 156 ("d", "ef out(tag: str, n=50):", "ef "),
150 157 ("de", "f out(tag: str, n=50):", "f "),
151 158 ("def", " out(tag: str, n=50):", " "),
152 159 # (this is why we also have accept_token)
153 160 ("def ", "out(tag: str, n=50):", "out(tag: "),
154 161 ],
155 162 )
156 163 def test_accept_word(text, suggestion, expected):
157 164 event = make_event(text, len(text), suggestion)
158 165 event.current_buffer.insert_text = Mock()
159 166 accept_word(event)
160 167 assert event.current_buffer.insert_text.called
161 168 assert event.current_buffer.insert_text.call_args[0] == (expected,)
162 169
163 170
164 171 @pytest.mark.parametrize(
165 172 "text, suggestion, expected, cursor",
166 173 [
167 174 ("", "def out(tag: str, n=50):", "def out(tag: str, n=50):", 0),
168 175 ("def ", "out(tag: str, n=50):", "out(tag: str, n=50):", 4),
169 176 ],
170 177 )
171 178 def test_accept_and_keep_cursor(text, suggestion, expected, cursor):
172 179 event = make_event(text, cursor, suggestion)
173 180 buffer = event.current_buffer
174 181 buffer.insert_text = Mock()
175 182 accept_and_keep_cursor(event)
176 183 assert buffer.insert_text.called
177 184 assert buffer.insert_text.call_args[0] == (expected,)
178 185 assert buffer.cursor_position == cursor
179 186
180 187
181 188 def test_autosuggest_token_empty():
182 189 full = "def out(tag: str, n=50):"
183 190 event = make_event(full, len(full), "")
184 191 event.current_buffer.insert_text = Mock()
185 192
186 193 with patch(
187 194 "prompt_toolkit.key_binding.bindings.named_commands.forward_word"
188 195 ) as forward_word:
189 196 accept_token(event)
190 197 assert not event.current_buffer.insert_text.called
191 198 assert forward_word.called
192 199
193 200
194 201 def test_other_providers():
195 202 """Ensure that swapping autosuggestions does not break with other providers"""
196 203 provider = AutoSuggestFromHistory()
197 204 ip = get_ipython()
198 205 ip.auto_suggest = provider
199 206 event = Mock()
200 207 event.current_buffer = Buffer()
201 208 assert swap_autosuggestion_up(event) is None
202 209 assert swap_autosuggestion_down(event) is None
203 210
204 211
205 212 async def test_navigable_provider():
206 213 provider = NavigableAutoSuggestFromHistory()
207 214 history = InMemoryHistory(history_strings=["very_a", "very", "very_b", "very_c"])
208 215 buffer = Buffer(history=history)
209 216 ip = get_ipython()
210 217 ip.auto_suggest = provider
211 218
212 219 async for _ in history.load():
213 220 pass
214 221
215 222 buffer.cursor_position = 5
216 223 buffer.text = "very"
217 224
218 225 up = swap_autosuggestion_up
219 226 down = swap_autosuggestion_down
220 227
221 228 event = Mock()
222 229 event.current_buffer = buffer
223 230
224 231 def get_suggestion():
225 232 suggestion = provider.get_suggestion(buffer, buffer.document)
226 233 buffer.suggestion = suggestion
227 234 return suggestion
228 235
229 236 assert get_suggestion().text == "_c"
230 237
231 238 # should go up
232 239 up(event)
233 240 assert get_suggestion().text == "_b"
234 241
235 242 # should skip over 'very' which is identical to buffer content
236 243 up(event)
237 244 assert get_suggestion().text == "_a"
238 245
239 246 # should cycle back to beginning
240 247 up(event)
241 248 assert get_suggestion().text == "_c"
242 249
243 250 # should cycle back through end boundary
244 251 down(event)
245 252 assert get_suggestion().text == "_a"
246 253
247 254 down(event)
248 255 assert get_suggestion().text == "_b"
249 256
250 257 down(event)
251 258 assert get_suggestion().text == "_c"
252 259
253 260 down(event)
254 261 assert get_suggestion().text == "_a"
255 262
256 263
257 264 async def test_navigable_provider_multiline_entries():
258 265 provider = NavigableAutoSuggestFromHistory()
259 266 history = InMemoryHistory(history_strings=["very_a\nvery_b", "very_c"])
260 267 buffer = Buffer(history=history)
261 268 ip = get_ipython()
262 269 ip.auto_suggest = provider
263 270
264 271 async for _ in history.load():
265 272 pass
266 273
267 274 buffer.cursor_position = 5
268 275 buffer.text = "very"
269 276 up = swap_autosuggestion_up
270 277 down = swap_autosuggestion_down
271 278
272 279 event = Mock()
273 280 event.current_buffer = buffer
274 281
275 282 def get_suggestion():
276 283 suggestion = provider.get_suggestion(buffer, buffer.document)
277 284 buffer.suggestion = suggestion
278 285 return suggestion
279 286
280 287 assert get_suggestion().text == "_c"
281 288
282 289 up(event)
283 290 assert get_suggestion().text == "_b"
284 291
285 292 up(event)
286 293 assert get_suggestion().text == "_a"
287 294
288 295 down(event)
289 296 assert get_suggestion().text == "_b"
290 297
291 298 down(event)
292 299 assert get_suggestion().text == "_c"
293 300
294 301
295 302 def create_session_mock():
296 303 session = Mock()
297 304 session.default_buffer = Buffer()
298 305 return session
299 306
300 307
301 308 def test_navigable_provider_connection():
302 309 provider = NavigableAutoSuggestFromHistory()
303 310 provider.skip_lines = 1
304 311
305 312 session_1 = create_session_mock()
306 313 provider.connect(session_1)
307 314
308 315 assert provider.skip_lines == 1
309 316 session_1.default_buffer.on_text_insert.fire()
310 317 assert provider.skip_lines == 0
311 318
312 319 session_2 = create_session_mock()
313 320 provider.connect(session_2)
314 321 provider.skip_lines = 2
315 322
316 323 assert provider.skip_lines == 2
317 324 session_2.default_buffer.on_text_insert.fire()
318 325 assert provider.skip_lines == 0
319 326
320 327 provider.skip_lines = 3
321 328 provider.disconnect()
322 329 session_1.default_buffer.on_text_insert.fire()
323 330 session_2.default_buffer.on_text_insert.fire()
324 331 assert provider.skip_lines == 3
325 332
326 333
327 334 @pytest.fixture
328 335 def ipython_with_prompt():
329 336 ip = get_ipython()
330 337 ip.pt_app = Mock()
331 338 ip.pt_app.key_bindings = create_ipython_shortcuts(ip)
332 339 try:
333 340 yield ip
334 341 finally:
335 342 ip.pt_app = None
336 343
337 344
338 345 def find_bindings_by_command(command):
339 346 ip = get_ipython()
340 347 return [
341 348 binding
342 349 for binding in ip.pt_app.key_bindings.bindings
343 350 if binding.handler == command
344 351 ]
345 352
346 353
347 354 def test_modify_unique_shortcut(ipython_with_prompt):
348 355 original = find_bindings_by_command(accept_token)
349 356 assert len(original) == 1
350 357
351 358 ipython_with_prompt.shortcuts = [
352 359 {"command": "IPython:auto_suggest.accept_token", "new_keys": ["a", "b", "c"]}
353 360 ]
354 361 matched = find_bindings_by_command(accept_token)
355 362 assert len(matched) == 1
356 363 assert list(matched[0].keys) == ["a", "b", "c"]
357 364 assert list(matched[0].keys) != list(original[0].keys)
358 365 assert matched[0].filter == original[0].filter
359 366
360 367 ipython_with_prompt.shortcuts = [
361 368 {"command": "IPython:auto_suggest.accept_token", "new_filter": "always"}
362 369 ]
363 370 matched = find_bindings_by_command(accept_token)
364 371 assert len(matched) == 1
365 372 assert list(matched[0].keys) != ["a", "b", "c"]
366 373 assert list(matched[0].keys) == list(original[0].keys)
367 374 assert matched[0].filter != original[0].filter
368 375
369 376
370 377 def test_disable_shortcut(ipython_with_prompt):
371 378 matched = find_bindings_by_command(accept_token)
372 379 assert len(matched) == 1
373 380
374 381 ipython_with_prompt.shortcuts = [
375 382 {"command": "IPython:auto_suggest.accept_token", "new_keys": []}
376 383 ]
377 384 matched = find_bindings_by_command(accept_token)
378 385 assert len(matched) == 0
379 386
380 387 ipython_with_prompt.shortcuts = []
381 388 matched = find_bindings_by_command(accept_token)
382 389 assert len(matched) == 1
383 390
384 391
385 392 def test_modify_shortcut_with_filters(ipython_with_prompt):
386 393 matched = find_bindings_by_command(skip_over)
387 394 matched_keys = {m.keys[0] for m in matched}
388 395 assert matched_keys == {")", "]", "}", "'", '"'}
389 396
390 397 with pytest.raises(ValueError, match="Multiple shortcuts matching"):
391 398 ipython_with_prompt.shortcuts = [
392 399 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"]}
393 400 ]
394 401
395 402 ipython_with_prompt.shortcuts = [
396 403 {
397 404 "command": "IPython:auto_match.skip_over",
398 405 "new_keys": ["x"],
399 406 "match_filter": "focused_insert & auto_match & followed_by_single_quote",
400 407 }
401 408 ]
402 409 matched = find_bindings_by_command(skip_over)
403 410 matched_keys = {m.keys[0] for m in matched}
404 411 assert matched_keys == {")", "]", "}", "x", '"'}
405 412
406 413
407 414 def example_command():
408 415 pass
409 416
410 417
411 418 def test_add_shortcut_for_new_command(ipython_with_prompt):
412 419 matched = find_bindings_by_command(example_command)
413 420 assert len(matched) == 0
414 421
415 422 with pytest.raises(ValueError, match="example_command is not a known"):
416 423 ipython_with_prompt.shortcuts = [
417 424 {"command": "example_command", "new_keys": ["x"]}
418 425 ]
419 426 matched = find_bindings_by_command(example_command)
420 427 assert len(matched) == 0
421 428
422 429
423 430 def test_modify_shortcut_failure(ipython_with_prompt):
424 431 with pytest.raises(ValueError, match="No shortcuts matching"):
425 432 ipython_with_prompt.shortcuts = [
426 433 {
427 434 "command": "IPython:auto_match.skip_over",
428 435 "match_keys": ["x"],
429 436 "new_keys": ["y"],
430 437 }
431 438 ]
432 439
433 440
434 441 def test_add_shortcut_for_existing_command(ipython_with_prompt):
435 442 matched = find_bindings_by_command(skip_over)
436 443 assert len(matched) == 5
437 444
438 445 with pytest.raises(ValueError, match="Cannot add a shortcut without keys"):
439 446 ipython_with_prompt.shortcuts = [
440 447 {"command": "IPython:auto_match.skip_over", "new_keys": [], "create": True}
441 448 ]
442 449
443 450 ipython_with_prompt.shortcuts = [
444 451 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True}
445 452 ]
446 453 matched = find_bindings_by_command(skip_over)
447 454 assert len(matched) == 6
448 455
449 456 ipython_with_prompt.shortcuts = []
450 457 matched = find_bindings_by_command(skip_over)
451 458 assert len(matched) == 5
452 459
453 460
454 461 def test_setting_shortcuts_before_pt_app_init():
455 462 ipython = get_ipython()
456 463 assert ipython.pt_app is None
457 464 shortcuts = [
458 465 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True}
459 466 ]
460 467 ipython.shortcuts = shortcuts
461 468 assert ipython.shortcuts == shortcuts
General Comments 0
You need to be logged in to leave comments. Login now