Janw.xyz

Doing things on the internet.

Syntax-aware redefinition of kill-word in IPython

Although IPython to me is the best of all the Python REPLs, there is something that bothered me about it for a while: Alt-Backspace is one of the shortcuts I use most. In many shell contexts, as well as generally in text editing, it removes the last word behind the cursor, i.e. the last word I wrote. It is a very useful shortcut to fix missspelled words or when after writing it I notice a word does not really work in a context after all and I want to replace it. This removal the last word is often called the “kill-word command”.

Usually you’d expect the shortcut to remove “true” words when writing prose i.e.: whitespace. That is also how IPython applies it but for writing Python expressions at a REPL I would define word boundaries a little differently, namely as: All things separated by the programming language’s non-word characters. For Python this amounts to spaces, =, ., _, -, and maybe even a bunch of other characters or character combinations. Except for spaces, IPython ignores them blissfully by default. This becomes most apparent in my daily work, when entering imports at the REPL. For example I were to misstype

1In [1]: from django.db.models.functions  # <= Oops, I scrambled the letters C and N

I’d expect Alt-Backspace to only remove functions, so I can replace the word with functions. Instead what IPython (or being more precise: the underlying prompt-toolkit) does is remove the entire module path, leaving only

1In [1]:  from

That gets particularly annoying for long module paths of course. After some digging through the implementation of the kill-word functionality in the prompt-toolkit, I found a simple yet effective solution to make IPython more syntax-aware there. The following code snippet overrides the keybinding with a more “intelligent” version of original, that defines a word boundary with a more flexible regular expression r"([^\s/\.\=\_\-]+)", i.e. a word consists of combination of characters that does not contain a space, ., =, _, or -. Which is what I did, and thanks to IPython’s ability to script the shell startup, my ~/.ipython/profile_default/startup/10-sub-word-kill.py now contain this:

 1import re
 2from IPython import get_ipython
 3from prompt_toolkit import Application
 4from prompt_toolkit.key_binding import KeyBindings
 5from prompt_toolkit.keys import Keys
 6from prompt_toolkit.filters import ViInsertMode, EmacsInsertMode
 7
 8ip = get_ipython()
 9insert_mode = ViInsertMode() | EmacsInsertMode()
10
11FIND_SYNTAX_WORD_RE = re.compile(r"([^\s/\.\=\_\-]+)")
12
13
14def syntax_word_kill(event, WORD=True):
15    """
16    Kill the "syntactical word" behind point, using whitespace and a few other
17    characters as a word boundary. Usually bound to M-Backspace.
18    """
19    buff = event.current_buffer
20    pos = buff.document.find_start_of_previous_word(
21        count=event.arg, pattern=FIND_SYNTAX_WORD_RE
22    )
23
24    if pos is None:
25        # Nothing found? delete until the start of the document.  (The
26        # input starts with whitespace and no words were found before the
27        # cursor.)
28        pos = -buff.cursor_position
29
30    if pos:
31        deleted = buff.delete_before_cursor(count=-pos)
32
33        # If the previous key press was also Control-W, concatenate deleted
34        # text.
35        if event.is_repeat:
36            deleted += event.cli.clipboard.get_data().text
37
38        event.cli.clipboard.set_text(deleted)
39    else:
40        # Nothing to delete. Bell.
41        event.cli.output.bell()
42
43
44# Register the shortcut if IPython is using prompt_toolkit
45if getattr(ip, "pt_app", None):
46    registry = ip.pt_app.key_bindings
47    registry.add_binding(Keys.Escape, Keys.Backspace, filter=insert_mode)(syntax_word_kill)