Skip to content

Commit 8d5072b

Browse files
committed
refactor is_inside_quotes()
* rename without underscore, as it might be used in another file * recognize backtick quoting, including doubled backticks as escapes * accept negative numbers for "pos" * "escaped" should not be toggled to "True" unless inside a double- or single-quoted string * return a string or "False", typed as Literal * optimize: "is not in" is much faster than looping Motivation: to improve completions which start with backtick.
1 parent fe2dfa1 commit 8d5072b

File tree

3 files changed

+149
-14
lines changed

3 files changed

+149
-14
lines changed

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Bug Fixes
1010
Internal
1111
--------
1212
* Tune Codex reviews.
13+
* Refactor `is_inside_quotes()` detection.
1314

1415

1516
1.54.0 (2026/02/16)

mycli/packages/completion_engine.py

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import functools
22
import re
3-
from typing import Any
3+
from typing import Any, Literal
44

55
import sqlparse
66
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,
2222
match = _ENUM_VALUE_RE.search(text_before_cursor)
2323
if not match:
2424
return None
25-
if _is_inside_quotes(text_before_cursor, match.start("lhs")):
25+
if is_inside_quotes(text_before_cursor, match.start("lhs")):
2626
return None
2727

2828
lhs = match.group("lhs")
@@ -43,25 +43,80 @@ def _is_where_or_having(token: Token | None) -> bool:
4343
return bool(token and token.value and token.value.lower() in ("where", "having"))
4444

4545

46+
def _find_doubled_backticks(text: str) -> list[int]:
47+
length = len(text)
48+
doubled_backticks: list[int] = []
49+
backtick = '`'
50+
51+
for index in range(0, length):
52+
ch = text[index]
53+
if ch != backtick:
54+
index += 1
55+
continue
56+
if index + 1 < length and text[index + 1] == backtick:
57+
doubled_backticks.append(index)
58+
doubled_backticks.append(index + 1)
59+
index += 2
60+
continue
61+
index += 1
62+
63+
return doubled_backticks
64+
65+
4666
@functools.lru_cache(maxsize=128)
47-
def _is_inside_quotes(text: str, pos: int) -> bool:
67+
def is_inside_quotes(text: str, pos: int) -> Literal[False, 'single', 'double', 'backtick']:
4868
in_single = False
4969
in_double = False
70+
in_backticks = False
5071
escaped = False
51-
52-
for ch in text[:pos]:
53-
if escaped:
72+
doubled_backtick_positions = []
73+
single_quote = "'"
74+
double_quote = '"'
75+
backtick = '`'
76+
backslash = '\\'
77+
78+
# scanning the string twice seems to be needed to handle doubled backticks
79+
if backtick in text:
80+
doubled_backtick_positions = _find_doubled_backticks(text)
81+
82+
if pos < 0:
83+
pos = len(text) + pos + 1
84+
pos = max(pos, 0)
85+
86+
# optimization
87+
up_to_pos = text[:pos]
88+
if backtick not in up_to_pos and single_quote not in up_to_pos and double_quote not in up_to_pos:
89+
return False
90+
91+
for index in range(0, pos):
92+
ch = text[index]
93+
if index in doubled_backtick_positions:
94+
index += 1
95+
continue
96+
if escaped and (in_double or in_single):
5497
escaped = False
98+
index += 1
5599
continue
56-
if ch == "\\":
100+
if ch == backslash and (in_double or in_single):
57101
escaped = True
102+
index += 1
58103
continue
59-
if ch == "'" and not in_double:
104+
if ch == backtick and not in_double and not in_single:
105+
in_backticks = not in_backticks
106+
elif ch == single_quote and not in_double and not in_backticks:
60107
in_single = not in_single
61-
elif ch == '"' and not in_single:
108+
elif ch == double_quote and not in_single and not in_backticks:
62109
in_double = not in_double
63-
64-
return in_single or in_double
110+
index += 1
111+
112+
if in_single:
113+
return 'single'
114+
elif in_double:
115+
return 'double'
116+
elif in_backticks:
117+
return 'backtick'
118+
else:
119+
return False
65120

66121

67122
def suggest_type(full_text: str, text_before_cursor: str) -> list[dict[str, Any]]:
@@ -197,7 +252,7 @@ def suggest_based_on_last_token(
197252
# less efficient, but handles all cases
198253
# in fact, this is quite slow, but not as slow as offering completions!
199254
# faster would be to peek inside the Pygments lexer run by prompt_toolkit -- how?
200-
if _is_inside_quotes(text_before_cursor, -1):
255+
if is_inside_quotes(text_before_cursor, -1) in ['single', 'double']:
201256
return []
202257

203258
if isinstance(token, str):
@@ -383,7 +438,7 @@ def suggest_based_on_last_token(
383438
# "CREATE DATABASE <newdb> WITH TEMPLATE <db>"
384439
return [{"type": "database"}]
385440

386-
elif _is_inside_quotes(text_before_cursor, -1):
441+
elif is_inside_quotes(text_before_cursor, -1) in ['single', 'double']:
387442
return []
388443

389444
elif token_v.endswith(",") or is_operand(token_v) or token_v in ["=", "and", "or"]:

test/test_completion_engine.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import pytest
44

55
from mycli.packages import special
6-
from mycli.packages.completion_engine import suggest_type
6+
from mycli.packages.completion_engine import (
7+
_find_doubled_backticks,
8+
is_inside_quotes,
9+
suggest_type,
10+
)
711

812

913
def sorted_dicts(dicts):
@@ -628,3 +632,78 @@ def test_quoted_where():
628632
text = "'where i=';"
629633
suggestions = suggest_type(text, text)
630634
assert suggestions == [{"type": "keyword"}]
635+
636+
637+
def test_find_doubled_backticks_none():
638+
text = 'select `ab`'
639+
assert _find_doubled_backticks(text) == []
640+
641+
642+
def test_find_doubled_backticks_some():
643+
text = 'select `a``b`'
644+
assert _find_doubled_backticks(text) == [9, 10]
645+
646+
647+
def test_inside_quotes_01():
648+
text = "select '"
649+
assert is_inside_quotes(text, -1) == 'single'
650+
651+
652+
def test_inside_quotes_02():
653+
text = "select '\\'"
654+
assert is_inside_quotes(text, -1) == 'single'
655+
656+
657+
def test_inside_quotes_03():
658+
text = "select '`"
659+
assert is_inside_quotes(text, -1) == 'single'
660+
661+
662+
def test_inside_quotes_04():
663+
text = 'select "'
664+
assert is_inside_quotes(text, -1) == 'double'
665+
666+
667+
def test_inside_quotes_05():
668+
text = 'select "\\"\''
669+
assert is_inside_quotes(text, -1) == 'double'
670+
671+
672+
def test_inside_quotes_06():
673+
text = 'select ""'
674+
assert is_inside_quotes(text, -1) is False
675+
676+
677+
@pytest.mark.parametrize(
678+
["text", "position", "expected"],
679+
[
680+
("select `'", -1, 'backtick'),
681+
('select `ab`', -1, False),
682+
('select `ab`', -2, 'backtick'),
683+
('select `a``b`', -1, False),
684+
('select `a``b`', -2, 'backtick'),
685+
('select `a``b`', -3, 'backtick'),
686+
('select `a``b`', -4, 'backtick'),
687+
('select `a``b`', -5, 'backtick'),
688+
('select `a``b`', -6, 'backtick'),
689+
('select `a``b`', -7, False),
690+
]
691+
) # fmt: skip
692+
def test_inside_quotes_backtick_01(text, position, expected):
693+
assert is_inside_quotes(text, position) == expected
694+
695+
696+
def test_inside_quotes_backtick_02():
697+
"""Empty backtick pairs are treated as a doubled (escaped) backtick.
698+
This is okay because it is invalid SQL, and we don't have to complete on it.
699+
"""
700+
text = 'select ``'
701+
assert is_inside_quotes(text, -1) is False
702+
703+
704+
def test_inside_quotes_backtick_03():
705+
"""Empty backtick pairs are treated as a doubled (escaped) backtick.
706+
This is okay because it is invalid SQL, and we don't have to complete on it.
707+
"""
708+
text = 'select ``'
709+
assert is_inside_quotes(text, -2) is False

0 commit comments

Comments
 (0)