Skip to content

Commit 57ee87c

Browse files
authored
Backport PR ipython#14838 on branch 8.x (Fix Tab Completion Context Detection) (ipython#14855)
Backport PR ipython#14838 on branch 8.x (Fix Tab Completion Context Detection)
2 parents dd14727 + 6224bca commit 57ee87c

File tree

2 files changed

+274
-4
lines changed

2 files changed

+274
-4
lines changed

IPython/core/completer.py

Lines changed: 155 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,7 @@ def attr_matches(self, text):
11481148
def _attr_matches(
11491149
self, text: str, include_prefix: bool = True
11501150
) -> Tuple[Sequence[str], str]:
1151-
m2 = self._ATTR_MATCH_RE.match(self.line_buffer)
1151+
m2 = self._ATTR_MATCH_RE.match(text)
11521152
if not m2:
11531153
return [], ""
11541154
expr, attr = m2.group(1, 2)
@@ -2356,11 +2356,163 @@ def _jedi_matches(
23562356
else:
23572357
return iter([])
23582358

2359+
class _CompletionContextType(enum.Enum):
2360+
ATTRIBUTE = "attribute" # For attribute completion
2361+
GLOBAL = "global" # For global completion
2362+
2363+
def _determine_completion_context(self, line):
2364+
"""
2365+
Determine whether the cursor is in an attribute or global completion context.
2366+
"""
2367+
# Cursor in string/comment → GLOBAL.
2368+
is_string, is_in_expression = self._is_in_string_or_comment(line)
2369+
if is_string and not is_in_expression:
2370+
return self._CompletionContextType.GLOBAL
2371+
2372+
# If we're in a template string expression, handle specially
2373+
if is_string and is_in_expression:
2374+
# Extract the expression part - look for the last { that isn't closed
2375+
expr_start = line.rfind("{")
2376+
if expr_start >= 0:
2377+
# We're looking at the expression inside a template string
2378+
expr = line[expr_start + 1 :]
2379+
# Recursively determine the context of the expression
2380+
return self._determine_completion_context(expr)
2381+
2382+
# Handle plain number literals - should be global context
2383+
# Ex: 3. -42.14 but not 3.1.
2384+
if re.search(r"(?<!\w)(?<!\d\.)([-+]?\d+\.(\d+)?)(?!\w)$", line):
2385+
return self._CompletionContextType.GLOBAL
2386+
2387+
# Handle all other attribute matches np.ran, d[0].k, (a,b).count
2388+
chain_match = re.search(r".*(.+\.(?:[a-zA-Z]\w*)?)$", line)
2389+
if chain_match:
2390+
return self._CompletionContextType.ATTRIBUTE
2391+
2392+
return self._CompletionContextType.GLOBAL
2393+
2394+
def _is_in_string_or_comment(self, text):
2395+
"""
2396+
Determine if the cursor is inside a string or comment.
2397+
Returns (is_string, is_in_expression) tuple:
2398+
- is_string: True if in any kind of string
2399+
- is_in_expression: True if inside an f-string/t-string expression
2400+
"""
2401+
in_single_quote = False
2402+
in_double_quote = False
2403+
in_triple_single = False
2404+
in_triple_double = False
2405+
in_template_string = False # Covers both f-strings and t-strings
2406+
in_expression = False # For expressions in f/t-strings
2407+
expression_depth = 0 # Track nested braces in expressions
2408+
i = 0
2409+
2410+
while i < len(text):
2411+
# Check for f-string or t-string start
2412+
if (
2413+
i + 1 < len(text)
2414+
and text[i] in ("f", "t")
2415+
and (text[i + 1] == '"' or text[i + 1] == "'")
2416+
and not (
2417+
in_single_quote
2418+
or in_double_quote
2419+
or in_triple_single
2420+
or in_triple_double
2421+
)
2422+
):
2423+
in_template_string = True
2424+
i += 1 # Skip the 'f' or 't'
2425+
2426+
# Handle triple quotes
2427+
if i + 2 < len(text):
2428+
if (
2429+
text[i : i + 3] == '"""'
2430+
and not in_single_quote
2431+
and not in_triple_single
2432+
):
2433+
in_triple_double = not in_triple_double
2434+
if not in_triple_double:
2435+
in_template_string = False
2436+
i += 3
2437+
continue
2438+
if (
2439+
text[i : i + 3] == "'''"
2440+
and not in_double_quote
2441+
and not in_triple_double
2442+
):
2443+
in_triple_single = not in_triple_single
2444+
if not in_triple_single:
2445+
in_template_string = False
2446+
i += 3
2447+
continue
2448+
2449+
# Handle escapes
2450+
if text[i] == "\\" and i + 1 < len(text):
2451+
i += 2
2452+
continue
2453+
2454+
# Handle nested braces within f-strings
2455+
if in_template_string:
2456+
# Special handling for consecutive opening braces
2457+
if i + 1 < len(text) and text[i : i + 2] == "{{":
2458+
i += 2
2459+
continue
2460+
2461+
# Detect start of an expression
2462+
if text[i] == "{":
2463+
# Only increment depth and mark as expression if not already in an expression
2464+
# or if we're at a top-level nested brace
2465+
if not in_expression or (in_expression and expression_depth == 0):
2466+
in_expression = True
2467+
expression_depth += 1
2468+
i += 1
2469+
continue
2470+
2471+
# Detect end of an expression
2472+
if text[i] == "}":
2473+
expression_depth -= 1
2474+
if expression_depth <= 0:
2475+
in_expression = False
2476+
expression_depth = 0
2477+
i += 1
2478+
continue
2479+
2480+
in_triple_quote = in_triple_single or in_triple_double
2481+
2482+
# Handle quotes - also reset template string when closing quotes are encountered
2483+
if text[i] == '"' and not in_single_quote and not in_triple_quote:
2484+
in_double_quote = not in_double_quote
2485+
if not in_double_quote and not in_triple_quote:
2486+
in_template_string = False
2487+
elif text[i] == "'" and not in_double_quote and not in_triple_quote:
2488+
in_single_quote = not in_single_quote
2489+
if not in_single_quote and not in_triple_quote:
2490+
in_template_string = False
2491+
2492+
# Check for comment
2493+
if text[i] == "#" and not (
2494+
in_single_quote or in_double_quote or in_triple_quote
2495+
):
2496+
return True, False
2497+
2498+
i += 1
2499+
2500+
is_string = (
2501+
in_single_quote or in_double_quote or in_triple_single or in_triple_double
2502+
)
2503+
2504+
# Return tuple (is_string, is_in_expression)
2505+
return (
2506+
is_string or (in_template_string and not in_expression),
2507+
in_expression and expression_depth > 0,
2508+
)
2509+
23592510
@context_matcher()
23602511
def python_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
23612512
"""Match attributes or global python names"""
2362-
text = context.line_with_cursor
2363-
if "." in text:
2513+
text = context.text_until_cursor
2514+
completion_type = self._determine_completion_context(text)
2515+
if completion_type == self._CompletionContextType.ATTRIBUTE:
23642516
try:
23652517
matches, fragment = self._attr_matches(text, include_prefix=False)
23662518
if text.endswith(".") and self.omit__names:

IPython/core/tests/test_completer.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,6 @@ def _(line, cursor_pos, expect, message, completion):
625625
"Should have completed on a[0].r: %s",
626626
Completion(5, 6, "real"),
627627
)
628-
629628
_(
630629
"a[0].from_",
631630
10,
@@ -745,6 +744,46 @@ class A:
745744
words = completer.get__all__entries(A())
746745
self.assertEqual(words, [])
747746

747+
def test_completes_globals_as_args_of_methods(self):
748+
ip = get_ipython()
749+
c = ip.Completer
750+
c.use_jedi = False
751+
ip.ex("long_variable_name = 1")
752+
ip.ex("a = []")
753+
s, matches = c.complete(None, "a.sort(lo")
754+
self.assertIn("long_variable_name", matches)
755+
756+
def test_completes_attributes_in_fstring_expressions(self):
757+
ip = get_ipython()
758+
c = ip.Completer
759+
c.use_jedi = False
760+
761+
class CustomClass:
762+
def method_one(self):
763+
pass
764+
765+
ip.user_ns["custom_obj"] = CustomClass()
766+
767+
# Test completion inside f-string expressions
768+
s, matches = c.complete(None, "f'{custom_obj.meth")
769+
self.assertIn(".method_one", matches)
770+
771+
def test_completes_in_dict_expressions(self):
772+
ip = get_ipython()
773+
c = ip.Completer
774+
c.use_jedi = False
775+
ip.ex("class Test: pass")
776+
ip.ex("test_obj = Test()")
777+
ip.ex("test_obj.attribute = 'value'")
778+
779+
# Test completion in dictionary expressions
780+
s, matches = c.complete(None, "d = {'key': test_obj.attr")
781+
self.assertIn(".attribute", matches)
782+
783+
# Test global completion in dictionary expressions with dots
784+
s, matches = c.complete(None, "d = {'k.e.y': Te")
785+
self.assertIn("Test", matches)
786+
748787
def test_func_kw_completions(self):
749788
ip = get_ipython()
750789
c = ip.Completer
@@ -1745,6 +1784,85 @@ def _(expected):
17451784
_(["completion_a"])
17461785

17471786

1787+
@pytest.mark.parametrize(
1788+
"line,expected",
1789+
[
1790+
# Basic test cases
1791+
("np.", "attribute"),
1792+
("np.ran", "attribute"),
1793+
("np.random.rand(np.random.ran", "attribute"),
1794+
("np.random.rand(n", "global"),
1795+
("d['k.e.y.'](ran", "global"),
1796+
("d[0].k", "attribute"),
1797+
("a = { 'a': np.ran", "attribute"),
1798+
("n", "global"),
1799+
("", "global"),
1800+
# Dots in string literals
1801+
('some_var = "this is a string with a dot.', "global"),
1802+
("text = 'another string with a dot.", "global"),
1803+
('f"greeting {user.na', "attribute"), # Cursor in f-string expression
1804+
('t"welcome {guest.na', "attribute"), # Cursor in t-string expression
1805+
('f"hello {name} worl', "global"), # Cursor in f-string outside expression
1806+
('f"hello {{a.', "global"),
1807+
('f"hello {{{a.', "attribute"),
1808+
# Backslash escapes in strings
1809+
('var = "string with \\"escaped quote and a dot.', "global"),
1810+
("escaped = 'single \\'quote\\' with a dot.", "global"),
1811+
# Multi-line strings
1812+
('multi = """This is line one\nwith a dot.', "global"),
1813+
("multi_single = '''Another\nmulti-line\nwith a dot.", "global"),
1814+
# Inline comments
1815+
("x = 5 # This is a comment", "global"),
1816+
("y = obj.method() # Comment after dot.method", "global"),
1817+
# Hash symbol within string literals should not be treated as comments
1818+
("d['#'] = np.", "attribute"),
1819+
# Nested parentheses with dots
1820+
("complex_expr = (func((obj.method(param.attr", "attribute"),
1821+
("multiple_nesting = {key: [value.attr", "attribute"),
1822+
# Numbers
1823+
("3.", "global"),
1824+
("3.14", "global"),
1825+
("-42.14", "global"),
1826+
("x = func(3.14", "global"),
1827+
("x = func(a3.", "attribute"),
1828+
("x = func(a3.12", "global"),
1829+
("3.1.", "attribute"),
1830+
("-3.1.", "attribute"),
1831+
("(3).", "attribute"),
1832+
# Additional cases
1833+
("", "global"),
1834+
('str_with_code = "x.attr', "global"),
1835+
('f"formatted {obj.attr', "attribute"),
1836+
('f"formatted {obj.attr}', "global"),
1837+
("dict_with_dots = {'key.with.dots': value.attr", "attribute"),
1838+
("d[f'{a}']['{a.", "global"),
1839+
],
1840+
)
1841+
def test_completion_context(line, expected):
1842+
"""Test completion context"""
1843+
ip = get_ipython()
1844+
get_context = ip.Completer._determine_completion_context
1845+
result = get_context(line)
1846+
assert result.value == expected, f"Failed on input: '{line}'"
1847+
1848+
1849+
@pytest.mark.xfail(reason="Completion context not yet supported")
1850+
@pytest.mark.parametrize(
1851+
"line, expected",
1852+
[
1853+
("f'{f'a.", "global"), # Nested f-string
1854+
("3a.", "global"), # names starting with numbers or other symbols
1855+
("$).", "global"), # random things with dot at end
1856+
],
1857+
)
1858+
def test_unsupported_completion_context(line, expected):
1859+
"""Test unsupported completion context"""
1860+
ip = get_ipython()
1861+
get_context = ip.Completer._determine_completion_context
1862+
result = get_context(line)
1863+
assert result.value == expected, f"Failed on input: '{line}'"
1864+
1865+
17481866
@pytest.mark.parametrize(
17491867
"setup,code,expected,not_expected",
17501868
[

0 commit comments

Comments
 (0)