diff --git a/changelog.md b/changelog.md index 204a51f9..4278fef6 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ Bug Fixes Internal -------- * Tune Codex reviews. +* Refactor `is_inside_quotes()` detection. 1.54.0 (2026/02/16) diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index fd134ea7..ccc890ec 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -1,6 +1,6 @@ import functools import re -from typing import Any +from typing import Any, Literal import sqlparse from sqlparse.sql import Comparison, Identifier, Token, Where @@ -22,7 +22,7 @@ def _enum_value_suggestion(text_before_cursor: str, full_text: str) -> dict[str, match = _ENUM_VALUE_RE.search(text_before_cursor) if not match: return None - if _is_inside_quotes(text_before_cursor, match.start("lhs")): + if is_inside_quotes(text_before_cursor, match.start("lhs")): return None lhs = match.group("lhs") @@ -43,25 +43,82 @@ def _is_where_or_having(token: Token | None) -> bool: return bool(token and token.value and token.value.lower() in ("where", "having")) +def _find_doubled_backticks(text: str) -> list[int]: + length = len(text) + doubled_backticks: list[int] = [] + backtick = '`' + + for index in range(0, length): + ch = text[index] + if ch != backtick: + index += 1 + continue + if index + 1 < length and text[index + 1] == backtick: + doubled_backticks.append(index) + doubled_backticks.append(index + 1) + index += 2 + continue + index += 1 + + return doubled_backticks + + @functools.lru_cache(maxsize=128) -def _is_inside_quotes(text: str, pos: int) -> bool: +def is_inside_quotes(text: str, pos: int) -> Literal[False, 'single', 'double', 'backtick']: in_single = False in_double = False + in_backticks = False escaped = False - - for ch in text[:pos]: - if escaped: + doubled_backtick_positions = [] + single_quote = "'" + double_quote = '"' + backtick = '`' + backslash = '\\' + + # scanning the string twice seems to be needed to handle doubled backticks + if backtick in text: + doubled_backtick_positions = _find_doubled_backticks(text) + + length = len(text) + if pos < 0: + pos = length + pos + pos = max(pos, 0) + pos = min(length, pos) + + # optimization + up_to_pos = text[:pos] + if backtick not in up_to_pos and single_quote not in up_to_pos and double_quote not in up_to_pos: + return False + + for index in range(0, pos): + ch = text[index] + if index in doubled_backtick_positions: + index += 1 + continue + if escaped and (in_double or in_single): escaped = False + index += 1 continue - if ch == "\\": + if ch == backslash and (in_double or in_single): escaped = True + index += 1 continue - if ch == "'" and not in_double: + if ch == backtick and not in_double and not in_single: + in_backticks = not in_backticks + elif ch == single_quote and not in_double and not in_backticks: in_single = not in_single - elif ch == '"' and not in_single: + elif ch == double_quote and not in_single and not in_backticks: in_double = not in_double - - return in_single or in_double + index += 1 + + if in_single: + return 'single' + elif in_double: + return 'double' + elif in_backticks: + return 'backtick' + else: + return False def suggest_type(full_text: str, text_before_cursor: str) -> list[dict[str, Any]]: @@ -197,7 +254,7 @@ def suggest_based_on_last_token( # less efficient, but handles all cases # in fact, this is quite slow, but not as slow as offering completions! # faster would be to peek inside the Pygments lexer run by prompt_toolkit -- how? - if _is_inside_quotes(text_before_cursor, -1): + if is_inside_quotes(text_before_cursor, -1) in ['single', 'double']: return [] if isinstance(token, str): @@ -383,7 +440,7 @@ def suggest_based_on_last_token( # "CREATE DATABASE WITH TEMPLATE " return [{"type": "database"}] - elif _is_inside_quotes(text_before_cursor, -1): + elif is_inside_quotes(text_before_cursor, -1) in ['single', 'double']: return [] elif token_v.endswith(",") or is_operand(token_v) or token_v in ["=", "and", "or"]: diff --git a/test/test_completion_engine.py b/test/test_completion_engine.py index 0528d05a..da7ba558 100644 --- a/test/test_completion_engine.py +++ b/test/test_completion_engine.py @@ -3,7 +3,11 @@ import pytest from mycli.packages import special -from mycli.packages.completion_engine import suggest_type +from mycli.packages.completion_engine import ( + _find_doubled_backticks, + is_inside_quotes, + suggest_type, +) def sorted_dicts(dicts): @@ -628,3 +632,81 @@ def test_quoted_where(): text = "'where i=';" suggestions = suggest_type(text, text) assert suggestions == [{"type": "keyword"}] + + +def test_find_doubled_backticks_none(): + text = 'select `ab`' + assert _find_doubled_backticks(text) == [] + + +def test_find_doubled_backticks_some(): + text = 'select `a``b`' + assert _find_doubled_backticks(text) == [9, 10] + + +def test_inside_quotes_01(): + text = "select '" + assert is_inside_quotes(text, len(text)) == 'single' + + +def test_inside_quotes_02(): + text = "select '\\'" + assert is_inside_quotes(text, len(text)) == 'single' + + +def test_inside_quotes_03(): + text = "select '`" + assert is_inside_quotes(text, len(text)) == 'single' + + +def test_inside_quotes_04(): + text = 'select "' + assert is_inside_quotes(text, len(text)) == 'double' + + +def test_inside_quotes_05(): + text = 'select "\\"\'' + assert is_inside_quotes(text, len(text)) == 'double' + + +def test_inside_quotes_06(): + text = 'select ""' + assert is_inside_quotes(text, len(text)) is False + + +@pytest.mark.parametrize( + ["text", "position", "expected"], + [ + ("select `'", len("select `'"), 'backtick'), + ("select `' ", len("select `' "), 'backtick'), + ("select `'", -1, 'backtick'), + ("select `'", -2, False), + ('select `ab` ', -1, False), + ('select `ab` ', -2, 'backtick'), + ('select `a``b` ', -1, False), + ('select `a``b` ', -2, 'backtick'), + ('select `a``b` ', -3, 'backtick'), + ('select `a``b` ', -4, 'backtick'), + ('select `a``b` ', -5, 'backtick'), + ('select `a``b` ', -6, 'backtick'), + ('select `a``b` ', -7, False), + ] +) # fmt: skip +def test_inside_quotes_backtick_01(text, position, expected): + assert is_inside_quotes(text, position) == expected + + +def test_inside_quotes_backtick_02(): + """Empty backtick pairs are treated as a doubled (escaped) backtick. + This is okay because it is invalid SQL, and we don't have to complete on it. + """ + text = 'select ``' + assert is_inside_quotes(text, -1) is False + + +def test_inside_quotes_backtick_03(): + """Empty backtick pairs are treated as a doubled (escaped) backtick. + This is okay because it is invalid SQL, and we don't have to complete on it. + """ + text = 'select ``' + assert is_inside_quotes(text, -2) is False