Skip to content

Commit 8a80b12

Browse files
stevemadereclaude
andcommitted
Fix patchedast region for implicitly-concatenated f-strings
`_JoinedStr` builds its children as [start_quote, *FormattedValues, end_quote] and lets `_handle` consume each by forward search. The end-quote child therefore matches the first closing quote after the last interpolation -- which, in an implicit concatenation, closes only the part that held that interpolation, not the whole concatenation. The node's region was then understated and, worse, the scanner was left parked inside source text that had not been recognised as a string literal. A `#` in a trailing part -- `f"{a} one " f"#52 two"` -- then reads to `_good_token` as the start of a real comment, so the following `)` is skipped as commented-out and `consume` raises MismatchedTokenError (issue #879). The truncation itself predates the `#`: a hash-free concatenation such as `f"{a} one " f"52 two"` also produced a short region, it just failed silently because nothing downstream needed to match a token past the truncation point. `consume_string` already measures the full concatenation before `_JoinedStr` rewinds the scanner, so close the gap from its end offset rather than re-scanning. Plain (non-f) strings were never affected because `_Str` consumes via that same pattern. Fixes #879 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e1f2cc2 commit 8a80b12

3 files changed

Lines changed: 70 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- #623, #819, #863 Support MatchOr, MatchSequence, MatchStar (@jheld, @lieryan)
99
- #870 Add default implementation for is_dir() (@lieryan)
1010
- #872 Fix unicode handling in patchedast (@lieryan)
11+
- #879 Fix patchedast region for implicitly-concatenated f-strings whose trailing parts hold no interpolation (@stevemadere)
1112

1213
# Release 1.14.0
1314

‎rope/refactor/patchedast.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,27 @@ def end_quote_char():
408408
children.append(part)
409409
children.append(end_quote_char())
410410
self._handle(node, children)
411+
self._extend_joined_string_to(node, end)
412+
413+
def _extend_joined_string_to(self, node, string_end):
414+
"""Cover concatenation parts that follow the last interpolation.
415+
416+
`_handle` stops at the first closing quote it finds after the last
417+
`FormattedValue`, so in an implicit concatenation whose trailing parts
418+
are pure literal text -- ``f"{a} one " f"#52 two"`` -- it stops at the
419+
end of the part holding the interpolation. That both understates the
420+
node's region and parks the scanner inside the literal, where an
421+
unescaped ``#`` reads as the start of a comment and swallows the rest
422+
of the line. `consume_string` already measured the whole
423+
concatenation, so close the gap from its end offset.
424+
"""
425+
if self.source.offset >= string_end:
426+
return
427+
trailing_parts = self.source[self.source.offset : string_end]
428+
if self.children:
429+
node.sorted_children.append(trailing_parts)
430+
self.source.offset = string_end
431+
node.region = (node.region[0], string_end)
411432

412433
def _FormattedValue(self, node):
413434
children = []

‎ropetest/refactor/patchedasttest.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,48 @@ def test_handling_format_strings_with_implicit_join(self):
337337
)
338338
checker.check_children("FormattedValue", ["{", "", "Name", "", "}"])
339339

340+
@testutils.only_for_versions_higher("3.6")
341+
def test_handling_format_strings_with_implicit_join_trailing_literal_part(self):
342+
source = 'a = f"one {b}" f"two"\n'
343+
ast_frag = patchedast.get_patched_ast(source, True)
344+
checker = _ResultChecker(self, ast_frag)
345+
checker.check_children(
346+
"JoinedStr", ['f"', "one ", "FormattedValue", "", '"', ' f"two"']
347+
)
348+
checker.check_region("JoinedStr", 4, len('a = f"one {b}" f"two"'))
349+
350+
@testutils.only_for_versions_higher("3.6")
351+
def test_handling_format_strings_with_hash_in_joined_trailing_part(self):
352+
source = dedent("""\
353+
E(
354+
f"{a} one "
355+
f"#52 two")
356+
""")
357+
ast_frag = patchedast.get_patched_ast(source, True)
358+
checker = _ResultChecker(self, ast_frag)
359+
checker.check_children(
360+
"JoinedStr", ['f"', "", "FormattedValue", " one ", '"', '\n f"#52 two"']
361+
)
362+
checker.check_children("Call", ["Name", "", "(", "\n ", "JoinedStr", "", ")"])
363+
364+
@testutils.only_for_versions_higher("3.6")
365+
def test_handling_format_strings_with_hash_in_joined_trailing_part_assignment(self):
366+
source = 'a = 1\nb = (f"{a} one "\n f"#52 two")\n'
367+
ast_frag = patchedast.get_patched_ast(source, True)
368+
checker = _ResultChecker(self, ast_frag)
369+
checker.check_children(
370+
"JoinedStr",
371+
['f"', "", "FormattedValue", " one ", '"', '\n f"#52 two"'],
372+
)
373+
374+
@testutils.only_for_versions_higher("3.6")
375+
def test_handling_format_strings_followed_by_a_real_comment(self):
376+
source = 'a = f"one {b}" # trailing comment\nc = 1\n'
377+
ast_frag = patchedast.get_patched_ast(source, True)
378+
checker = _ResultChecker(self, ast_frag)
379+
checker.check_children("JoinedStr", ['f"', "one ", "FormattedValue", "", '"'])
380+
checker.check_region("JoinedStr", 4, len('a = f"one {b}"'))
381+
340382
@testutils.only_for_versions_higher("3.6")
341383
def test_handling_format_strings_with_format_spec(self):
342384
source = 'f"abc{a:01}"\n'
@@ -2001,6 +2043,12 @@ def __call__(self, node):
20012043
ast.call_for_nodes(self.ast, search)
20022044
return search.result
20032045

2046+
def check_region(self, text, start, end):
2047+
node = self._find_node(text)
2048+
if node is None:
2049+
self.test_case.fail("Node <%s> cannot be found" % text)
2050+
self.test_case.assertEqual((start, end), node.region)
2051+
20042052
def check_children(self, text, children):
20052053
node = self._find_node(text)
20062054
if node is None:

0 commit comments

Comments
 (0)