diff --git a/changelog.md b/changelog.md index 1d3e0d83..35a4ea39 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Features --------- * Show purpose in tabular `--checkup` output. * Add optional dependencies to `--checkup`. +* Improve completions for `/command`s. Documentation diff --git a/mycli/client_commands.py b/mycli/client_commands.py index 286acb98..370b5d39 100644 --- a/mycli/client_commands.py +++ b/mycli/client_commands.py @@ -52,7 +52,7 @@ def register_special_commands(self) -> None: "rehash", "/rehash", "Refresh auto-completions.", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, aliases=[SpecialCommandAlias("\\#", case_sensitive=False)], ) special.register_special_command( diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index 0ff724d5..875531d6 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -71,6 +71,10 @@ def _keyword_suggestions() -> list[Suggestion]: return [{'type': 'keyword'}] +def _special_suggestions() -> list[Suggestion]: + return [{'type': 'special'}] + + def _keyword_and_special_suggestions() -> list[Suggestion]: return [{'type': 'keyword'}, {'type': 'special'}] @@ -144,8 +148,11 @@ def _emit_none_token(_ctx: SuggestContext) -> list[Suggestion]: return _keyword_suggestions() -def _emit_blank_token(_ctx: SuggestContext) -> list[Suggestion]: - return _keyword_and_special_suggestions() +def _emit_blank_token(ctx: SuggestContext) -> list[Suggestion]: + if ctx.text_before_cursor.startswith('/'): + return _special_suggestions() + else: + return _keyword_and_special_suggestions() def _emit_star(_ctx: SuggestContext) -> list[Suggestion]: @@ -793,7 +800,8 @@ def suggest_special(text: str) -> list[dict[str, Any]]: {"type": "view", "schema": []}, {"type": "schema"}, ] - elif cmd.lower() in [ + + if cmd.lower() in [ r'\.', r'/.', 'source', @@ -806,14 +814,16 @@ def suggest_special(text: str) -> list[dict[str, Any]]: '/tee', ]: return [{"type": "file_name"}] + # todo: why is \edit case-sensitive? - elif cmd in [ + if cmd in [ r'\e', '/e', r'\edit', '/edit', ]: return [{"type": "file_name"}] + if cmd in ["\\llm", "/llm", "\\ai", "/ai"]: return [{"type": "llm"}] @@ -828,7 +838,17 @@ def suggest_special(text: str) -> list[dict[str, Any]]: return [] return [{'type': 'special_subcommand', 'subcommands': list(DSN_SUBCOMMANDS)}] - return [{"type": "keyword"}, {"type": "special"}] + if cmd.lower() in [ + 'help', + '/help', + r'\help', + '?', + '/?', + r'\?', + ]: + return [{"type": "keyword"}, {"type": "special"}] + + return [] def suggest_based_on_last_token( diff --git a/mycli/packages/special/iocommands.py b/mycli/packages/special/iocommands.py index 9c607908..fcd4e822 100644 --- a/mycli/packages/special/iocommands.py +++ b/mycli/packages/special/iocommands.py @@ -98,7 +98,7 @@ def is_show_warnings_enabled() -> bool: 'warnings', '/warnings', 'Enable automatic warnings display.', - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, aliases=[SpecialCommandAlias('\\W', case_sensitive=True)], ) @@ -113,7 +113,7 @@ def enable_show_warnings() -> Generator[SQLResult, None, None]: 'nowarnings', '/nowarnings', 'Disable automatic warnings display.', - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, aliases=[SpecialCommandAlias('\\w', case_sensitive=True)], ) @@ -152,7 +152,7 @@ def set_pager(arg: str, **_) -> list[SQLResult]: "nopager", "/nopager", "Disable pager; print to stdout.", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, aliases=[SpecialCommandAlias("\\n", case_sensitive=True)], ) @@ -165,7 +165,7 @@ def disable_pager() -> list[SQLResult]: "\\timing", "/timing", "Toggle timing of queries.", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, aliases=[SpecialCommandAlias("\\t", case_sensitive=True)], ) diff --git a/mycli/packages/special/main.py b/mycli/packages/special/main.py index da34d9d9..d881b5e6 100644 --- a/mycli/packages/special/main.py +++ b/mycli/packages/special/main.py @@ -27,7 +27,7 @@ class ArgType(Enum): - NO_QUERY = 0 + NO_ARGUMENT = 0 PARSED_QUERY = 1 RAW_QUERY = 2 @@ -205,7 +205,7 @@ def execute(cur: Cursor, sql: str) -> list[SQLResult]: if command.lower().startswith(("help", "/help", "\\?", "/?", "?")) and arg: return show_keyword_help(cur=cur, arg=arg) - if special_cmd.arg_type == ArgType.NO_QUERY: + if special_cmd.arg_type == ArgType.NO_ARGUMENT: return special_cmd.handler() elif special_cmd.arg_type == ArgType.PARSED_QUERY: return special_cmd.handler(cur=cur, arg=arg, command_verbosity=(command_verbosity == CommandVerbosity.VERBOSE)) @@ -219,7 +219,7 @@ def execute(cur: Cursor, sql: str) -> list[SQLResult]: "help", "/help [term]", "Show this table, or search for help on a term.", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, aliases=[SpecialCommandAlias("\\?", case_sensitive=False), SpecialCommandAlias("?", case_sensitive=False)], ) def show_help(*_args) -> list[SQLResult]: @@ -283,7 +283,7 @@ def show_keyword_help(cur: Cursor, arg: str) -> list[SQLResult]: return _show_mysql_help(cur, keyword) -@special_command('\\bug', '/bug', 'File a bug on GitHub.', arg_type=ArgType.NO_QUERY) +@special_command('\\bug', '/bug', 'File a bug on GitHub.', arg_type=ArgType.NO_ARGUMENT) def file_bug(*_args) -> list[SQLResult]: webbrowser.open_new_tab(ISSUES_URL) return [SQLResult(status=f'{ISSUES_URL} — press "New Issue"')] @@ -293,14 +293,14 @@ def file_bug(*_args) -> list[SQLResult]: "exit", "/exit", "Exit.", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, aliases=[SpecialCommandAlias("\\q", case_sensitive=False)], ) @special_command( "quit", "/quit", "Quit.", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, aliases=[SpecialCommandAlias("\\q", case_sensitive=False)], ) def quit_(*_args): @@ -311,7 +311,7 @@ def quit_(*_args): "\\edit", "/edit | \\edit", "Edit query with editor (uses $VISUAL or $EDITOR).", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, aliases=[SpecialCommandAlias("\\e", case_sensitive=True)], ) @@ -319,14 +319,14 @@ def quit_(*_args): "\\clip", "/clip | \\clip", "Copy query to the system clipboard.", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, ) @special_command( "\\G", "\\G", "Display query results vertically.", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, backslash_only=True, ) @@ -334,7 +334,7 @@ def quit_(*_args): "\\g", "\\g", "Display query results (mnemonic: go).", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, backslash_only=True, ) @@ -342,7 +342,7 @@ def quit_(*_args): "\\x", "\\x", "Display query results in an explorer rather than a pager.", - arg_type=ArgType.NO_QUERY, + arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, backslash_only=True, ) diff --git a/test/pytests/test_completion_engine.py b/test/pytests/test_completion_engine.py index 2330dda5..1f760e19 100644 --- a/test/pytests/test_completion_engine.py +++ b/test/pytests/test_completion_engine.py @@ -4,6 +4,8 @@ import pytest import sqlparse +from sqlparse import tokens +from sqlparse.sql import Statement, Token from mycli.packages import completion_engine, special from mycli.packages.completion_engine import ( @@ -428,9 +430,16 @@ def test_emit_none_token(): assert _emit_none_token(context) == [{'type': 'keyword'}] -def test_emit_blank_token(): - context = _build_suggest_context('', '', None, '', empty_identifier()) - assert _emit_blank_token(context) == [{'type': 'keyword'}, {'type': 'special'}] +@pytest.mark.parametrize( + ('text_before_cursor', 'expected'), + [ + ('', [{'type': 'keyword'}, {'type': 'special'}]), + ('/', [{'type': 'special'}]), + ], +) +def test_emit_blank_token(text_before_cursor, expected): + context = _build_suggest_context('', text_before_cursor, None, '', empty_identifier()) + assert _emit_blank_token(context) == expected def test_emit_star(): @@ -816,6 +825,25 @@ def fake_parse(text: str): assert suggestions == [{'type': 'special'}] +def test_suggest_type_handles_whitespace_only_statement(monkeypatch): + whitespace_statement = Statement([Token(tokens.Text.Whitespace, ' ')]) + monkeypatch.setattr(completion_engine.sqlparse, 'parse', lambda _text: [whitespace_statement]) + monkeypatch.setattr(completion_engine, 'suggest_based_on_last_token', lambda *_args: [{'type': 'fallback'}]) + + assert suggest_type(' ', ' ') == [{'type': 'fallback'}] + + +def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch): + statements = [ + Statement([Token(tokens.Keyword, 'SELECT')]), + Statement([Token(tokens.Keyword, 'SELECT')]), + ] + monkeypatch.setattr(completion_engine.sqlparse, 'parse', lambda _text: statements) + monkeypatch.setattr(completion_engine, 'suggest_based_on_last_token', lambda *_args: [{'type': 'fallback'}]) + + assert suggest_type('long cursor text', 'long cursor text') == [{'type': 'fallback'}] + + @pytest.mark.parametrize( ('text', 'expected'), [ @@ -848,7 +876,8 @@ def fake_parse(text: str): ('/dsn delete prod ', []), ('/dsn show', []), ('/dsn help ', []), - ('pager ', [{'type': 'keyword'}, {'type': 'special'}]), + ('/help ', [{'type': 'keyword'}, {'type': 'special'}]), + ('/pager ', []), ], ) def test_suggest_special(text, expected): diff --git a/test/pytests/test_special_main.py b/test/pytests/test_special_main.py index 62f14f9a..5b74bd58 100644 --- a/test/pytests/test_special_main.py +++ b/test/pytests/test_special_main.py @@ -179,7 +179,7 @@ def test_execute_raises_when_case_sensitive_exact_lookup_falls_back_to_lowercase 'Camel', 'Camel', 'Description', - arg_type=special_main.ArgType.NO_QUERY, + arg_type=special_main.ArgType.NO_ARGUMENT, hidden=False, case_sensitive=True, aliases=None, @@ -204,7 +204,7 @@ def handler() -> list[SQLResult]: 'demo', 'demo', 'Description', - arg_type=special_main.ArgType.NO_QUERY, + arg_type=special_main.ArgType.NO_ARGUMENT, ) assert special_main.execute(cast(Any, None), 'demo') == [SQLResult(status='ok')] @@ -224,7 +224,7 @@ def handler() -> list[SQLResult]: 'demo', 'demo', 'Description', - arg_type=special_main.ArgType.NO_QUERY, + arg_type=special_main.ArgType.NO_ARGUMENT, ) assert special_main.execute(cast(Any, None), 'DEMO') == [SQLResult(status='ok')]