Skip to content

Commit 033e735

Browse files
committed
add-xfail-test-and-improve-context-detection
1 parent b050118 commit 033e735

2 files changed

Lines changed: 127 additions & 23 deletions

File tree

IPython/core/completer.py

Lines changed: 107 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2369,12 +2369,51 @@ def _determine_completion_context(self, line):
23692369
Determine whether the cursor is in an attribute or global completion context.
23702370
"""
23712371
# Cursor in string/comment → GLOBAL.
2372-
if self._is_in_string_or_comment(line):
2372+
is_string, is_in_expression = self._is_in_string_or_comment(line)
2373+
if is_string and not is_in_expression:
23732374
return self._CompletionContextType.GLOBAL
23742375

2375-
# Match 'anything-dot-word' at end for attribute context.
2376+
# If we're in a template string expression, handle specially
2377+
if is_string and is_in_expression:
2378+
# Extract the expression part - look for the last { that isn't closed
2379+
expr_start = line.rfind("{")
2380+
if expr_start >= 0:
2381+
# We're looking at the expression inside a template string
2382+
expr = line[expr_start + 1 :]
2383+
# Recursively determine the context of the expression
2384+
return self._determine_completion_context(expr)
2385+
2386+
# Match for number literals should come first
2387+
# Handle plain number literals - should be global context
2388+
if re.search(r"^[-+]?\d+\.(\d+)?$", line):
2389+
return self._CompletionContextType.GLOBAL
2390+
2391+
# Match numeric literals in parentheses followed by dot
2392+
# Handles cases like (3).to_
2393+
numeric_paren_attr_match = re.search(
2394+
r"\([-+]?\d+(\.\d*)?\)\.([a-zA-Z_][a-zA-Z0-9_]*)?$", line
2395+
)
2396+
if numeric_paren_attr_match:
2397+
return self._CompletionContextType.ATTRIBUTE
2398+
2399+
# Match float literals followed by dot and optional attribute
2400+
# Handles cases like 3.1.as_, -3.1.r_
2401+
float_attr_match = re.search(r"[-+]?\d+\.\d+\.([a-zA-Z_][a-zA-Z0-9_]*)?$", line)
2402+
if float_attr_match:
2403+
return self._CompletionContextType.ATTRIBUTE
2404+
2405+
# Handle indexed access followed by dot - like d[0].k
2406+
indexed_attr_match = re.search(
2407+
r"[a-zA-Z_][a-zA-Z0-9_]*\[[^\]]+\]\.([a-zA-Z_][a-zA-Z0-9_]*)?$", line
2408+
)
2409+
if indexed_attr_match:
2410+
return self._CompletionContextType.ATTRIBUTE
2411+
2412+
# Match 'word-dot-word' at end for attribute context.
23762413
# Ex: 'obj.', 'np.random.ran'.
2377-
chain_match = re.search(r"([a-zA-Z_].*)\.([a-zA-Z_][a-zA-Z0-9_]*)?$", line)
2414+
chain_match = re.search(
2415+
r"([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)?$", line
2416+
)
23782417
if chain_match:
23792418
return self._CompletionContextType.ATTRIBUTE
23802419

@@ -2383,12 +2422,19 @@ def _determine_completion_context(self, line):
23832422
return self._CompletionContextType.GLOBAL
23842423

23852424
def _is_in_string_or_comment(self, text):
2425+
"""
2426+
Determine if the cursor is inside a string or comment.
2427+
Returns (is_string, is_in_expression) tuple:
2428+
- is_string: True if in any kind of string
2429+
- is_in_expression: True if inside an f-string/t-string expression
2430+
"""
23862431
in_single_quote = False
23872432
in_double_quote = False
23882433
in_triple_single = False
23892434
in_triple_double = False
23902435
in_template_string = False # Covers both f-strings and t-strings
23912436
in_expression = False # For expressions in f/t-strings
2437+
expression_depth = 0 # Track nested braces in expressions
23922438
i = 0
23932439

23942440
while i < len(text):
@@ -2397,9 +2443,15 @@ def _is_in_string_or_comment(self, text):
23972443
i + 1 < len(text)
23982444
and text[i] in ("f", "t")
23992445
and (text[i + 1] == '"' or text[i + 1] == "'")
2446+
and not (
2447+
in_single_quote
2448+
or in_double_quote
2449+
or in_triple_single
2450+
or in_triple_double
2451+
)
24002452
):
24012453
in_template_string = True
2402-
i += 1
2454+
i += 1 # Skip the 'f' or 't'
24032455

24042456
# Handle triple quotes
24052457
if i + 2 < len(text):
@@ -2409,6 +2461,8 @@ def _is_in_string_or_comment(self, text):
24092461
and not in_triple_single
24102462
):
24112463
in_triple_double = not in_triple_double
2464+
if not in_triple_double:
2465+
in_template_string = False
24122466
i += 3
24132467
continue
24142468
if (
@@ -2417,6 +2471,8 @@ def _is_in_string_or_comment(self, text):
24172471
and not in_triple_double
24182472
):
24192473
in_triple_single = not in_triple_single
2474+
if not in_triple_single:
2475+
in_template_string = False
24202476
i += 3
24212477
continue
24222478

@@ -2426,54 +2482,82 @@ def _is_in_string_or_comment(self, text):
24262482
continue
24272483

24282484
# Handle expressions in f-strings or t-strings
2429-
if in_template_string and text[i] == "{":
2485+
if (
2486+
in_template_string
2487+
and text[i] == "{"
2488+
and not (in_expression and expression_depth > 0 and text[i - 1] != "{")
2489+
):
24302490
in_expression = True
2431-
elif in_template_string and text[i] == "}":
2432-
in_expression = False
2491+
expression_depth += 1
2492+
i += 1
2493+
continue
2494+
elif in_template_string and text[i] == "}" and in_expression:
2495+
expression_depth -= 1
2496+
if expression_depth == 0:
2497+
in_expression = False
2498+
i += 1
2499+
continue
2500+
2501+
# Handle nested braces within expressions
2502+
if in_expression and text[i] == "{":
2503+
expression_depth += 1
2504+
elif in_expression and text[i] == "}" and expression_depth > 0:
2505+
expression_depth -= 1
24332506

2434-
# Handle quotes
2507+
# Handle quotes - also reset template string when closing quotes are encountered
24352508
if (
24362509
text[i] == '"'
24372510
and not in_single_quote
24382511
and not in_triple_single
24392512
and not in_triple_double
24402513
):
24412514
in_double_quote = not in_double_quote
2515+
if (
2516+
not in_double_quote
2517+
and not in_triple_double
2518+
and not in_triple_single
2519+
):
2520+
in_template_string = False
24422521
elif (
24432522
text[i] == "'"
24442523
and not in_double_quote
24452524
and not in_triple_double
24462525
and not in_triple_single
24472526
):
24482527
in_single_quote = not in_single_quote
2528+
if (
2529+
not in_single_quote
2530+
and not in_triple_single
2531+
and not in_triple_double
2532+
):
2533+
in_template_string = False
24492534

24502535
# Check for comment
2451-
if text[i] == "#" and (
2452-
not (
2453-
in_single_quote
2454-
or in_double_quote
2455-
or in_triple_single
2456-
or in_triple_double
2457-
)
2458-
or in_expression
2536+
if text[i] == "#" and not (
2537+
in_single_quote
2538+
or in_double_quote
2539+
or in_triple_single
2540+
or in_triple_double
24592541
):
2460-
return True
2542+
return True, False
24612543

24622544
i += 1
24632545

2464-
# If we're in an expression (f-string or t-string), return False
2465-
if in_expression:
2466-
return False
2546+
is_string = (
2547+
in_single_quote or in_double_quote or in_triple_single or in_triple_double
2548+
)
24672549

2468-
# Otherwise, return True if we're in any type of string
2550+
# Return tuple (is_string, is_in_expression)
2551+
# For nested f-strings, we're in a string but not necessarily in an expression
24692552
return (
2470-
in_single_quote or in_double_quote or in_triple_single or in_triple_double
2553+
is_string or (in_template_string and not in_expression),
2554+
in_expression and expression_depth > 0,
24712555
)
24722556

24732557
@context_matcher()
24742558
def python_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
24752559
"""Match attributes or global python names"""
2476-
text = context.line_with_cursor
2560+
text = context.text_until_cursor
24772561
completion_type = self._determine_completion_context(text)
24782562
if completion_type == self._CompletionContextType.ATTRIBUTE:
24792563
try:

tests/test_completer.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1827,6 +1827,11 @@ def _(expected):
18271827
('f"formatted {obj.attr', "attribute"),
18281828
('f"formatted {obj.attr}', "global"),
18291829
("dict_with_dots = {'key.with.dots': value.attr", "attribute"),
1830+
("d[f'{a}']['{a.", "global"),
1831+
("3.", "global"),
1832+
("3.1.", "attribute"),
1833+
("-3.1.", "attribute"),
1834+
("(3).", "attribute"),
18301835
],
18311836
)
18321837
def test_completion_context(line, expected):
@@ -1837,6 +1842,21 @@ def test_completion_context(line, expected):
18371842
assert result.value == expected, f"Failed on input: '{line}'"
18381843

18391844

1845+
@pytest.mark.xfail(reason="Completion context not yet supported")
1846+
@pytest.mark.parametrize(
1847+
"line, expected",
1848+
[
1849+
("f'{f'a.", "global"),
1850+
],
1851+
)
1852+
def test_unsupported_completion_context(line, expected):
1853+
"""Test unsupported completion context"""
1854+
ip = get_ipython()
1855+
get_context = ip.Completer._determine_completion_context
1856+
result = get_context(line)
1857+
assert result.value == expected, f"Failed on input: '{line}'"
1858+
1859+
18401860
@pytest.mark.parametrize(
18411861
"setup,code,expected,not_expected",
18421862
[

0 commit comments

Comments
 (0)