Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Bug Fixes
Internal
--------
* Tune Codex reviews.
* Refactor `is_inside_quotes()` detection.


1.54.0 (2026/02/16)
Expand Down
83 changes: 70 additions & 13 deletions mycli/packages/completion_engine.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand All @@ -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]]:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -383,7 +440,7 @@ def suggest_based_on_last_token(
# "CREATE DATABASE <newdb> WITH TEMPLATE <db>"
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"]:
Expand Down
84 changes: 83 additions & 1 deletion test/test_completion_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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