1# -*- coding: utf-8 -*-
2"""Key bindings for prompt_toolkit xonsh shell."""
3import builtins
4
5from prompt_toolkit import search
6from prompt_toolkit.enums import DEFAULT_BUFFER
7from prompt_toolkit.filters import (
8    Condition,
9    IsMultiline,
10    HasSelection,
11    EmacsInsertMode,
12    ViInsertMode,
13    IsSearching,
14)
15from prompt_toolkit.keys import Keys
16from prompt_toolkit.application.current import get_app
17
18from xonsh.aliases import xonsh_exit
19from xonsh.tools import check_for_partial_string, get_line_continuation
20from xonsh.shell import transform_command
21
22env = builtins.__xonsh_env__
23DEDENT_TOKENS = frozenset(["raise", "return", "pass", "break", "continue"])
24
25
26def carriage_return(b, cli, *, autoindent=True):
27    """Preliminary parser to determine if 'Enter' key should send command to the
28    xonsh parser for execution or should insert a newline for continued input.
29
30    Current 'triggers' for inserting a newline are:
31    - Not on first line of buffer and line is non-empty
32    - Previous character is a colon (covers if, for, etc...)
33    - User is in an open paren-block
34    - Line ends with backslash
35    - Any text exists below cursor position (relevant when editing previous
36    multiline blocks)
37    """
38    doc = b.document
39    at_end_of_line = _is_blank(doc.current_line_after_cursor)
40    current_line_blank = _is_blank(doc.current_line)
41
42    indent = env.get("INDENT") if autoindent else ""
43
44    partial_string_info = check_for_partial_string(doc.text)
45    in_partial_string = (
46        partial_string_info[0] is not None and partial_string_info[1] is None
47    )
48
49    # indent after a colon
50    if doc.current_line_before_cursor.strip().endswith(":") and at_end_of_line:
51        b.newline(copy_margin=autoindent)
52        b.insert_text(indent, fire_event=False)
53    # if current line isn't blank, check dedent tokens
54    elif (
55        not current_line_blank
56        and doc.current_line.split(maxsplit=1)[0] in DEDENT_TOKENS
57        and doc.line_count > 1
58    ):
59        b.newline(copy_margin=autoindent)
60        b.delete_before_cursor(count=len(indent))
61    elif not doc.on_first_line and not current_line_blank:
62        b.newline(copy_margin=autoindent)
63    elif doc.current_line.endswith(get_line_continuation()):
64        b.newline(copy_margin=autoindent)
65    elif doc.find_next_word_beginning() is not None and (
66        any(not _is_blank(i) for i in doc.lines_from_current[1:])
67    ):
68        b.newline(copy_margin=autoindent)
69    elif not current_line_blank and not can_compile(doc.text):
70        b.newline(copy_margin=autoindent)
71    elif current_line_blank and in_partial_string:
72        b.newline(copy_margin=autoindent)
73    else:
74        try:
75            b.accept_action.validate_and_handle(cli, b)
76        except AttributeError:  # PTK 2.0
77            b.validate_and_handle()
78
79
80def _is_blank(l):
81    return len(l.strip()) == 0
82
83
84def can_compile(src):
85    """Returns whether the code can be compiled, i.e. it is valid xonsh."""
86    src = src if src.endswith("\n") else src + "\n"
87    src = transform_command(src, show_diff=False)
88    src = src.lstrip()
89    try:
90        builtins.__xonsh_execer__.compile(
91            src, mode="single", glbs=None, locs=builtins.__xonsh_ctx__
92        )
93        rtn = True
94    except SyntaxError:
95        rtn = False
96    except Exception:
97        rtn = True
98    return rtn
99
100
101@Condition
102def tab_insert_indent():
103    """Check if <Tab> should insert indent instead of starting autocompletion.
104    Checks if there are only whitespaces before the cursor - if so indent
105    should be inserted, otherwise autocompletion.
106
107    """
108    before_cursor = get_app().current_buffer.document.current_line_before_cursor
109
110    return bool(before_cursor.isspace())
111
112
113@Condition
114def beginning_of_line():
115    """Check if cursor is at beginning of a line other than the first line in a
116    multiline document
117    """
118    app = get_app()
119    before_cursor = app.current_buffer.document.current_line_before_cursor
120
121    return bool(
122        len(before_cursor) == 0 and not app.current_buffer.document.on_first_line
123    )
124
125
126@Condition
127def end_of_line():
128    """Check if cursor is at the end of a line other than the last line in a
129    multiline document
130    """
131    d = get_app().current_buffer.document
132    at_end = d.is_cursor_at_the_end_of_line
133    last_line = d.is_cursor_at_the_end
134
135    return bool(at_end and not last_line)
136
137
138@Condition
139def should_confirm_completion():
140    """Check if completion needs confirmation"""
141    return (
142        builtins.__xonsh_env__.get("COMPLETIONS_CONFIRM")
143        and get_app().current_buffer.complete_state
144    )
145
146
147# Copied from prompt-toolkit's key_binding/bindings/basic.py
148@Condition
149def ctrl_d_condition():
150    """Ctrl-D binding is only active when the default buffer is selected and
151    empty.
152    """
153    if builtins.__xonsh_env__.get("IGNOREEOF"):
154        raise EOFError
155    else:
156        app = get_app()
157        try:
158            buffer_name = app.current_buffer_name
159        except AttributeError:  # PTK 2.0
160            buffer_name = app.current_buffer.name
161
162        return buffer_name == DEFAULT_BUFFER and not app.current_buffer.text
163
164
165@Condition
166def autopair_condition():
167    """Check if XONSH_AUTOPAIR is set"""
168    return builtins.__xonsh_env__.get("XONSH_AUTOPAIR", False)
169
170
171@Condition
172def whitespace_or_bracket_before():
173    """Check if there is whitespace or an opening
174       bracket to the left of the cursor"""
175    d = get_app().current_buffer.document
176    return bool(
177        d.cursor_position == 0
178        or d.char_before_cursor.isspace()
179        or d.char_before_cursor in "([{"
180    )
181
182
183@Condition
184def whitespace_or_bracket_after():
185    """Check if there is whitespace or a closing
186       bracket to the right of the cursor"""
187    d = get_app().current_buffer.document
188    return bool(
189        d.is_cursor_at_the_end_of_line
190        or d.current_char.isspace()
191        or d.current_char in ")]}"
192    )
193
194
195def load_xonsh_bindings(key_bindings):
196    """
197    Load custom key bindings.
198    """
199    handle = key_bindings.add
200    has_selection = HasSelection()
201    insert_mode = ViInsertMode() | EmacsInsertMode()
202
203    @handle(Keys.Tab, filter=tab_insert_indent)
204    def insert_indent(event):
205        """
206        If there are only whitespaces before current cursor position insert
207        indent instead of autocompleting.
208        """
209        event.cli.current_buffer.insert_text(env.get("INDENT"))
210
211    @handle(Keys.ControlX, Keys.ControlE, filter=~has_selection)
212    def open_editor(event):
213        """ Open current buffer in editor """
214        event.current_buffer.open_in_editor(event.cli)
215
216    @handle(Keys.BackTab, filter=insert_mode)
217    def insert_literal_tab(event):
218        """ Insert literal tab on Shift+Tab instead of autocompleting """
219        b = event.current_buffer
220        if b.complete_state:
221            b.complete_previous()
222        else:
223            event.cli.current_buffer.insert_text(env.get("INDENT"))
224
225    @handle("(", filter=autopair_condition & whitespace_or_bracket_after)
226    def insert_right_parens(event):
227        event.cli.current_buffer.insert_text("(")
228        event.cli.current_buffer.insert_text(")", move_cursor=False)
229
230    @handle(")", filter=autopair_condition)
231    def overwrite_right_parens(event):
232        buffer = event.cli.current_buffer
233        if buffer.document.current_char == ")":
234            buffer.cursor_position += 1
235        else:
236            buffer.insert_text(")")
237
238    @handle("[", filter=autopair_condition & whitespace_or_bracket_after)
239    def insert_right_bracket(event):
240        event.cli.current_buffer.insert_text("[")
241        event.cli.current_buffer.insert_text("]", move_cursor=False)
242
243    @handle("]", filter=autopair_condition)
244    def overwrite_right_bracket(event):
245        buffer = event.cli.current_buffer
246
247        if buffer.document.current_char == "]":
248            buffer.cursor_position += 1
249        else:
250            buffer.insert_text("]")
251
252    @handle("{", filter=autopair_condition & whitespace_or_bracket_after)
253    def insert_right_brace(event):
254        event.cli.current_buffer.insert_text("{")
255        event.cli.current_buffer.insert_text("}", move_cursor=False)
256
257    @handle("}", filter=autopair_condition)
258    def overwrite_right_brace(event):
259        buffer = event.cli.current_buffer
260
261        if buffer.document.current_char == "}":
262            buffer.cursor_position += 1
263        else:
264            buffer.insert_text("}")
265
266    @handle("'", filter=autopair_condition)
267    def insert_right_quote(event):
268        buffer = event.cli.current_buffer
269
270        if buffer.document.current_char == "'":
271            buffer.cursor_position += 1
272        elif whitespace_or_bracket_before() and whitespace_or_bracket_after():
273            buffer.insert_text("'")
274            buffer.insert_text("'", move_cursor=False)
275        else:
276            buffer.insert_text("'")
277
278    @handle('"', filter=autopair_condition)
279    def insert_right_double_quote(event):
280        buffer = event.cli.current_buffer
281
282        if buffer.document.current_char == '"':
283            buffer.cursor_position += 1
284        elif whitespace_or_bracket_before() and whitespace_or_bracket_after():
285            buffer.insert_text('"')
286            buffer.insert_text('"', move_cursor=False)
287        else:
288            buffer.insert_text('"')
289
290    @handle(Keys.Backspace, filter=autopair_condition)
291    def delete_brackets_or_quotes(event):
292        """Delete empty pair of brackets or quotes"""
293        buffer = event.cli.current_buffer
294        before = buffer.document.char_before_cursor
295        after = buffer.document.current_char
296
297        if any(
298            [before == b and after == a for (b, a) in ["()", "[]", "{}", "''", '""']]
299        ):
300            buffer.delete(1)
301
302        buffer.delete_before_cursor(1)
303
304    @handle(Keys.ControlD, filter=ctrl_d_condition)
305    def call_exit_alias(event):
306        """Use xonsh exit function"""
307        b = event.cli.current_buffer
308        try:
309            b.accept_action.validate_and_handle(event.cli, b)
310        except AttributeError:  # PTK 2.0
311            b.validate_and_handle()
312        xonsh_exit([])
313
314    @handle(Keys.ControlJ, filter=IsMultiline())
315    @handle(Keys.ControlM, filter=IsMultiline())
316    def multiline_carriage_return(event):
317        """ Wrapper around carriage_return multiline parser """
318        b = event.cli.current_buffer
319        carriage_return(b, event.cli)
320
321    @handle(Keys.ControlJ, filter=should_confirm_completion)
322    @handle(Keys.ControlM, filter=should_confirm_completion)
323    def enter_confirm_completion(event):
324        """Ignore <enter> (confirm completion)"""
325        event.current_buffer.complete_state = None
326
327    @handle(Keys.Escape, filter=should_confirm_completion)
328    def esc_cancel_completion(event):
329        """Use <ESC> to cancel completion"""
330        event.cli.current_buffer.cancel_completion()
331
332    @handle(Keys.Escape, Keys.ControlJ)
333    def execute_block_now(event):
334        """Execute a block of text irrespective of cursor position"""
335        b = event.cli.current_buffer
336        try:
337            b.accept_action.validate_and_handle(event.cli, b)
338        except AttributeError:  # PTK 2.0
339            b.validate_and_handle()
340
341    @handle(Keys.Left, filter=beginning_of_line)
342    def wrap_cursor_back(event):
343        """Move cursor to end of previous line unless at beginning of
344        document
345        """
346        b = event.cli.current_buffer
347        b.cursor_up(count=1)
348        relative_end_index = b.document.get_end_of_line_position()
349        b.cursor_right(count=relative_end_index)
350
351    @handle(Keys.Right, filter=end_of_line)
352    def wrap_cursor_forward(event):
353        """Move cursor to beginning of next line unless at end of document"""
354        b = event.cli.current_buffer
355        relative_begin_index = b.document.get_start_of_line_position()
356        b.cursor_left(count=abs(relative_begin_index))
357        b.cursor_down(count=1)
358
359    @handle(Keys.ControlM, filter=IsSearching())
360    @handle(Keys.ControlJ, filter=IsSearching())
361    def accept_search(event):
362        search.accept_search()
363