1 """ 2 Filters restricting scope of IPython Terminal shortcuts. 3 """ 4 5 # Copyright (c) IPython Development Team. 6 # Distributed under the terms of the Modified BSD License. 7 8 import ast 9 import re 10 import signal 11 import sys 12 from typing import Callable, Dict, Union 13 14 from prompt_toolkit.application.current import get_app 15 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER 16 from prompt_toolkit.key_binding import KeyPressEvent 17 from prompt_toolkit.filters import Condition, Filter, emacs_insert_mode, has_completions 18 from prompt_toolkit.filters import has_focus as has_focus_impl 19 from prompt_toolkit.filters import ( 20 Always, 21 Never, 22 has_selection, 23 has_suggestion, 24 vi_insert_mode, 25 vi_mode, 26 ) 27 from prompt_toolkit.layout.layout import FocusableElement 28 29 from IPython.core.getipython import get_ipython 30 from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS 31 from IPython.terminal.shortcuts import auto_suggest 32 from IPython.utils.decorators import undoc 33 34 35 @undoc 36 @Condition 37 def cursor_in_leading_ws(): 38 before = get_app().current_buffer.document.current_line_before_cursor 39 return (not before) or before.isspace() 40 41 42 def has_focus(value: FocusableElement): 43 """Wrapper around has_focus adding a nice `__name__` to tester function""" 44 tester = has_focus_impl(value).func 45 tester.__name__ = f"is_focused({value})" 46 return Condition(tester) 47 48 49 @undoc 50 @Condition 51 def has_line_below() -> bool: 52 document = get_app().current_buffer.document 53 return document.cursor_position_row < len(document.lines) - 1 54 55 56 @undoc 57 @Condition 58 def is_cursor_at_the_end_of_line() -> bool: 59 document = get_app().current_buffer.document 60 return document.is_cursor_at_the_end_of_line 61 62 63 @undoc 64 @Condition 65 def has_line_above() -> bool: 66 document = get_app().current_buffer.document 67 return document.cursor_position_row != 0 68 69 70 @Condition 71 def ebivim(): 72 shell = get_ipython() 73 return shell.emacs_bindings_in_vi_insert_mode 74 75 76 @Condition 77 def supports_suspend(): 78 return hasattr(signal, "SIGTSTP") 79 80 81 @Condition 82 def auto_match(): 83 shell = get_ipython() 84 return shell.auto_match 85 86 87 def all_quotes_paired(quote, buf): 88 paired = True 89 i = 0 90 while i < len(buf): 91 c = buf[i] 92 if c == quote: 93 paired = not paired 94 elif c == "\\": 95 i += 1 96 i += 1 97 return paired 98 99 100 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {} 101 _following_text_cache: Dict[Union[str, Callable], Condition] = {} 102 103 104 def preceding_text(pattern: Union[str, Callable]): 105 if pattern in _preceding_text_cache: 106 return _preceding_text_cache[pattern] 107 108 if callable(pattern): 109 110 def _preceding_text(): 111 app = get_app() 112 before_cursor = app.current_buffer.document.current_line_before_cursor 113 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603 114 return bool(pattern(before_cursor)) # type: ignore[operator] 115 116 else: 117 m = re.compile(pattern) 118 119 def _preceding_text(): 120 app = get_app() 121 before_cursor = app.current_buffer.document.current_line_before_cursor 122 return bool(m.match(before_cursor)) 123 124 _preceding_text.__name__ = f"preceding_text({pattern!r})" 125 126 condition = Condition(_preceding_text) 127 _preceding_text_cache[pattern] = condition 128 return condition 129 130 131 def following_text(pattern): 132 try: 133 return _following_text_cache[pattern] 134 except KeyError: 135 pass 136 m = re.compile(pattern) 137 138 def _following_text(): 139 app = get_app() 140 return bool(m.match(app.current_buffer.document.current_line_after_cursor)) 141 142 _following_text.__name__ = f"following_text({pattern!r})" 143 144 condition = Condition(_following_text) 145 _following_text_cache[pattern] = condition 146 return condition 147 148 149 @Condition 150 def not_inside_unclosed_string(): 151 app = get_app() 152 s = app.current_buffer.document.text_before_cursor 153 # remove escaped quotes 154 s = s.replace('\\"', "").replace("\\'", "") 155 # remove triple-quoted string literals 156 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s) 157 # remove single-quoted string literals 158 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s) 159 return not ('"' in s or "'" in s) 160 161 162 @Condition 163 def navigable_suggestions(): 164 shell = get_ipython() 165 return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory) 166 167 168 @Condition 169 def readline_like_completions(): 170 shell = get_ipython() 171 return shell.display_completions == "readlinelike" 172 173 174 @Condition 175 def is_windows_os(): 176 return sys.platform == "win32" 177 178 179 class PassThrough(Filter): 180 """A filter allowing to implement pass-through behaviour of keybindings. 181 182 Prompt toolkit key processor dispatches only one event per binding match, 183 which means that adding a new shortcut will suppress the old shortcut 184 if the keybindings are the same (unless one is filtered out). 185 186 To stop a shortcut binding from suppressing other shortcuts: 187 - add the `pass_through` filter to list of filter, and 188 - call `pass_through.reply(event)` in the shortcut handler. 189 """ 190 191 def __init__(self): 192 self._is_replying = False 193 194 def reply(self, event: KeyPressEvent): 195 self._is_replying = True 196 try: 197 event.key_processor.reset() 198 event.key_processor.feed_multiple(event.key_sequence) 199 event.key_processor.process_keys() 200 finally: 201 self._is_replying = False 202 203 def __call__(self): 204 return not self._is_replying 205 206 207 pass_through = PassThrough() 208 209 # these one is callable and re-used multiple times hence needs to be 210 # only defined once beforehand so that transforming back to human-readable 211 # names works well in the documentation. 212 default_buffer_focused = has_focus(DEFAULT_BUFFER) 213 214 KEYBINDING_FILTERS = { 215 "always": Always(), 216 # never is used for exposing commands which have no default keybindings 217 "never": Never(), 218 "has_line_below": has_line_below, 219 "has_line_above": has_line_above, 220 "is_cursor_at_the_end_of_line": is_cursor_at_the_end_of_line, 221 "has_selection": has_selection, 222 "has_suggestion": has_suggestion, 223 "vi_mode": vi_mode, 224 "vi_insert_mode": vi_insert_mode, 225 "emacs_insert_mode": emacs_insert_mode, 226 # https://github.com/ipython/ipython/pull/12603 argued for inclusion of 227 # emacs key bindings with a configurable `emacs_bindings_in_vi_insert_mode` 228 # toggle; when the toggle is on user can access keybindigns like `ctrl + e` 229 # in vi insert mode. Because some of the emacs bindings involve `escape` 230 # followed by another key, e.g. `escape` followed by `f`, prompt-toolkit 231 # needs to wait to see if there will be another character typed in before 232 # executing pure `escape` keybinding; in vi insert mode `escape` switches to 233 # command mode which is common and performance critical action for vi users. 234 # To avoid the delay users employ a workaround: 235 # https://github.com/ipython/ipython/issues/13443#issuecomment-1032753703 236 # which involves switching `emacs_bindings_in_vi_insert_mode` off. 237 # 238 # For the workaround to work: 239 # 1) end users need to toggle `emacs_bindings_in_vi_insert_mode` off 240 # 2) all keybindings which would involve `escape` need to respect that 241 # toggle by including either: 242 # - `vi_insert_mode & ebivim` for actions which have emacs keybindings 243 # predefined upstream in prompt-toolkit, or 244 # - `emacs_like_insert_mode` for actions which do not have existing 245 # emacs keybindings predefined upstream (or need overriding of the 246 # upstream bindings to modify behaviour), defined below. 247 "emacs_like_insert_mode": (vi_insert_mode & ebivim) | emacs_insert_mode, 248 "has_completions": has_completions, 249 "insert_mode": vi_insert_mode | emacs_insert_mode, 250 "default_buffer_focused": default_buffer_focused, 251 "search_buffer_focused": has_focus(SEARCH_BUFFER), 252 # `ebivim` stands for emacs bindings in vi insert mode 253 "ebivim": ebivim, 254 "supports_suspend": supports_suspend, 255 "is_windows_os": is_windows_os, 256 "auto_match": auto_match, 257 "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused, 258 "not_inside_unclosed_string": not_inside_unclosed_string, 259 "readline_like_completions": readline_like_completions, 260 "preceded_by_paired_double_quotes": preceding_text( 261 lambda line: all_quotes_paired('"', line) 262 ), 263 "preceded_by_paired_single_quotes": preceding_text( 264 lambda line: all_quotes_paired("'", line) 265 ), 266 "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"), 267 "preceded_by_two_double_quotes": preceding_text(r'^.*""$'), 268 "preceded_by_two_single_quotes": preceding_text(r"^.*''$"), 269 "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"), 270 "preceded_by_opening_round_paren": preceding_text(r".*\($"), 271 "preceded_by_opening_bracket": preceding_text(r".*\[$"), 272 "preceded_by_opening_brace": preceding_text(r".*\{$"), 273 "preceded_by_double_quote": preceding_text('.*"$'), 274 "preceded_by_single_quote": preceding_text(r".*'$"), 275 "followed_by_closing_round_paren": following_text(r"^\)"), 276 "followed_by_closing_bracket": following_text(r"^\]"), 277 "followed_by_closing_brace": following_text(r"^\}"), 278 "followed_by_double_quote": following_text('^"'), 279 "followed_by_single_quote": following_text("^'"), 280 "navigable_suggestions": navigable_suggestions, 281 "cursor_in_leading_ws": cursor_in_leading_ws, 282 "pass_through": pass_through, 283 } 284 285 286 def eval_node(node: Union[ast.AST, None]): 287 if node is None: 288 return None 289 if isinstance(node, ast.Expression): 290 return eval_node(node.body) 291 if isinstance(node, ast.BinOp): 292 left = eval_node(node.left) 293 right = eval_node(node.right) 294 dunders = _find_dunder(node.op, BINARY_OP_DUNDERS) 295 if dunders: 296 return getattr(left, dunders[0])(right) 297 raise ValueError(f"Unknown binary operation: {node.op}") 298 if isinstance(node, ast.UnaryOp): 299 value = eval_node(node.operand) 300 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS) 301 if dunders: 302 return getattr(value, dunders[0])() 303 raise ValueError(f"Unknown unary operation: {node.op}") 304 if isinstance(node, ast.Name): 305 if node.id in KEYBINDING_FILTERS: 306 return KEYBINDING_FILTERS[node.id] 307 else: 308 sep = "\n - " 309 known_filters = sep.join(sorted(KEYBINDING_FILTERS)) 310 raise NameError( 311 f"{node.id} is not a known shortcut filter." 312 f" Known filters are: {sep}{known_filters}." 313 ) 314 raise ValueError("Unhandled node", ast.dump(node)) 315 316 317 def filter_from_string(code: str): 318 expression = ast.parse(code, mode="eval") 319 return eval_node(expression) 320 321 322 __all__ = ["KEYBINDING_FILTERS", "filter_from_string"] 323