Don't fold source after an f-string with an unmatched literal bracket - #858
Don't fold source after an f-string with an unmatched literal bracket#858gadievron wants to merge 1 commit into
Conversation
6256c29 to
2045417
Compare
real_code() left f-strings untouched so their expression offsets stay
real, but that also left any literal/format-spec ()[]{} text in the
source, where the _parens implicit-continuation pass miscounted it as a
real unmatched bracket -- blanking every following newline, desyncing
offsets, and making worder mis-read class-method def headers.
find_definition then returned None/the call site and Rename silently
corrupted the file (renamed the call, orphaned the def) for methods
defined after such an f-string. Module-level defs were immune.
Fix: after leaving f-strings in place, blank any ()[]{} char not covered
by an OP token (tokenize; Python 3.12+/PEP 701 only -- documented no-op
earlier); TokenError/SyntaxError falls back to prior behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2045417 to
896c632
Compare
yangfan-yf-yf
left a comment
There was a problem hiding this comment.
I reproduced two remaining correctness paths on 896c632.
First, the stated single-quoted multiline limitation is valid Python 3.12 syntax and still triggers the original corruption:
f"[{1 +
2}"
def g(): passcompile() accepts this source, but real_code() reduces its three newlines to zero. In the equivalent class-method case, find_definition() then fails inside patched AST processing. Since this is another PEP 701 spelling of the reported trigger, I think it needs to be handled in this change rather than left as a known limitation.
Second, tokenizing the whole source makes an unrelated incomplete suffix disable the fix for an earlier valid f-string:
f"[{x}"
def g(): pass
(The trailing open parenthesis raises TokenError, _blank_fstring_literal_brackets() returns the entire source unchanged, and the three newlines again collapse to zero. Rope routinely sees incomplete buffers while they are being edited, so the fallback should preserve the brackets already classified before the error, or the scan should be scoped so later invalid syntax cannot undo an earlier correction.
There is also a measurable cost from tokenizing the full module on each uncached real_code() call. Using rope/refactor/extract.py (41,840 characters) with 20 unique inputs over five trials, current master took 0.048–0.051 seconds and this head took 0.249–0.297 seconds, roughly 5–6× slower. Please scope the token scan to the relevant f-string spans or otherwise avoid a full-module tokenization for every detected f-string.
The focused simplify and find-definition suites pass (40 passed), and the covered single-line/triple-quoted cases behave correctly. The two correctness cases above need regression coverage before this is safe to merge.
Description
simplify.real_code()folds all source after an f-string that contains anunmatched literal bracket (e.g.
f"[{x}",f"({n} items", an ANSI escapef"\x1b[K{x}", an escaped bracef"{{", or a format spec likef"{x:>[}").real_codedeliberately leaves f-strings unblanked so their inner-expressionoffsets stay valid. The subsequent implicit-continuation pass
_parensthencounts the f-string's literal brackets too, so
parensgoes positive andnever returns to zero, and every newline to EOF is blanked. Everything below the
f-string collapses onto one logical line, so any offset-based analysis of that
code is wrong.
real_code's only consumer isworder, so the user-visible effect lands onworder's header predicate: for a class-attribute def whosedeffollowsthe f-string,
find_definitionresolves to the wrong location (or the call site)and
Renamesilently renames the usages while leaving thedefuntouched (orvice-versa) — no exception, no diagnostic. Module-level and nested defs are
immune (they don't go through that predicate).
Fix
_blank_fstring_literal_brackets()tokenizes the source (PEP 701, Python 3.12+)and blanks every
()[]{}character that is not covered by anOPtoken —i.e. the literal/format-spec brackets, and bracket characters that are literal
data inside a nested string. Real expression brackets (
f"{[1,2][0]}",f"{x:{width}}") areOPtokens and are left untouched. Blanking islength-preserving (one char → one space), so all offsets are unchanged; it is
required so the literal bracket stops swaying
_parens. On Python < 3.12 anf-string is one opaque
STRINGtoken and can't be split, so the pass is a strictno-op; a
TokenError/SyntaxErrorfallback leaves the source unchanged onincomplete/invalid mid-edit buffers.
Tests are version-gated with
only_for_versions_higher("3.12")and cover eachtrigger class above plus an end-to-end
find_definition+Renameregression, aset of preservation controls (real brackets untouched), a module-level immunity
control, and the invalid-input fallback.
Known limitation
The blanking pass is gated on rope's existing regex-based f-string detection
(
ignored_regions), which recognises single-line and triple-quoted f-strings butnot single-quoted multiline f-strings (
f"[{1 +⏎2}"); those still fold asbefore. Dropping the gate (tokenizing unconditionally) would close the remaining
case — happy to do that here instead if you'd prefer.
Related
Sibling of the still-open #499 (
patched_astsetsregion[0]=Noneon anunmatched bracket in an f-string): same trigger family, but a different code path
(
patched_astdoes not callreal_code). This PR does not fix #499. Samedesync class as the false-triple-quote bug #248 (fixed by #309).
Checklist