diff --git a/bpython/config.py b/bpython/config.py index 48686610..e19f9ee9 100644 --- a/bpython/config.py +++ b/bpython/config.py @@ -152,6 +152,7 @@ class Config: "syntax": True, "tab_length": 4, "unicode_box": True, + "brackets_completion": False, }, "keyboard": { "backspace": "C-h", @@ -358,6 +359,9 @@ def get_key_no_doublebind(command: str) -> str: if self.unicode_box and supports_box_chars() else ("|", "|", "-", "-", "+", "+", "+", "+") ) + self.brackets_completion = config.getboolean( + "general", "brackets_completion" + ) def load_theme( diff --git a/bpython/curtsiesfrontend/manual_readline.py b/bpython/curtsiesfrontend/manual_readline.py index afeb5553..f95e66c5 100644 --- a/bpython/curtsiesfrontend/manual_readline.py +++ b/bpython/curtsiesfrontend/manual_readline.py @@ -5,9 +5,10 @@ based on http://www.bigsmoke.us/readline/shortcuts""" from ..lazyre import LazyReCompile - import inspect +from ..line import cursor_on_closing_char_pair + INDENT = 4 # TODO Allow user config of keybindings for these actions @@ -244,6 +245,18 @@ def backspace(cursor_offset, line): cursor_offset - to_delete, line[: cursor_offset - to_delete] + line[cursor_offset:], ) + # removes opening bracket along with closing bracket + # if there is nothing between them + # TODO: could not get config value here, works even without -B option + on_closing_char, pair_close = cursor_on_closing_char_pair( + cursor_offset, line + ) + if on_closing_char and pair_close: + return ( + cursor_offset - 1, + line[: cursor_offset - 1] + line[cursor_offset + 1 :], + ) + return (cursor_offset - 1, line[: cursor_offset - 1] + line[cursor_offset:]) diff --git a/bpython/curtsiesfrontend/repl.py b/bpython/curtsiesfrontend/repl.py index 30fbbb14..657a0188 100644 --- a/bpython/curtsiesfrontend/repl.py +++ b/bpython/curtsiesfrontend/repl.py @@ -42,7 +42,7 @@ Interp, code_finished_will_parse, ) -from .manual_readline import edit_keys +from .manual_readline import edit_keys, cursor_on_closing_char_pair from .parse import parse as bpythonparse, func_for_letter, color_for_letter from .preprocess import preprocess from .. import __version__ @@ -54,6 +54,7 @@ SourceNotFound, ) from ..translations import _ +from ..line import CHARACTER_PAIR_MAP logger = logging.getLogger(__name__) @@ -789,9 +790,74 @@ def process_key_event(self, e): self.incr_search_mode = None elif e in ("",): self.add_normal_character(" ") + elif e in CHARACTER_PAIR_MAP.keys(): + if e in ["'", '"']: + if self.is_closing_quote(e): + self.insert_char_pair_end(e) + else: + self.insert_char_pair_start(e) + else: + self.insert_char_pair_start(e) + elif e in CHARACTER_PAIR_MAP.values(): + self.insert_char_pair_end(e) else: self.add_normal_character(e) + def is_closing_quote(self, e): + char_count = self._current_line.count(e) + if ( + char_count % 2 == 0 + and cursor_on_closing_char_pair( + self._cursor_offset, self._current_line, e + )[0] + ): + return True + return False + + def insert_char_pair_start(self, e): + """Accepts character which is a part of CHARACTER_PAIR_MAP + like brackets and quotes, and appends it to the line with + an appropriate character pair ending. Closing character can only be inserted + when the next character is either a closing character or a space + + e.x. if you type "(" (lparen) , this will insert "()" + into the line + """ + self.add_normal_character(e) + if self.config.brackets_completion: + allowed_chars = ["}", ")", "]", " "] + start_of_line = len(self._current_line) == 1 + end_of_line = len(self._current_line) == self._cursor_offset + can_lookup_next = len(self._current_line) > self._cursor_offset + next_char = ( + None + if not can_lookup_next + else self._current_line[self._cursor_offset] + ) + next_char_allowed = next_char in allowed_chars + if start_of_line or end_of_line or next_char_allowed: + closing_char = CHARACTER_PAIR_MAP[e] + self.add_normal_character(closing_char, narrow_search=False) + self._cursor_offset -= 1 + + def insert_char_pair_end(self, e): + """Accepts character which is a part of CHARACTER_PAIR_MAP + like brackets and quotes, and checks whether it should be + inserted to the line or overwritten + + e.x. if you type ")" (rparen) , and your cursor is directly + above another ")" (rparen) in the cmd, this will just skip + it and move the cursor. + If there is no same character underneath the cursor, the + character will be printed/appended to the line + """ + if self.config.brackets_completion: + if self.cursor_offset < len(self._current_line): + if self._current_line[self.cursor_offset] == e: + self.cursor_offset += 1 + return + self.add_normal_character(e) + def get_last_word(self): previous_word = _last_word(self.rl_history.entry) @@ -892,7 +958,15 @@ def only_whitespace_left_of_cursor(): for unused in range(to_add): self.add_normal_character(" ") return - + # if cursor on closing character from pair, + # moves cursor behind it on tab + # ? should we leave it here as default? + if self.config.brackets_completion: + on_closing_char, _ = cursor_on_closing_char_pair( + self._cursor_offset, self._current_line + ) + if on_closing_char: + self._cursor_offset += 1 # run complete() if we don't already have matches if len(self.matches_iter.matches) == 0: self.list_win_visible = self.complete(tab=True) @@ -904,7 +978,6 @@ def only_whitespace_left_of_cursor(): # using _current_line so we don't trigger a completion reset if not self.matches_iter.matches: self.list_win_visible = self.complete() - elif self.matches_iter.matches: self.current_match = ( back and self.matches_iter.previous() or next(self.matches_iter) @@ -913,6 +986,24 @@ def only_whitespace_left_of_cursor(): self._cursor_offset, self._current_line = cursor_and_line # using _current_line so we don't trigger a completion reset self.list_win_visible = True + if self.config.brackets_completion: + # appends closing char pair if completion is a callable + if self.is_completion_callable(self._current_line): + self._current_line = self.append_closing_character( + self._current_line + ) + + def is_completion_callable(self, completion): + """Checks whether given completion is callable (e.x. function)""" + completion_end = completion[-1] + return completion_end in CHARACTER_PAIR_MAP + + def append_closing_character(self, completion): + """Appends closing character/bracket to the completion""" + completion_end = completion[-1] + if completion_end in CHARACTER_PAIR_MAP: + completion = f"{completion}{CHARACTER_PAIR_MAP[completion_end]}" + return completion def on_control_d(self): if self.current_line == "": @@ -1060,7 +1151,7 @@ def toggle_file_watch(self): ) # Handler Helpers - def add_normal_character(self, char): + def add_normal_character(self, char, narrow_search=True): if len(char) > 1 or is_nop(char): return if self.incr_search_mode: @@ -1076,12 +1167,18 @@ def add_normal_character(self, char): reset_rl_history=False, clear_special_mode=False, ) - self.cursor_offset += 1 + if narrow_search: + self.cursor_offset += 1 + else: + self._cursor_offset += 1 if self.config.cli_trim_prompts and self.current_line.startswith( self.ps1 ): self.current_line = self.current_line[4:] - self.cursor_offset = max(0, self.cursor_offset - 4) + if narrow_search: + self.cursor_offset = max(0, self.cursor_offset - 4) + else: + self._cursor_offset += max(0, self.cursor_offset - 4) def add_to_incremental_search(self, char=None, backspace=False): """Modify the current search term while in incremental search. diff --git a/bpython/line.py b/bpython/line.py index b98302dd..cbfa682d 100644 --- a/bpython/line.py +++ b/bpython/line.py @@ -19,6 +19,7 @@ class LinePart(NamedTuple): _current_word_re = LazyReCompile(r"(? Optional[LinePart]: @@ -287,3 +288,25 @@ def current_expression_attribute( if m.start(1) <= cursor_offset <= m.end(1): return LinePart(m.start(1), m.end(1), m.group(1)) return None + + +def cursor_on_closing_char_pair(cursor_offset, line, ch=None): + """Checks if cursor sits on closing character of a pair + and whether its pair character is directly behind it + """ + on_closing_char, pair_close = False, False + if line is None: + return on_closing_char, pair_close + if cursor_offset < len(line): + cur_char = line[cursor_offset] + if cur_char in CHARACTER_PAIR_MAP.values(): + on_closing_char = True if not ch else cur_char == ch + if cursor_offset > 0: + prev_char = line[cursor_offset - 1] + if ( + on_closing_char + and prev_char in CHARACTER_PAIR_MAP + and CHARACTER_PAIR_MAP[prev_char] == cur_char + ): + pair_close = True if not ch else prev_char == ch + return on_closing_char, pair_close diff --git a/bpython/sample-config b/bpython/sample-config index 287e8a2f..03127f1b 100644 --- a/bpython/sample-config +++ b/bpython/sample-config @@ -61,6 +61,8 @@ # Enable autoreload feature by default (default: False). # default_autoreload = False +# Enable autocompletion of brackets and quotes (default: False) +# brackets_completion = False [keyboard] diff --git a/bpython/test/test_brackets_completion.py b/bpython/test/test_brackets_completion.py new file mode 100644 index 00000000..fd983665 --- /dev/null +++ b/bpython/test/test_brackets_completion.py @@ -0,0 +1,149 @@ +import os + +from bpython.test import FixLanguageTestCase as TestCase, TEST_CONFIG +from bpython.curtsiesfrontend import repl as curtsiesrepl +from bpython import config + + +def setup_config(conf): + config_struct = config.Config(TEST_CONFIG) + for key, value in conf.items(): + if not hasattr(config_struct, key): + raise ValueError(f"{key!r} is not a valid config attribute") + setattr(config_struct, key, value) + return config_struct + + +def create_repl(brackets_enabled=False, **kwargs): + config = setup_config( + {"editor": "true", "brackets_completion": brackets_enabled} + ) + repl = curtsiesrepl.BaseRepl(config, **kwargs) + os.environ["PAGER"] = "true" + os.environ.pop("PYTHONSTARTUP", None) + repl.width = 50 + repl.height = 20 + return repl + + +class TestBracketCompletionEnabled(TestCase): + def setUp(self): + self.repl = create_repl(brackets_enabled=True) + + def process_multiple_events(self, event_list): + for event in event_list: + self.repl.process_event(event) + + def test_start_line(self): + self.repl.process_event("(") + self.assertEqual(self.repl._current_line, "()") + self.assertEqual(self.repl._cursor_offset, 1) + + def test_nested_brackets(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 3) + + def test_quotes(self): + self.process_multiple_events(["(", "'", "x", "", ","]) + self.process_multiple_events(["[", '"', "y", "", "", ""]) + self.assertEqual(self.repl._current_line, """('x',["y"])""") + self.assertEqual(self.repl._cursor_offset, 11) + + def test_bracket_overwrite_closing_char(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 3) + self.process_multiple_events(["}", "]", ")"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 6) + + def test_brackets_move_cursor_on_tab(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 3) + self.repl.process_event("") + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 4) + self.repl.process_event("") + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 5) + self.repl.process_event("") + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 6) + + def test_brackets_non_whitespace_following_char(self): + self.repl.current_line = "s = s.connect('localhost', 8080)" + self.repl.cursor_offset = 14 + self.repl.process_event("(") + self.assertEqual( + self.repl._current_line, "s = s.connect(('localhost', 8080)" + ) + self.assertEqual(self.repl._cursor_offset, 15) + + def test_brackets_deletion_on_backspace(self): + self.repl.current_line = "def foo()" + self.repl.cursor_offset = 8 + self.repl.process_event("") + self.assertEqual(self.repl._current_line, "def foo") + self.assertEqual(self.repl.cursor_offset, 7) + + def test_brackets_deletion_on_backspace_nested(self): + self.repl.current_line = '([{""}])' + self.repl.cursor_offset = 4 + self.process_multiple_events( + ["", "", ""] + ) + self.assertEqual(self.repl._current_line, "()") + self.assertEqual(self.repl.cursor_offset, 1) + + +class TestBracketCompletionDisabled(TestCase): + def setUp(self): + self.repl = create_repl(brackets_enabled=False) + + def process_multiple_events(self, event_list): + for event in event_list: + self.repl.process_event(event) + + def test_start_line(self): + self.repl.process_event("(") + self.assertEqual(self.repl._current_line, "(") + self.assertEqual(self.repl._cursor_offset, 1) + + def test_nested_brackets(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, "([{") + self.assertEqual(self.repl._cursor_offset, 3) + + def test_bracket_overwrite_closing_char(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{""") + self.assertEqual(self.repl._cursor_offset, 3) + self.process_multiple_events(["}", "]", ")"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 6) + + def test_brackets_move_cursor_on_tab(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{""") + self.assertEqual(self.repl._cursor_offset, 3) + self.repl.process_event("") + self.assertEqual(self.repl._current_line, """([{""") + self.assertEqual(self.repl._cursor_offset, 3) + + def test_brackets_deletion_on_backspace(self): + self.repl.current_line = "def foo()" + self.repl.cursor_offset = 8 + self.repl.process_event("") + self.assertEqual(self.repl._current_line, "def foo") + self.assertEqual(self.repl.cursor_offset, 7) + + def test_brackets_deletion_on_backspace_nested(self): + self.repl.current_line = '([{""}])' + self.repl.cursor_offset = 4 + self.process_multiple_events( + ["", "", ""] + ) + self.assertEqual(self.repl._current_line, "()") + self.assertEqual(self.repl.cursor_offset, 1)