From 70b9ebbc2857dedbde7708f1414d28e8e89aea31 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 4 Apr 2026 18:04:47 -0400 Subject: [PATCH] move complete_while_typing_filter() to repl.py Still a bit hacky as we need to set repl_package.MIN_COMPLETION_TRIGGER directly. But, one more function migrated out of main.py. No functional change. --- mycli/main.py | 38 +++------------------------- mycli/main_modes/repl.py | 35 +++++++++++++++++++++++-- test/pytests/test_main_modes_repl.py | 30 ++++++++++++++++++++++ test/pytests/test_main_regression.py | 24 ------------------ 4 files changed, 66 insertions(+), 61 deletions(-) diff --git a/mycli/main.py b/mycli/main.py index 97d4513e..74aaff91 100755 --- a/mycli/main.py +++ b/mycli/main.py @@ -32,10 +32,8 @@ from configobj import ConfigObj import keyring from prompt_toolkit import print_formatted_text -from prompt_toolkit.application.current import get_app from prompt_toolkit.completion import Completion from prompt_toolkit.document import Document -from prompt_toolkit.filters import Condition from prompt_toolkit.formatted_text import ( ANSI, HTML, @@ -65,6 +63,7 @@ ISSUES_URL, REPO_URL, ) +from mycli.main_modes import repl as repl_package from mycli.main_modes.batch import ( main_batch_from_stdin, main_batch_with_progress_bar, @@ -93,39 +92,9 @@ sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None # type: ignore[assignment] sqlparse.engine.grouping.MAX_GROUPING_TOKENS = None # type: ignore[assignment] -MIN_COMPLETION_TRIGGER = 1 EMPTY_PASSWORD_FLAG_SENTINEL = -1 -@Condition -def complete_while_typing_filter() -> bool: - """Whether enough characters have been typed to trigger completion. - - Written in a verbose way, with a string slice, for efficiency.""" - if MIN_COMPLETION_TRIGGER <= 1: - return True - app = get_app() - text = app.current_buffer.text.lstrip() - text_len = len(text) - if text_len < MIN_COMPLETION_TRIGGER: - return False - last_word = text[-MIN_COMPLETION_TRIGGER:] - if len(last_word) == text_len: - return text_len >= MIN_COMPLETION_TRIGGER - if text[:6].lower() in ['source', r'\.']: - # Different word characters for paths; see comment below. - # In fact, it might be nice if paths had a different threshold. - return not bool(re.search(r'[\s!-,:-@\[-^\{\}-]', last_word)) - else: - # This is "whitespace and all punctuation except underscore and backtick" - # acting as word breaks, but it would be neat if we could complete differently - # when inside a backtick, accepting all legal characters towards the trigger - # limit. We would have to parse the statement, or at least go back more - # characters, costing performance. This still works within a backtick! So - # long as there are three trailing non-punctuation characters. - return not bool(re.search(r'[\s!-/:-@\[-^\{-~]', last_word)) - - class IntOrStringClickParamType(click.ParamType): name = 'text' # display as TEXT in helpdoc @@ -179,8 +148,6 @@ def __init__( warn: bool | None = None, myclirc: str = "~/.myclirc", ) -> None: - global MIN_COMPLETION_TRIGGER - self.sqlexecute = sqlexecute self.logfile = logfile self.defaults_suffix = defaults_suffix @@ -291,7 +258,8 @@ def __init__( self._completer_lock = threading.Lock() self.min_completion_trigger = c["main"].as_int("min_completion_trigger") - MIN_COMPLETION_TRIGGER = self.min_completion_trigger + # a hack, pending a better way to handle settings and state + repl_package.MIN_COMPLETION_TRIGGER = self.min_completion_trigger self.last_prompt_message = ANSI('') self.last_custom_toolbar_message = ANSI('') diff --git a/mycli/main_modes/repl.py b/mycli/main_modes/repl.py index a507a38f..2292af9d 100644 --- a/mycli/main_modes/repl.py +++ b/mycli/main_modes/repl.py @@ -13,10 +13,11 @@ import click import prompt_toolkit +from prompt_toolkit.application.current import get_app from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, ThreadedAutoSuggest from prompt_toolkit.completion import DynamicCompleter from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode -from prompt_toolkit.filters import HasFocus, IsDone +from prompt_toolkit.filters import Condition, HasFocus, IsDone from prompt_toolkit.formatted_text import ( ANSI, ) @@ -66,6 +67,7 @@ SUPPORT_INFO = f"Home: {HOME_URL}\nBug tracker: {ISSUES_URL}" +MIN_COMPLETION_TRIGGER = 1 def _main_module(): @@ -80,6 +82,35 @@ class ReplState: mutating: bool = False +@Condition +def complete_while_typing_filter() -> bool: + """Whether enough characters have been typed to trigger completion. + + Written in a verbose way, with a string slice, for efficiency.""" + if MIN_COMPLETION_TRIGGER <= 1: + return True + app = get_app() + text = app.current_buffer.text.lstrip() + text_len = len(text) + if text_len < MIN_COMPLETION_TRIGGER: + return False + last_word = text[-MIN_COMPLETION_TRIGGER:] + if len(last_word) == text_len: + return text_len >= MIN_COMPLETION_TRIGGER + if text[:6].lower() in ['source', r'\.']: + # Different word characters for paths; see comment below. + # In fact, it might be nice if paths had a different threshold. + return not bool(re.search(r'[\s!-,:-@\[-^\{\}-]', last_word)) + else: + # This is "whitespace and all punctuation except underscore and backtick" + # acting as word breaks, but it would be neat if we could complete differently + # when inside a backtick, accepting all legal characters towards the trigger + # limit. We would have to parse the statement, or at least go back more + # characters, costing performance. This still works within a backtick! So + # long as there are three trailing non-punctuation characters. + return not bool(re.search(r'[\s!-/:-@\[-^\{-~]', last_word)) + + def _create_history(mycli: 'MyCli') -> FileHistoryWithTimestamp | None: history_file = os.path.expanduser(os.environ.get('MYCLI_HISTFILE', mycli.config.get('history_file', '~/.mycli-history'))) if dir_path_exists(history_file): @@ -307,7 +338,7 @@ def _build_prompt_session( complete_in_thread=True, history=history, auto_suggest=ThreadedAutoSuggest(AutoSuggestFromHistory()), - complete_while_typing=_main_module().complete_while_typing_filter, + complete_while_typing=complete_while_typing_filter, multiline=cli_is_multiline(mycli), style=style_factory_ptoolkit(mycli.syntax_style, mycli.cli_style), include_default_pygments_style=False, diff --git a/test/pytests/test_main_modes_repl.py b/test/pytests/test_main_modes_repl.py index 2d1812c6..771566a1 100644 --- a/test/pytests/test_main_modes_repl.py +++ b/test/pytests/test_main_modes_repl.py @@ -244,6 +244,36 @@ def patch_repl_runtime_defaults(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(repl_mode, 'is_mutating', lambda status: False) +def test_complete_while_typing_filter_covers_threshold_and_word_rules(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(repl_mode, 'MIN_COMPLETION_TRIGGER', 3) + monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='ab'))) + assert repl_mode.complete_while_typing_filter() is False + + monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='abc'))) + assert repl_mode.complete_while_typing_filter() is True + + monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='source xyz'))) + assert repl_mode.complete_while_typing_filter() is True + + monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='source x/'))) + assert repl_mode.complete_while_typing_filter() is False + + monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='\\. abc'))) + assert repl_mode.complete_while_typing_filter() is True + + monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='\\. a/'))) + assert repl_mode.complete_while_typing_filter() is False + + monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='select abc'))) + assert repl_mode.complete_while_typing_filter() is True + + monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='select a!'))) + assert repl_mode.complete_while_typing_filter() is False + + monkeypatch.setattr(repl_mode, 'MIN_COMPLETION_TRIGGER', 1) + assert repl_mode.complete_while_typing_filter() is True + + def test_repl_main_module_and_create_history(monkeypatch: pytest.MonkeyPatch) -> None: cli = make_repl_cli() monkeypatch.setenv('MYCLI_HISTFILE', '~/override-history') diff --git a/test/pytests/test_main_regression.py b/test/pytests/test_main_regression.py index a04de3ec..596ad639 100644 --- a/test/pytests/test_main_regression.py +++ b/test/pytests/test_main_regression.py @@ -532,30 +532,6 @@ def __init__(self) -> None: assert mycli.llm_prompt_section_truncate == 0 -def test_complete_while_typing_filter_covers_source_and_sql_word_rules(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(main, 'MIN_COMPLETION_TRIGGER', 3) - monkeypatch.setattr(main, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='ab'))) - assert main.complete_while_typing_filter() is False - - monkeypatch.setattr(main, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='abc'))) - assert main.complete_while_typing_filter() is True - - monkeypatch.setattr(main, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='source xyz'))) - assert main.complete_while_typing_filter() is True - - monkeypatch.setattr(main, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='source x/'))) - assert main.complete_while_typing_filter() is False - - monkeypatch.setattr(main, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='select abc'))) - assert main.complete_while_typing_filter() is True - - monkeypatch.setattr(main, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='select a!'))) - assert main.complete_while_typing_filter() is False - - monkeypatch.setattr(main, 'MIN_COMPLETION_TRIGGER', 1) - assert main.complete_while_typing_filter() is True - - def test_int_or_string_click_param_type_accepts_and_rejects_values() -> None: param_type = main.IntOrStringClickParamType()